diff --git a/README.md b/README.md index 98f8683e..e13ad62e 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,6 @@ **Stop typing code. Start directing AI agents.** -> **[!WARNING]** -> -> **This project is no longer actively maintained.** The codebase is provided as-is. No bug fixes, security updates, or new features are being developed. -

Table of Contents

diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs new file mode 100644 index 00000000..008c1f68 --- /dev/null +++ b/apps/server/eslint.config.mjs @@ -0,0 +1,74 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import js from '@eslint/js'; +import ts from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +const eslintConfig = defineConfig([ + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + globals: { + // Node.js globals + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + fetch: 'readonly', + Response: 'readonly', + Request: 'readonly', + Headers: 'readonly', + FormData: 'readonly', + RequestInit: 'readonly', + // Timers + setTimeout: 'readonly', + setInterval: 'readonly', + clearTimeout: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + clearImmediate: 'readonly', + queueMicrotask: 'readonly', + // Node.js types + NodeJS: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': ts, + }, + rules: { + ...ts.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + // Server code frequently works with terminal output containing ANSI escape codes + 'no-control-regex': 'off', + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-nocheck': 'allow-with-description', + minimumDescriptionLength: 10, + }, + ], + }, + }, + globalIgnores(['dist/**', 'node_modules/**']), +]); + +export default eslintConfig; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index a78e9e83..a7ad979d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -368,24 +368,61 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur logger.warn('Failed to check for legacy settings migration:', err); } - // Apply logging settings from saved settings + // Fetch global settings once and reuse for logging config and feature reconciliation + let globalSettings: Awaited> | null = null; try { - const settings = await settingsService.getGlobalSettings(); - if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) { - setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]); - logger.info(`Server log level set to: ${settings.serverLogLevel}`); - } - // Apply request logging setting (default true if not set) - const enableRequestLog = settings.enableRequestLogging ?? true; - setRequestLoggingEnabled(enableRequestLog); - logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); + globalSettings = await settingsService.getGlobalSettings(); } catch (err) { - logger.warn('Failed to load logging settings, using defaults'); + logger.warn('Failed to load global settings, using defaults'); + } + + // Apply logging settings from saved settings + if (globalSettings) { + try { + if ( + globalSettings.serverLogLevel && + LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined + ) { + setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]); + logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`); + } + // Apply request logging setting (default true if not set) + const enableRequestLog = globalSettings.enableRequestLogging ?? true; + setRequestLoggingEnabled(enableRequestLog); + logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); + } catch (err) { + logger.warn('Failed to apply logging settings, using defaults'); + } } await agentService.initialize(); logger.info('Agent service initialized'); + // Reconcile feature states on startup + // After any type of restart (clean, forced, crash), features may be stuck in + // transient states (in_progress, interrupted, pipeline_*) that don't match reality. + // Reconcile them back to resting states before the UI is served. + if (globalSettings) { + try { + if (globalSettings.projects && globalSettings.projects.length > 0) { + let totalReconciled = 0; + for (const project of globalSettings.projects) { + const count = await autoModeService.reconcileFeatureStates(project.path); + totalReconciled += count; + } + if (totalReconciled > 0) { + logger.info( + `[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)` + ); + } else { + logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); + } + } + } catch (err) { + logger.warn('[STARTUP] Failed to reconcile feature states:', err); + } + } + // Bootstrap Codex model cache in background (don't block server startup) void codexModelCacheService.getModels().catch((err) => { logger.error('Failed to bootstrap Codex model cache:', err); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index a0c998d6..016447d7 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -21,6 +21,7 @@ import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js'; import { createCommitFeatureHandler } from './routes/commit-feature.js'; import { createApprovePlanHandler } from './routes/approve-plan.js'; import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; +import { createReconcileHandler } from './routes/reconcile.js'; /** * Create auto-mode routes. @@ -81,6 +82,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Ro validatePathParams('projectPath'), createResumeInterruptedHandler(autoModeService) ); + router.post( + '/reconcile', + validatePathParams('projectPath'), + createReconcileHandler(autoModeService) + ); return router; } diff --git a/apps/server/src/routes/auto-mode/routes/reconcile.ts b/apps/server/src/routes/auto-mode/routes/reconcile.ts new file mode 100644 index 00000000..96109051 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/reconcile.ts @@ -0,0 +1,53 @@ +/** + * Reconcile Feature States Handler + * + * On-demand endpoint to reconcile all feature states for a project. + * Resets features stuck in transient states (in_progress, interrupted, pipeline_*) + * back to resting states (ready/backlog) and emits events to update the UI. + * + * This is useful when: + * - The UI reconnects after a server restart + * - A client detects stale feature states + * - An admin wants to force-reset stuck features + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; + +const logger = createLogger('ReconcileFeatures'); + +interface ReconcileRequest { + projectPath: string; +} + +export function createReconcileHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as ReconcileRequest; + + if (!projectPath) { + res.status(400).json({ error: 'Project path is required' }); + return; + } + + logger.info(`Reconciling feature states for ${projectPath}`); + + try { + const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath); + + res.json({ + success: true, + reconciledCount, + message: + reconciledCount > 0 + ? `Reconciled ${reconciledCount} feature(s)` + : 'No features needed reconciliation', + }); + } catch (error) { + logger.error('Error reconciling feature states:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 29f7d075..c607e72e 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event return; } - // Check for duplicate title if title is provided - if (feature.title && feature.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${feature.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - const created = await featureLoader.create(projectPath, feature); // Emit feature_created event for hooks diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index a5b532c1..4d5e7a00 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { return; } - // Check for duplicate title if title is being updated - if (updates.title && updates.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle( - projectPath, - updates.title, - featureId // Exclude the current feature from duplicate check - ); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${updates.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - // Get the current feature to detect status changes const currentFeature = await featureLoader.get(projectPath, featureId); const previousStatus = currentFeature?.status as FeatureStatus | undefined; diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 992a7b48..a7df37bb 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -101,7 +101,12 @@ export function createWorktreeRoutes( requireValidWorktree, createPullHandler() ); - router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler()); + router.post( + '/checkout-branch', + validatePathParams('worktreePath'), + requireValidWorktree, + createCheckoutBranchHandler() + ); router.post( '/list-branches', validatePathParams('worktreePath'), diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index ffa6e5e3..23963480 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -2,15 +2,15 @@ * POST /checkout-branch endpoint - Create and checkout a new branch * * Note: Git repository validation (isGitRepo, hasCommits) is handled by - * the requireValidWorktree middleware in index.ts + * the requireValidWorktree middleware in index.ts. + * Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams + * middleware in index.ts. */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getErrorMessage, logError } from '../common.js'; - -const execAsync = promisify(exec); +import path from 'path'; +import { stat } from 'fs/promises'; +import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; export function createCheckoutBranchHandler() { return async (req: Request, res: Response): Promise => { @@ -36,27 +36,47 @@ export function createCheckoutBranchHandler() { return; } - // Validate branch name (basic validation) - const invalidChars = /[\s~^:?*\[\\]/; - if (invalidChars.test(branchName)) { + // Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/ + if (!isValidBranchName(branchName)) { res.status(400).json({ success: false, - error: 'Branch name contains invalid characters', + error: + 'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.', }); return; } - // Get current branch for reference - const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); + // Resolve and validate worktreePath to prevent traversal attacks. + // The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY, + // but we also resolve the path and verify it exists as a directory. + const resolvedPath = path.resolve(worktreePath); + try { + const stats = await stat(resolvedPath); + if (!stats.isDirectory()) { + res.status(400).json({ + success: false, + error: 'worktreePath is not a directory', + }); + return; + } + } catch { + res.status(400).json({ + success: false, + error: 'worktreePath does not exist or is not accessible', + }); + return; + } + + // Get current branch for reference (using argument array to avoid shell injection) + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + resolvedPath + ); const currentBranch = currentBranchOutput.trim(); // Check if branch already exists try { - await execAsync(`git rev-parse --verify ${branchName}`, { - cwd: worktreePath, - }); + await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath); // Branch exists res.status(400).json({ success: false, @@ -67,10 +87,8 @@ export function createCheckoutBranchHandler() { // Branch doesn't exist, good to create } - // Create and checkout the new branch - await execAsync(`git checkout -b ${branchName}`, { - cwd: worktreePath, - }); + // Create and checkout the new branch (using argument array to avoid shell injection) + await execGitCommand(['checkout', '-b', branchName], resolvedPath); res.json({ success: true, diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index c5ea6f9e..f0d620d4 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -125,19 +125,14 @@ export function createOpenInEditorHandler() { `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` ); - try { - const result = await openInFileManager(worktreePath); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${result.editorName}`, - editorName: result.editorName, - }, - }); - } catch (fallbackError) { - // Both editor and file manager failed - throw fallbackError; - } + const result = await openInFileManager(worktreePath); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); } } catch (error) { logError(error, 'Open in editor failed'); diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts index 2c713c01..97fe19e8 100644 --- a/apps/server/src/services/auto-mode/compat.ts +++ b/apps/server/src/services/auto-mode/compat.ts @@ -88,6 +88,10 @@ export class AutoModeServiceCompat { return this.globalService.markAllRunningFeaturesInterrupted(reason); } + async reconcileFeatureStates(projectPath: string): Promise { + return this.globalService.reconcileFeatureStates(projectPath); + } + // =========================================================================== // PER-PROJECT OPERATIONS (delegated to facades) // =========================================================================== diff --git a/apps/server/src/services/auto-mode/global-service.ts b/apps/server/src/services/auto-mode/global-service.ts index 0e0e7e52..90576f8c 100644 --- a/apps/server/src/services/auto-mode/global-service.ts +++ b/apps/server/src/services/auto-mode/global-service.ts @@ -205,4 +205,21 @@ export class GlobalAutoModeService { ); } } + + /** + * Reconcile all feature states for a project on server startup. + * + * Resets features stuck in transient states (in_progress, interrupted, pipeline_*) + * back to a resting state and emits events so the UI reflects corrected states. + * + * This should be called during server initialization to handle: + * - Clean shutdown: features already marked as interrupted + * - Forced kill / crash: features left in in_progress or pipeline_* states + * + * @param projectPath - The project path to reconcile + * @returns The number of features that were reconciled + */ + async reconcileFeatureStates(projectPath: string): Promise { + return this.featureStateManager.reconcileAllFeatureStates(projectPath); + } } diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 6438b5dc..40cffd7f 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -662,7 +662,7 @@ export class ClaudeUsageService { resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text - resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); + resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim(); } return { percentage: percentage ?? 0, resetTime, resetText }; diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index d81e539c..76cf3174 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -124,7 +124,7 @@ class DevServerService { /(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL - /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL + /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL ]; for (const pattern of urlPatterns) { diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index b33f6df6..1f8a4952 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -25,6 +25,7 @@ import { import { getFeatureDir, getFeaturesDir } from '@automaker/platform'; import * as secureFs from '../lib/secure-fs.js'; import type { EventEmitter } from '../lib/events.js'; +import type { AutoModeEventType } from './typed-event-bus.js'; import { getNotificationService } from './notification-service.js'; import { FeatureLoader } from './feature-loader.js'; @@ -268,20 +269,39 @@ export class FeatureStateManager { } /** - * Reset features that were stuck in transient states due to server crash. - * Called when auto mode is enabled to clean up from previous session. + * Shared helper that scans features in a project directory and resets any stuck + * in transient states (in_progress, interrupted, pipeline_*) back to resting states. * - * Resets: - * - in_progress features back to ready (if has plan) or backlog (if no plan) + * Also resets: * - generating planSpec status back to pending * - in_progress tasks back to pending * - * @param projectPath - The project path to reset features for + * @param projectPath - The project path to scan + * @param callerLabel - Label for log messages (e.g., 'resetStuckFeatures', 'reconcileAllFeatureStates') + * @returns Object with reconciledFeatures (id + status info), reconciledCount, and scanned count */ - async resetStuckFeatures(projectPath: string): Promise { + private async scanAndResetFeatures( + projectPath: string, + callerLabel: string + ): Promise<{ + reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }>; + reconciledFeatureIds: string[]; + reconciledCount: number; + scanned: number; + }> { const featuresDir = getFeaturesDir(projectPath); - let featuresScanned = 0; - let featuresReset = 0; + let scanned = 0; + let reconciledCount = 0; + const reconciledFeatureIds: string[] = []; + const reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }> = []; try { const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); @@ -289,7 +309,7 @@ export class FeatureStateManager { for (const entry of entries) { if (!entry.isDirectory()) continue; - featuresScanned++; + scanned++; const featurePath = path.join(featuresDir, entry.name, 'feature.json'); const result = await readJsonWithRecovery(featurePath, null, { maxBackups: DEFAULT_BACKUP_COUNT, @@ -300,14 +320,21 @@ export class FeatureStateManager { if (!feature) continue; let needsUpdate = false; + const originalStatus = feature.status; - // Reset in_progress features back to ready/backlog - if (feature.status === 'in_progress') { + // Reset features in active execution states back to a resting state + // After a server restart, no processes are actually running + const isActiveState = + originalStatus === 'in_progress' || + originalStatus === 'interrupted' || + (originalStatus != null && originalStatus.startsWith('pipeline_')); + + if (isActiveState) { const hasApprovedPlan = feature.planSpec?.status === 'approved'; feature.status = hasApprovedPlan ? 'ready' : 'backlog'; needsUpdate = true; logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}` + `[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` ); } @@ -316,7 +343,7 @@ export class FeatureStateManager { feature.planSpec.status = 'pending'; needsUpdate = true; logger.info( - `[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending` + `[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending` ); } @@ -327,13 +354,13 @@ export class FeatureStateManager { task.status = 'pending'; needsUpdate = true; logger.info( - `[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` + `[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` ); // Clear currentTaskId if it points to this reverted task if (feature.planSpec?.currentTaskId === task.id) { feature.planSpec.currentTaskId = undefined; logger.info( - `[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` + `[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` ); } } @@ -343,19 +370,94 @@ export class FeatureStateManager { if (needsUpdate) { feature.updatedAt = new Date().toISOString(); await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); - featuresReset++; + reconciledCount++; + reconciledFeatureIds.push(feature.id); + reconciledFeatures.push({ + id: feature.id, + previousStatus: originalStatus, + newStatus: feature.status, + }); } } - - logger.info( - `[resetStuckFeatures] Scanned ${featuresScanned} features, reset ${featuresReset} features for ${projectPath}` - ); } catch (error) { // If features directory doesn't exist, that's fine if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error); + logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error); } } + + return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned }; + } + + /** + * Reset features that were stuck in transient states due to server crash. + * Called when auto mode is enabled to clean up from previous session. + * + * Resets: + * - in_progress features back to ready (if has plan) or backlog (if no plan) + * - interrupted features back to ready (if has plan) or backlog (if no plan) + * - pipeline_* features back to ready (if has plan) or backlog (if no plan) + * - generating planSpec status back to pending + * - in_progress tasks back to pending + * + * @param projectPath - The project path to reset features for + */ + async resetStuckFeatures(projectPath: string): Promise { + const { reconciledCount, scanned } = await this.scanAndResetFeatures( + projectPath, + 'resetStuckFeatures' + ); + + logger.info( + `[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}` + ); + } + + /** + * Reconcile all feature states on server startup. + * + * This method resets all features stuck in transient states (in_progress, + * interrupted, pipeline_*) and emits events so connected UI clients + * immediately reflect the corrected states. + * + * Should be called once during server initialization, before the UI is served, + * to ensure feature state consistency after any type of restart (clean, forced, crash). + * + * @param projectPath - The project path to reconcile features for + * @returns The number of features that were reconciled + */ + async reconcileAllFeatureStates(projectPath: string): Promise { + logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`); + + const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } = + await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates'); + + // Emit per-feature status change events so UI invalidates its cache + for (const { id, previousStatus, newStatus } of reconciledFeatures) { + this.emitAutoModeEvent('feature_status_changed', { + featureId: id, + projectPath, + status: newStatus, + previousStatus, + reason: 'server_restart_reconciliation', + }); + } + + // Emit a bulk reconciliation event for the UI + if (reconciledCount > 0) { + this.emitAutoModeEvent('features_reconciled', { + projectPath, + reconciledCount, + reconciledFeatureIds, + message: `Reconciled ${reconciledCount} feature(s) after server restart`, + }); + } + + logger.info( + `[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}` + ); + + return reconciledCount; } /** @@ -532,7 +634,7 @@ export class FeatureStateManager { * @param eventType - The event type (e.g., 'auto_mode_summary') * @param data - The event payload */ - private emitAutoModeEvent(eventType: string, data: Record): void { + private emitAutoModeEvent(eventType: AutoModeEventType, data: Record): void { // Wrap the event in auto-mode:event format expected by the client this.events.emit('auto-mode:event', { type: eventType, diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index efa32802..0d43252f 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -888,7 +888,7 @@ ${contextSection}${existingWorkSection}`; for (const line of lines) { // Check for numbered items or markdown headers - const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/); + const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/); if (titleMatch) { // Save previous suggestion diff --git a/apps/server/src/services/typed-event-bus.ts b/apps/server/src/services/typed-event-bus.ts index 11424826..09d1e9bc 100644 --- a/apps/server/src/services/typed-event-bus.ts +++ b/apps/server/src/services/typed-event-bus.ts @@ -40,9 +40,13 @@ export type AutoModeEventType = | 'plan_rejected' | 'plan_revision_requested' | 'plan_revision_warning' + | 'plan_spec_updated' | 'pipeline_step_started' | 'pipeline_step_complete' - | string; // Allow other strings for extensibility + | 'pipeline_test_failed' + | 'pipeline_merge_conflict' + | 'feature_status_changed' + | 'features_reconciled'; /** * TypedEventBus wraps an EventEmitter to provide type-safe event emission diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 3ad4d79d..2400404f 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -119,7 +119,15 @@ const eslintConfig = defineConfig([ }, rules: { ...ts.configs.recommended.rules, - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/ban-ts-comment': [ 'error', diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index a1e1fcec..768b40a5 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -590,6 +590,7 @@ export function BoardView() { handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, } = useBoardActions({ currentProject, features: hookFeatures, @@ -1503,6 +1504,8 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }, + onDuplicate: (feature) => handleDuplicateFeature(feature, false), + onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true), }} runningAutoTasks={runningAutoTasksAllWorktrees} pipelineConfig={pipelineConfig} @@ -1542,6 +1545,8 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }} + onDuplicate={(feature) => handleDuplicateFeature(feature, false)} + onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasksAllWorktrees} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index cc97b202..ac80d7ed 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck - header component props with optional handlers and status variants import { memo, useState } from 'react'; import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'; import { Feature } from '@/store/app-store'; @@ -9,6 +8,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -20,6 +22,7 @@ import { ChevronDown, ChevronUp, GitFork, + Copy, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { CountUpTimer } from '@/components/ui/count-up-timer'; @@ -27,6 +30,65 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser'; import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; import { getProviderIconForModel } from '@/components/ui/provider-icon'; +function DuplicateMenuItems({ + onDuplicate, + onDuplicateAsChild, +}: { + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; +}) { + if (!onDuplicate) return null; + + // When there's no sub-child action, render a simple menu item (no DropdownMenuSub wrapper) + if (!onDuplicateAsChild) { + return ( + { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs" + > + + Duplicate + + ); + } + + // When sub-child action is available, render a proper DropdownMenuSub with + // DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions + return ( + + + + Duplicate + + + { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs" + > + + Duplicate + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + + ); +} + interface CardHeaderProps { feature: Feature; isDraggable: boolean; @@ -36,6 +98,8 @@ interface CardHeaderProps { onDelete: () => void; onViewOutput?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; dragHandleListeners?: DraggableSyntheticListeners; dragHandleAttributes?: DraggableAttributes; } @@ -49,6 +113,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({ onDelete, onViewOutput, onSpawnTask, + onDuplicate, + onDuplicateAsChild, dragHandleListeners, dragHandleAttributes, }: CardHeaderProps) { @@ -71,7 +137,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
- {feature.startedAt && ( + {typeof feature.startedAt === 'string' && ( Spawn Sub-Task + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); @@ -162,6 +232,29 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > + {/* Only render overflow menu when there are actionable items */} + {onDuplicate && ( + + + + + + + + + )}
)} @@ -187,22 +280,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > - {onViewOutput && ( + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-${ + feature.status === 'waiting_approval' ? 'waiting' : 'verified' + }-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + + +
)} @@ -302,6 +414,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index eb44c49b..ab109d5f 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -52,6 +52,8 @@ interface KanbanCardProps { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; @@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, + onDuplicateAsChild, hasContext, isCurrentAutoTask, shortcutKey, @@ -254,6 +258,8 @@ export const KanbanCard = memo(function KanbanCard({ onDelete={onDelete} onViewOutput={onViewOutput} onSpawnTask={onSpawnTask} + onDuplicate={onDuplicate} + onDuplicateAsChild={onDuplicateAsChild} dragHandleListeners={isDraggable ? listeners : undefined} dragHandleAttributes={isDraggable ? attributes : undefined} /> diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 0a08b127..cac687eb 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -42,6 +42,8 @@ export interface ListViewActionHandlers { onViewPlan?: (feature: Feature) => void; onApprovePlan?: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature) => void; + onDuplicateAsChild?: (feature: Feature) => void; } export interface ListViewProps { @@ -313,6 +315,18 @@ export const ListView = memo(function ListView({ if (f) actionHandlers.onSpawnTask?.(f); } : undefined, + duplicate: actionHandlers.onDuplicate + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicate?.(f); + } + : undefined, + duplicateAsChild: actionHandlers.onDuplicateAsChild + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicateAsChild?.(f); + } + : undefined, }); }, [actionHandlers, allFeatures] diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index bb5c53d1..60158d0f 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -14,6 +14,7 @@ import { GitBranch, GitFork, ExternalLink, + Copy, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -22,6 +23,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import type { Feature } from '@/store/app-store'; @@ -43,6 +47,8 @@ export interface RowActionHandlers { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; } export interface RowActionsProps { @@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({ onClick={withClose(handlers.onSpawnTask)} /> )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} void; approvePlan?: (id: string) => void; spawnTask?: (id: string) => void; + duplicate?: (id: string) => void; + duplicateAsChild?: (id: string) => void; } ): RowActionHandlers { return { @@ -631,5 +764,9 @@ export function createRowActionHandlers( onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined, onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined, onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined, + onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined, + onDuplicateAsChild: actions.duplicateAsChild + ? () => actions.duplicateAsChild!(featureId) + : undefined, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index aedeebae..75d49030 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -517,7 +517,7 @@ export function useBoardActions({ } removeFeature(featureId); - persistFeatureDelete(featureId); + await persistFeatureDelete(featureId); }, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete] ); @@ -1090,6 +1090,38 @@ export function useBoardActions({ }); }, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]); + const handleDuplicateFeature = useCallback( + async (feature: Feature, asChild: boolean = false) => { + // Copy all feature data, stripping id, status (handled by create), and runtime/state fields + const { + id: _id, + status: _status, + startedAt: _startedAt, + error: _error, + summary: _summary, + spec: _spec, + passes: _passes, + planSpec: _planSpec, + descriptionHistory: _descriptionHistory, + titleGenerating: _titleGenerating, + ...featureData + } = feature; + const duplicatedFeatureData = { + ...featureData, + // If duplicating as child, set source as dependency; otherwise keep existing + ...(asChild && { dependencies: [feature.id] }), + }; + + // Reuse the existing handleAddFeature logic + await handleAddFeature(duplicatedFeatureData); + + toast.success(asChild ? 'Duplicated as child' : 'Feature duplicated', { + description: `Created copy of: ${truncateDescription(feature.description || feature.title || '')}`, + }); + }, + [handleAddFeature] + ); + return { handleAddFeature, handleUpdateFeature, @@ -1110,5 +1142,6 @@ export function useBoardActions({ handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 48793f93..143e9c3a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -85,15 +85,48 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps throw new Error('Features API not available'); } - const result = await api.features.create(currentProject.path, feature as ApiFeature); - if (result.success && result.feature) { - updateFeature(result.feature.id, result.feature as Partial); - // Invalidate React Query cache to sync UI + // Capture previous cache snapshot for synchronous rollback on error + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(currentProject.path) + ); + + // Optimistically add to React Query cache for immediate board refresh + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (existing) => (existing ? [...existing, feature] : [feature]) + ); + + try { + const result = await api.features.create(currentProject.path, feature as ApiFeature); + if (result.success && result.feature) { + updateFeature(result.feature.id, result.feature as Partial); + // Update cache with server-confirmed feature before invalidating + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (features) => { + if (!features) return features; + return features.map((f) => + f.id === result.feature!.id ? { ...f, ...(result.feature as Feature) } : f + ); + } + ); + } else if (!result.success) { + throw new Error(result.error || 'Failed to create feature on server'); + } + // Always invalidate to sync with server state queryClient.invalidateQueries({ queryKey: queryKeys.features.all(currentProject.path), }); - } else if (!result.success) { - throw new Error(result.error || 'Failed to create feature on server'); + } catch (error) { + logger.error('Failed to persist feature creation:', error); + // Rollback optimistic update synchronously on error + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); + throw error; } }, [currentProject, updateFeature, queryClient] @@ -104,20 +137,42 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps async (featureId: string) => { if (!currentProject) return; + // Optimistically remove from React Query cache for immediate board refresh + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(currentProject.path) + ); + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing) + ); + try { const api = getElectronAPI(); if (!api.features) { - logger.error('Features API not available'); - return; + // Rollback optimistic deletion since we can't persist + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); + throw new Error('Features API not available'); } await api.features.delete(currentProject.path, featureId); - // Invalidate React Query cache to sync UI + // Invalidate to sync with server state queryClient.invalidateQueries({ queryKey: queryKeys.features.all(currentProject.path), }); } catch (error) { logger.error('Failed to persist feature deletion:', error); + // Rollback optimistic update on error + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } }, [currentProject, queryClient] diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 7f857392..1a84080b 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -46,6 +46,8 @@ interface KanbanBoardProps { onViewPlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature) => void; + onDuplicateAsChild?: (feature: Feature) => void; featuresWithContext: Set; runningAutoTasks: string[]; onArchiveAllVerified: () => void; @@ -282,6 +284,8 @@ export function KanbanBoard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, + onDuplicateAsChild, featuresWithContext, runningAutoTasks, onArchiveAllVerified, @@ -569,6 +573,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature)} + onDuplicateAsChild={() => onDuplicateAsChild?.(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} @@ -611,6 +617,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature)} + onDuplicateAsChild={() => onDuplicateAsChild?.(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx index b27ec3e4..ad82a4d7 100644 --- a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId { } function internalToFeature(internal: FeatureWithId): Feature { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _id, _locationIds, ...feature } = internal; return feature; } diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx index c5d6ddd4..b13f35e7 100644 --- a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx @@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId { } function internalToPhase(internal: PhaseWithId): RoadmapPhase { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _id, ...phase } = internal; return phase; } diff --git a/apps/ui/src/hooks/use-query-invalidation.ts b/apps/ui/src/hooks/use-query-invalidation.ts index 0c09977c..241538e3 100644 --- a/apps/ui/src/hooks/use-query-invalidation.ts +++ b/apps/ui/src/hooks/use-query-invalidation.ts @@ -38,6 +38,8 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [ 'plan_rejected', 'pipeline_step_started', 'pipeline_step_complete', + 'feature_status_changed', + 'features_reconciled', ]; /** diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 22079822..446b7b6f 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1062,7 +1062,6 @@ if (typeof window !== 'undefined') { } // Mock API for development/fallback when no backend is available -// eslint-disable-next-line @typescript-eslint/no-unused-vars const _getMockElectronAPI = (): ElectronAPI => { return { ping: async () => 'pong (mock)', diff --git a/apps/ui/src/store/test-runners-store.ts b/apps/ui/src/store/test-runners-store.ts index b763c15a..8f8f7984 100644 --- a/apps/ui/src/store/test-runners-store.ts +++ b/apps/ui/src/store/test-runners-store.ts @@ -155,7 +155,6 @@ export const useTestRunnersStore = create const finishedAt = new Date().toISOString(); // Remove from active sessions since it's no longer running - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; return { @@ -202,7 +201,6 @@ export const useTestRunnersStore = create const session = state.sessions[sessionId]; if (!session) return state; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [sessionId]: _, ...remainingSessions } = state.sessions; // Remove from active if this was the active session @@ -231,7 +229,6 @@ export const useTestRunnersStore = create }); // Remove from active - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree; return { diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index cf41dabe..82ab237a 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -3,7 +3,7 @@ */ import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; -import type { ParsedTask } from '@automaker/types'; +import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types'; export interface ImageAttachment { id?: string; // Optional - may not be present in messages loaded from server @@ -359,6 +359,21 @@ export type AutoModeEvent = title?: string; status?: string; }>; + } + | { + type: 'feature_status_changed'; + featureId: string; + projectPath?: string; + status: FeatureStatusWithPipeline; + previousStatus: FeatureStatusWithPipeline; + reason?: string; + } + | { + type: 'features_reconciled'; + projectPath?: string; + reconciledCount: number; + reconciledFeatureIds: string[]; + message: string; }; export type SpecRegenerationEvent = diff --git a/libs/types/src/pipeline.ts b/libs/types/src/pipeline.ts index 05a4b4aa..7190abbd 100644 --- a/libs/types/src/pipeline.ts +++ b/libs/types/src/pipeline.ts @@ -21,6 +21,7 @@ export type PipelineStatus = `pipeline_${string}`; export type FeatureStatusWithPipeline = | 'backlog' + | 'ready' | 'in_progress' | 'interrupted' | 'waiting_approval' diff --git a/start-automaker.sh b/start-automaker.sh index 6770db2c..497ad305 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -36,8 +36,24 @@ elif [[ "$OSTYPE" == "darwin"* ]]; then fi # Port configuration -DEFAULT_WEB_PORT=3007 -DEFAULT_SERVER_PORT=3008 +# Defaults can be overridden via AUTOMAKER_WEB_PORT and AUTOMAKER_SERVER_PORT env vars + +# Validate env-provided ports early (before colors are available) +if [ -n "$AUTOMAKER_WEB_PORT" ]; then + if ! [[ "$AUTOMAKER_WEB_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_WEB_PORT" -lt 1 ] || [ "$AUTOMAKER_WEB_PORT" -gt 65535 ]; then + echo "Error: AUTOMAKER_WEB_PORT must be a number between 1-65535, got '$AUTOMAKER_WEB_PORT'" + exit 1 + fi +fi +if [ -n "$AUTOMAKER_SERVER_PORT" ]; then + if ! [[ "$AUTOMAKER_SERVER_PORT" =~ ^[0-9]+$ ]] || [ "$AUTOMAKER_SERVER_PORT" -lt 1 ] || [ "$AUTOMAKER_SERVER_PORT" -gt 65535 ]; then + echo "Error: AUTOMAKER_SERVER_PORT must be a number between 1-65535, got '$AUTOMAKER_SERVER_PORT'" + exit 1 + fi +fi + +DEFAULT_WEB_PORT=${AUTOMAKER_WEB_PORT:-3007} +DEFAULT_SERVER_PORT=${AUTOMAKER_SERVER_PORT:-3008} PORT_SEARCH_MAX_ATTEMPTS=100 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT @@ -136,6 +152,9 @@ EXAMPLES: start-automaker.sh docker # Launch Docker dev container start-automaker.sh --version # Show version + AUTOMAKER_WEB_PORT=4000 AUTOMAKER_SERVER_PORT=4001 start-automaker.sh web + # Launch web mode on custom ports + KEYBOARD SHORTCUTS (in menu): Up/Down arrows Navigate between options Enter Select highlighted option @@ -146,6 +165,10 @@ HISTORY: Your last selected mode is remembered in: ~/.automaker_launcher_history Use --no-history to disable this feature +ENVIRONMENT VARIABLES: + AUTOMAKER_WEB_PORT Override default web/UI port (default: 3007) + AUTOMAKER_SERVER_PORT Override default API server port (default: 3008) + PLATFORMS: Linux, macOS, Windows (Git Bash, WSL, MSYS2, Cygwin)