diff --git a/apps/app/src/components/ui/dialog.tsx b/apps/app/src/components/ui/dialog.tsx index ea00207a..9986223d 100644 --- a/apps/app/src/components/ui/dialog.tsx +++ b/apps/app/src/components/ui/dialog.tsx @@ -49,16 +49,18 @@ function DialogOverlay({ ); } -function DialogContent({ - className, - children, - showCloseButton = true, - compact = false, - ...props -}: React.ComponentProps & { +export type DialogContentProps = Omit< + React.ComponentProps, + "ref" +> & { showCloseButton?: boolean; compact?: boolean; -}) { +}; + +const DialogContent = React.forwardRef< + HTMLDivElement, + DialogContentProps +>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => { // Check if className contains a custom max-width const hasCustomMaxWidth = typeof className === "string" && className.includes("max-w-"); @@ -67,6 +69,7 @@ function DialogContent({ ); -} +}); + +DialogContent.displayName = "DialogContent"; function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/apps/app/src/components/ui/radio-group.tsx b/apps/app/src/components/ui/radio-group.tsx index a62d67dc..3377f6dc 100644 --- a/apps/app/src/components/ui/radio-group.tsx +++ b/apps/app/src/components/ui/radio-group.tsx @@ -43,3 +43,4 @@ RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; export { RadioGroup, RadioGroupItem }; + diff --git a/apps/app/src/components/ui/switch.tsx b/apps/app/src/components/ui/switch.tsx index 24d00673..47e4151a 100644 --- a/apps/app/src/components/ui/switch.tsx +++ b/apps/app/src/components/ui/switch.tsx @@ -28,3 +28,4 @@ Switch.displayName = SwitchPrimitives.Root.displayName; export { Switch }; + diff --git a/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx index 66674648..cb6d2f0d 100644 --- a/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx +++ b/apps/app/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx @@ -53,3 +53,4 @@ export function ArchiveAllVerifiedDialog({ ); } + diff --git a/apps/app/tests/utils/views/board.ts b/apps/app/tests/utils/views/board.ts index c1407357..8782ac62 100644 --- a/apps/app/tests/utils/views/board.ts +++ b/apps/app/tests/utils/views/board.ts @@ -120,7 +120,9 @@ export async function getDragHandleForFeature( */ export async function clickAddFeature(page: Page): Promise { await page.click('[data-testid="add-feature-button"]'); - await page.waitForSelector('[data-testid="add-feature-dialog"]', { timeout: 5000 }); + await page.waitForSelector('[data-testid="add-feature-dialog"]', { + timeout: 5000, + }); } /** @@ -132,18 +134,22 @@ export async function fillAddFeatureDialog( options?: { branch?: string; category?: string } ): Promise { // Fill description (using the dropzone textarea) - const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first(); + const descriptionInput = page + .locator('[data-testid="add-feature-dialog"] textarea') + .first(); await descriptionInput.fill(description); // Fill branch if provided (it's a combobox autocomplete) if (options?.branch) { // First, select "Other branch" radio option if not already selected - const otherBranchRadio = page.locator('[data-testid="feature-radio-group"]').locator('[id="feature-other"]'); + const otherBranchRadio = page + .locator('[data-testid="feature-radio-group"]') + .locator('[id="feature-other"]'); await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 }); await otherBranchRadio.click(); // Wait for the branch input to appear await page.waitForTimeout(300); - + // Now click on the branch input (autocomplete) const branchInput = page.locator('[data-testid="feature-input"]'); await branchInput.waitFor({ state: "visible", timeout: 5000 }); @@ -151,7 +157,7 @@ export async function fillAddFeatureDialog( // Wait for the popover to open await page.waitForTimeout(300); // Type in the command input - const commandInput = page.locator('[cmdk-input]'); + const commandInput = page.locator("[cmdk-input]"); await commandInput.fill(options.branch); // Press Enter to select/create the branch await commandInput.press("Enter"); @@ -161,10 +167,12 @@ export async function fillAddFeatureDialog( // Fill category if provided (it's also a combobox autocomplete) if (options?.category) { - const categoryButton = page.locator('[data-testid="feature-category-input"]'); + const categoryButton = page.locator( + '[data-testid="feature-category-input"]' + ); await categoryButton.click(); await page.waitForTimeout(300); - const commandInput = page.locator('[cmdk-input]'); + const commandInput = page.locator("[cmdk-input]"); await commandInput.fill(options.category); await commandInput.press("Enter"); await page.waitForTimeout(200); @@ -210,8 +218,13 @@ export async function getWorktreeSelector(page: Page): Promise { /** * Click on a branch button in the worktree selector */ -export async function selectWorktreeBranch(page: Page, branchName: string): Promise { - const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") }); +export async function selectWorktreeBranch( + page: Page, + branchName: string +): Promise { + const branchButton = page.getByRole("button", { + name: new RegExp(branchName, "i"), + }); await branchButton.click(); await page.waitForTimeout(500); // Wait for UI to update } @@ -219,9 +232,13 @@ export async function selectWorktreeBranch(page: Page, branchName: string): Prom /** * Get the currently selected branch in the worktree selector */ -export async function getSelectedWorktreeBranch(page: Page): Promise { +export async function getSelectedWorktreeBranch( + page: Page +): Promise { // The main branch button has aria-pressed="true" when selected - const selectedButton = page.locator('[data-testid="worktree-selector"] button[aria-pressed="true"]'); + const selectedButton = page.locator( + '[data-testid="worktree-selector"] button[aria-pressed="true"]' + ); const text = await selectedButton.textContent().catch(() => null); return text?.trim() || null; } @@ -229,7 +246,12 @@ export async function getSelectedWorktreeBranch(page: Page): Promise { - const branchButton = page.getByRole("button", { name: new RegExp(branchName, "i") }); +export async function isWorktreeBranchVisible( + page: Page, + branchName: string +): Promise { + const branchButton = page.getByRole("button", { + name: new RegExp(branchName, "i"), + }); return await branchButton.isVisible().catch(() => false); } diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts index 9964289c..b37907c8 100644 --- a/apps/server/src/routes/app-spec/index.ts +++ b/apps/server/src/routes/app-spec/index.ts @@ -23,3 +23,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router { } + diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index 575d0758..b6168282 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -103,3 +103,4 @@ export function createDeleteApiKeyHandler() { } + diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 12831a3d..04ffc9f9 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -21,6 +21,7 @@ import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js"; import { createAutoModeOptions } from "../lib/sdk-options.js"; import { isAbortError, classifyError } from "../lib/error-handler.js"; import type { Feature } from "./feature-loader.js"; +import { FeatureLoader } from "./feature-loader.js"; import { getFeatureDir, getAutomakerDir } from "../lib/automaker-paths.js"; const execAsync = promisify(exec); @@ -35,9 +36,18 @@ interface RunningFeature { startTime: number; } +interface AutoLoopState { + projectPath: string; + maxConcurrency: number; + abortController: AbortController; + isRunning: boolean; +} + export class AutoModeService { private events: EventEmitter; private runningFeatures = new Map(); + private autoLoop: AutoLoopState | null = null; + private featureLoader = new FeatureLoader(); constructor(events: EventEmitter) { this.events = events; @@ -57,32 +67,47 @@ export class AutoModeService { isAutoMode = false ): Promise { if (this.runningFeatures.has(featureId)) { - throw new Error(`Feature ${featureId} is already running`); - } - - // Check if feature has existing context - if so, resume instead of starting fresh - const hasExistingContext = await this.contextExists(projectPath, featureId); - if (hasExistingContext) { - console.log( - `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh` - ); - return this.resumeFeature(projectPath, featureId, useWorktrees); + throw new Error("already running"); } + // Add to running features immediately to prevent race conditions const abortController = new AbortController(); - - // Emit feature start event early - this.emitAutoModeEvent("auto_mode_feature_start", { + const tempRunningFeature: RunningFeature = { featureId, projectPath, - feature: { - id: featureId, - title: "Loading...", - description: "Feature is starting", - }, - }); + worktreePath: null, + branchName: null, + abortController, + isAutoMode, + startTime: Date.now(), + }; + this.runningFeatures.set(featureId, tempRunningFeature); try { + // Check if feature has existing context - if so, resume instead of starting fresh + const hasExistingContext = await this.contextExists( + projectPath, + featureId + ); + if (hasExistingContext) { + console.log( + `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh` + ); + // Remove from running features temporarily, resumeFeature will add it back + this.runningFeatures.delete(featureId); + return this.resumeFeature(projectPath, featureId, useWorktrees); + } + + // Emit feature start event early + this.emitAutoModeEvent("auto_mode_feature_start", { + featureId, + projectPath, + feature: { + id: featureId, + title: "Loading...", + description: "Feature is starting", + }, + }); // Load feature details FIRST to get branchName const feature = await this.loadFeature(projectPath, featureId); if (!feature) { @@ -90,9 +115,9 @@ export class AutoModeService { } // Derive workDir from feature.branchName - // If no branchName, use the project path directly + // If no branchName, derive from feature ID: feature/{featureId} let worktreePath: string | null = null; - const branchName = feature.branchName || null; + const branchName = feature.branchName || `feature/${featureId}`; if (useWorktrees && branchName) { // Try to find existing worktree for this branch @@ -120,15 +145,9 @@ export class AutoModeService { ? path.resolve(worktreePath) : path.resolve(projectPath); - this.runningFeatures.set(featureId, { - featureId, - projectPath, - worktreePath, - branchName, - abortController, - isAutoMode, - startTime: Date.now(), - }); + // Update running feature with actual worktree info + tempRunningFeature.worktreePath = worktreePath; + tempRunningFeature.branchName = branchName; // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); @@ -169,7 +188,7 @@ export class AutoModeService { featureId, passes: true, message: `Feature completed in ${Math.round( - (Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 + (Date.now() - tempRunningFeature.startTime) / 1000 )}s`, projectPath, }); @@ -219,6 +238,10 @@ export class AutoModeService { featureId: string, useWorktrees = false ): Promise { + if (this.runningFeatures.has(featureId)) { + throw new Error("already running"); + } + // Check if context exists in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, "agent-output.md"); @@ -242,7 +265,9 @@ export class AutoModeService { ); } - // No context, start fresh + // No context, start fresh - executeFeature will handle adding to runningFeatures + // Remove the temporary entry we added + this.runningFeatures.delete(featureId); return this.executeFeature(projectPath, featureId, useWorktrees, false); } @@ -266,9 +291,10 @@ export class AutoModeService { const feature = await this.loadFeature(projectPath, featureId); // Derive workDir from feature.branchName + // If no branchName, derive from feature ID: feature/{featureId} let workDir = path.resolve(projectPath); let worktreePath: string | null = null; - const branchName = feature?.branchName || null; + const branchName = feature?.branchName || `feature/${featureId}`; if (useWorktrees && branchName) { // Try to find existing worktree for this branch @@ -700,6 +726,61 @@ Format your response as a structured markdown document.`; } } + /** + * Start the auto loop to process pending features + * @param projectPath - The project path + * @param maxConcurrency - Maximum number of features to process concurrently + * @returns Promise that resolves when the loop stops + */ + async startAutoLoop( + projectPath: string, + maxConcurrency: number + ): Promise { + if (this.autoLoop?.isRunning) { + throw new Error("Auto mode is already running"); + } + + const abortController = new AbortController(); + this.autoLoop = { + projectPath, + maxConcurrency, + abortController, + isRunning: true, + }; + + // Emit start event + this.emitAutoModeEvent("auto_mode_started", { + projectPath, + message: `Auto mode started with max concurrency: ${maxConcurrency}`, + }); + + // Start the loop + return this.runAutoLoop(); + } + + /** + * Stop the auto loop + * @returns Number of running features (should be 0 after stopping) + */ + async stopAutoLoop(): Promise { + if (!this.autoLoop?.isRunning) { + return 0; + } + + const runningCount = this.runningFeatures.size; + this.autoLoop.abortController.abort(); + this.autoLoop.isRunning = false; + this.autoLoop = null; + + // Emit stop event + this.emitAutoModeEvent("auto_mode_stopped", { + message: "Auto mode stopped", + runningCount, + }); + + return runningCount; + } + /** * Get current status */ @@ -734,6 +815,89 @@ Format your response as a structured markdown document.`; // Private helpers + /** + * Run the auto loop to process pending features + */ + private async runAutoLoop(): Promise { + if (!this.autoLoop) { + return; + } + + const { projectPath, maxConcurrency, abortController } = this.autoLoop; + const runningPromises = new Map>(); + + try { + while (!abortController.signal.aborted && this.autoLoop.isRunning) { + // Get all features + const allFeatures = await this.featureLoader.getAll(projectPath); + + // Filter to pending features that aren't already running + const pendingFeatures = allFeatures.filter( + (feature) => + feature.status === "pending" && + !this.runningFeatures.has(feature.id) && + !runningPromises.has(feature.id) + ); + + // Start new features up to max concurrency + while ( + runningPromises.size < maxConcurrency && + pendingFeatures.length > 0 && + !abortController.signal.aborted + ) { + const feature = pendingFeatures.shift(); + if (!feature) break; + + const promise = this.executeFeature( + projectPath, + feature.id, + true, // useWorktrees + true // isAutoMode + ) + .catch((error) => { + console.error( + `[AutoMode] Feature ${feature.id} failed in auto loop:`, + error + ); + }) + .finally(() => { + runningPromises.delete(feature.id); + }); + + runningPromises.set(feature.id, promise); + } + + // Wait a bit before checking again + if (runningPromises.size > 0) { + await Promise.race([ + Promise.allSettled(Array.from(runningPromises.values())), + new Promise((resolve) => { + abortController.signal.addEventListener( + "abort", + () => resolve(), + { + once: true, + } + ); + }), + ]); + } else { + // No features running, wait before checking again + await this.sleep(1000, abortController.signal); + } + } + } catch (error) { + if (!isAbortError(error)) { + console.error("[AutoMode] Auto loop error:", error); + } + } finally { + // Wait for all running features to complete + if (runningPromises.size > 0) { + await Promise.allSettled(Array.from(runningPromises.values())); + } + } + } + /** * Find an existing worktree for a given branch by checking git worktree list */ @@ -1209,31 +1373,42 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. useWorktrees: boolean ): Promise { if (this.runningFeatures.has(featureId)) { - throw new Error(`Feature ${featureId} is already running`); + throw new Error("already running"); } const abortController = new AbortController(); - - // Emit feature start event early - this.emitAutoModeEvent("auto_mode_feature_start", { + // Add to running features immediately + const tempRunningFeature: RunningFeature = { featureId, projectPath, - feature: { - id: featureId, - title: "Resuming...", - description: "Feature is resuming from previous context", - }, - }); + worktreePath: null, + branchName: null, + abortController, + isAutoMode: false, + startTime: Date.now(), + }; + this.runningFeatures.set(featureId, tempRunningFeature); try { + // Emit feature start event early + this.emitAutoModeEvent("auto_mode_feature_start", { + featureId, + projectPath, + feature: { + id: featureId, + title: "Resuming...", + description: "Feature is resuming from previous context", + }, + }); const feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } // Derive workDir from feature.branchName + // If no branchName, derive from feature ID: feature/{featureId} let worktreePath: string | null = null; - const branchName = feature.branchName || null; + const branchName = feature.branchName || `feature/${featureId}`; if (useWorktrees && branchName) { worktreePath = await this.findExistingWorktreeForBranch( @@ -1256,15 +1431,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. ? path.resolve(worktreePath) : path.resolve(projectPath); - this.runningFeatures.set(featureId, { - featureId, - projectPath, - worktreePath, - branchName, - abortController, - isAutoMode: false, - startTime: Date.now(), - }); + // Update running feature with actual worktree info + tempRunningFeature.worktreePath = worktreePath; + tempRunningFeature.branchName = branchName; // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); @@ -1315,7 +1484,7 @@ Review the previous work and continue the implementation. If the feature appears featureId, passes: true, message: `Feature resumed and completed in ${Math.round( - (Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 + (Date.now() - tempRunningFeature.startTime) / 1000 )}s`, projectPath, }); diff --git a/docs/server/route-organization.md b/docs/server/route-organization.md index bb8df194..410bd5b9 100644 --- a/docs/server/route-organization.md +++ b/docs/server/route-organization.md @@ -582,3 +582,4 @@ The route organization pattern provides: Apply this pattern to all route modules for consistency and improved code quality. +