diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts new file mode 100644 index 00000000..03eb044a --- /dev/null +++ b/apps/server/src/services/auto-mode/facade.ts @@ -0,0 +1,913 @@ +/** + * AutoModeServiceFacade - Clean interface for auto-mode functionality + * + * This facade provides a thin delegation layer over the extracted services, + * exposing all 23 public methods that routes currently call on AutoModeService. + * + * Key design decisions: + * - Per-project factory pattern (projectPath is implicit in method calls) + * - Clean method names (e.g., startAutoLoop instead of startAutoLoopForProject) + * - Thin delegation to underlying services - no new business logic + * - Maintains backward compatibility during transition period + */ + +import path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { Feature } from '@automaker/types'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; +import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import * as secureFs from '../../lib/secure-fs.js'; +import { validateWorkingDirectory } from '../../lib/sdk-options.js'; +import { getPromptCustomization } from '../../lib/settings-helpers.js'; +import { TypedEventBus } from '../typed-event-bus.js'; +import { ConcurrencyManager } from '../concurrency-manager.js'; +import { WorktreeResolver } from '../worktree-resolver.js'; +import { FeatureStateManager } from '../feature-state-manager.js'; +import { PlanApprovalService } from '../plan-approval-service.js'; +import { AutoLoopCoordinator, type AutoModeConfig } from '../auto-loop-coordinator.js'; +import { ExecutionService } from '../execution-service.js'; +import { RecoveryService } from '../recovery-service.js'; +import { PipelineOrchestrator } from '../pipeline-orchestrator.js'; +import { AgentExecutor } from '../agent-executor.js'; +import { TestRunnerService } from '../test-runner-service.js'; +import { FeatureLoader } from '../feature-loader.js'; +import type { SettingsService } from '../settings-service.js'; +import type { EventEmitter } from '../../lib/events.js'; +import type { + FacadeOptions, + AutoModeStatus, + ProjectAutoModeStatus, + WorktreeCapacityInfo, + RunningAgentInfo, + OrphanedFeatureInfo, +} from './types.js'; + +const execAsync = promisify(exec); +const logger = createLogger('AutoModeServiceFacade'); + +/** + * Generate a unique key for worktree-scoped auto loop state + * (mirrors the function in AutoModeService for status lookups) + */ +function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { + const normalizedBranch = branchName === 'main' ? null : branchName; + return `${projectPath}::${normalizedBranch ?? '__main__'}`; +} + +/** + * AutoModeServiceFacade provides a clean interface for auto-mode functionality. + * + * Created via factory pattern with a specific projectPath, allowing methods + * to use clean names without requiring projectPath as a parameter. + */ +export class AutoModeServiceFacade { + private constructor( + private readonly projectPath: string, + private readonly events: EventEmitter, + private readonly eventBus: TypedEventBus, + private readonly concurrencyManager: ConcurrencyManager, + private readonly worktreeResolver: WorktreeResolver, + private readonly featureStateManager: FeatureStateManager, + private readonly featureLoader: FeatureLoader, + private readonly planApprovalService: PlanApprovalService, + private readonly autoLoopCoordinator: AutoLoopCoordinator, + private readonly executionService: ExecutionService, + private readonly recoveryService: RecoveryService, + private readonly pipelineOrchestrator: PipelineOrchestrator, + private readonly settingsService: SettingsService | null + ) {} + + /** + * Create a new AutoModeServiceFacade instance for a specific project. + * + * @param projectPath - The project path this facade operates on + * @param options - Configuration options including events, settingsService, featureLoader + */ + static create(projectPath: string, options: FacadeOptions): AutoModeServiceFacade { + const { events, settingsService = null, featureLoader = new FeatureLoader() } = options; + + // Create core services + const eventBus = new TypedEventBus(events); + const worktreeResolver = new WorktreeResolver(); + const concurrencyManager = new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p)); + const featureStateManager = new FeatureStateManager(events, featureLoader); + const planApprovalService = new PlanApprovalService( + eventBus, + featureStateManager, + settingsService + ); + const agentExecutor = new AgentExecutor( + eventBus, + featureStateManager, + planApprovalService, + settingsService + ); + const testRunnerService = new TestRunnerService(); + + // Helper for building feature prompts (used by pipeline orchestrator) + const buildFeaturePrompt = ( + feature: Feature, + prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } + ): string => { + const title = + feature.title || feature.description?.split('\n')[0]?.substring(0, 60) || 'Untitled'; + let prompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${title}\n**Description:** ${feature.description}\n`; + if (feature.spec) { + prompt += `\n**Specification:**\n${feature.spec}\n`; + } + if (!feature.skipTests) { + prompt += `\n${prompts.implementationInstructions}\n\n${prompts.playwrightVerificationInstructions}`; + } else { + prompt += `\n${prompts.implementationInstructions}`; + } + return prompt; + }; + + // Create placeholder callbacks - will be bound to facade methods after creation + // These use closures to capture the facade instance once created + let facadeInstance: AutoModeServiceFacade | null = null; + + // PipelineOrchestrator - runAgentFn is a stub; routes use AutoModeService directly + const pipelineOrchestrator = new PipelineOrchestrator( + eventBus, + featureStateManager, + agentExecutor, + testRunnerService, + worktreeResolver, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, status) => + featureStateManager.updateFeatureStatus(pPath, featureId, status), + loadContextFiles, + buildFeaturePrompt, + (pPath, featureId, useWorktrees, _isAutoMode, _model, opts) => + facadeInstance!.executeFeature(featureId, useWorktrees, false, undefined, opts), + // runAgentFn stub - facade does not implement runAgent directly + async () => { + throw new Error('runAgentFn not implemented in facade'); + } + ); + + // AutoLoopCoordinator + const autoLoopCoordinator = new AutoLoopCoordinator( + eventBus, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, useWorktrees, isAutoMode) => + facadeInstance!.executeFeature(featureId, useWorktrees, isAutoMode), + (pPath, branchName) => + featureLoader + .getAll(pPath) + .then((features) => + features.filter( + (f) => + (f.status === 'backlog' || f.status === 'ready') && + (branchName === null + ? !f.branchName || f.branchName === 'main' + : f.branchName === branchName) + ) + ), + (pPath, branchName, maxConcurrency) => + facadeInstance!.saveExecutionStateForProject(branchName, maxConcurrency), + (pPath, branchName) => facadeInstance!.clearExecutionState(branchName), + (pPath) => featureStateManager.resetStuckFeatures(pPath), + (feature) => + feature.status === 'completed' || + feature.status === 'verified' || + feature.status === 'waiting_approval', + (featureId) => concurrencyManager.isRunning(featureId) + ); + + // ExecutionService - runAgentFn is a stub + const executionService = new ExecutionService( + eventBus, + concurrencyManager, + worktreeResolver, + settingsService, + // Callbacks - runAgentFn stub + async () => { + throw new Error('runAgentFn not implemented in facade'); + }, + (context) => pipelineOrchestrator.executePipeline(context), + (pPath, featureId, status) => + featureStateManager.updateFeatureStatus(pPath, featureId, status), + (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), + async (_feature) => { + // getPlanningPromptPrefixFn - planning prompts handled by AutoModeService + return ''; + }, + (pPath, featureId, summary) => + featureStateManager.saveFeatureSummary(pPath, featureId, summary), + async () => { + /* recordLearnings - stub */ + }, + (pPath, featureId) => facadeInstance!.contextExists(featureId), + (pPath, featureId, useWorktrees, _calledInternally) => + facadeInstance!.resumeFeature(featureId, useWorktrees, _calledInternally), + (errorInfo) => + autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, errorInfo), + (errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, errorInfo), + () => { + /* recordSuccess - no-op */ + }, + (_pPath) => facadeInstance!.saveExecutionState(), + loadContextFiles + ); + + // RecoveryService + const recoveryService = new RecoveryService( + eventBus, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, opts) => + facadeInstance!.executeFeature( + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + opts + ), + (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), + (pPath, featureId, status) => + pipelineOrchestrator.detectPipelineStatus(pPath, featureId, status), + (pPath, feature, useWorktrees, pipelineInfo) => + pipelineOrchestrator.resumePipeline(pPath, feature, useWorktrees, pipelineInfo), + (featureId) => concurrencyManager.isRunning(featureId), + (opts) => concurrencyManager.acquire(opts), + (featureId) => concurrencyManager.release(featureId) + ); + + // Create the facade instance + facadeInstance = new AutoModeServiceFacade( + projectPath, + events, + eventBus, + concurrencyManager, + worktreeResolver, + featureStateManager, + featureLoader, + planApprovalService, + autoLoopCoordinator, + executionService, + recoveryService, + pipelineOrchestrator, + settingsService + ); + + return facadeInstance; + } + + // =========================================================================== + // AUTO LOOP CONTROL (4 methods) + // =========================================================================== + + /** + * Start the auto mode loop for this project/worktree + * @param branchName - The branch name for worktree scoping, null for main worktree + * @param maxConcurrency - Maximum concurrent features + */ + async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise { + return this.autoLoopCoordinator.startAutoLoopForProject( + this.projectPath, + branchName, + maxConcurrency + ); + } + + /** + * Stop the auto mode loop for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + async stopAutoLoop(branchName: string | null = null): Promise { + return this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName); + } + + /** + * Check if auto mode is running for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + isAutoLoopRunning(branchName: string | null = null): boolean { + return this.autoLoopCoordinator.isAutoLoopRunningForProject(this.projectPath, branchName); + } + + /** + * Get auto loop config for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + getAutoLoopConfig(branchName: string | null = null): AutoModeConfig | null { + return this.autoLoopCoordinator.getAutoLoopConfigForProject(this.projectPath, branchName); + } + + // =========================================================================== + // FEATURE EXECUTION (6 methods) + // =========================================================================== + + /** + * Execute a single feature + * @param featureId - The feature ID to execute + * @param useWorktrees - Whether to use worktrees for isolation + * @param isAutoMode - Whether this is running in auto mode + * @param providedWorktreePath - Optional pre-resolved worktree path + * @param options - Additional execution options + */ + async executeFeature( + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { + continuationPrompt?: string; + _calledInternally?: boolean; + } + ): Promise { + return this.executionService.executeFeature( + this.projectPath, + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + options + ); + } + + /** + * Stop a specific feature + * @param featureId - ID of the feature to stop + */ + async stopFeature(featureId: string): Promise { + // Cancel any pending plan approval for this feature + this.cancelPlanApproval(featureId); + return this.executionService.stopFeature(featureId); + } + + /** + * Resume a feature (continues from saved context or starts fresh) + * @param featureId - ID of the feature to resume + * @param useWorktrees - Whether to use git worktrees + * @param _calledInternally - Internal flag for nested calls + */ + async resumeFeature( + featureId: string, + useWorktrees = false, + _calledInternally = false + ): Promise { + return this.recoveryService.resumeFeature( + this.projectPath, + featureId, + useWorktrees, + _calledInternally + ); + } + + /** + * Follow up on a feature with additional instructions + * @param featureId - The feature ID + * @param prompt - Follow-up prompt + * @param imagePaths - Optional image paths + * @param useWorktrees - Whether to use worktrees + */ + async followUpFeature( + featureId: string, + prompt: string, + imagePaths?: string[], + useWorktrees = true + ): Promise { + // This method contains substantial logic - delegates most work to AgentExecutor + validateWorkingDirectory(this.projectPath); + + const runningEntry = this.concurrencyManager.acquire({ + featureId, + projectPath: this.projectPath, + isAutoMode: false, + }); + const abortController = runningEntry.abortController; + + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + let workDir = path.resolve(this.projectPath); + let worktreePath: string | null = null; + const branchName = feature?.branchName || `feature/${featureId}`; + + if (useWorktrees && branchName) { + worktreePath = await this.worktreeResolver.findWorktreeForBranch( + this.projectPath, + branchName + ); + if (worktreePath) { + workDir = worktreePath; + } + } + + // Load previous context + const featureDir = getFeatureDir(this.projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + let previousContext = ''; + try { + previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; + } catch { + // No previous context + } + + const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); + + // Build follow-up prompt inline (no template in TaskExecutionPrompts) + let fullPrompt = `## Follow-up on Feature Implementation + +${feature ? `**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled'}\n**Description:** ${feature.description}` : `**Feature ID:** ${featureId}`} +`; + + if (previousContext) { + fullPrompt += ` +## Previous Agent Work +The following is the output from the previous implementation attempt: + +${previousContext} +`; + } + + fullPrompt += ` +## Follow-up Instructions +${prompt} + +## Task +Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; + + try { + this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath: this.projectPath, + branchName: feature?.branchName ?? null, + feature: { + id: featureId, + title: feature?.title || 'Follow-up', + description: feature?.description || 'Following up on feature', + }, + }); + + // NOTE: Facade does not have runAgent - this method requires AutoModeService + // For now, throw to indicate routes should use AutoModeService.followUpFeature + throw new Error( + 'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead' + ); + } catch (error) { + const errorInfo = classifyError(error); + if (!errorInfo.isAbort) { + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath: this.projectPath, + }); + } + throw error; + } finally { + this.concurrencyManager.release(featureId); + } + } + + /** + * Verify a feature's implementation + * @param featureId - The feature ID to verify + */ + async verifyFeature(featureId: string): Promise { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId); + let workDir = this.projectPath; + + try { + await secureFs.access(worktreePath); + workDir = worktreePath; + } catch { + // No worktree + } + + const verificationChecks = [ + { cmd: 'npm run lint', name: 'Lint' }, + { cmd: 'npm run typecheck', name: 'Type check' }, + { cmd: 'npm test', name: 'Tests' }, + { cmd: 'npm run build', name: 'Build' }, + ]; + + let allPassed = true; + const results: Array<{ check: string; passed: boolean; output?: string }> = []; + + for (const check of verificationChecks) { + try { + const { stdout, stderr } = await execAsync(check.cmd, { cwd: workDir, timeout: 120000 }); + results.push({ check: check.name, passed: true, output: stdout || stderr }); + } catch (error) { + allPassed = false; + results.push({ check: check.name, passed: false, output: (error as Error).message }); + break; + } + } + + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + passes: allPassed, + message: allPassed + ? 'All verification checks passed' + : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, + projectPath: this.projectPath, + }); + + return allPassed; + } + + /** + * Commit feature changes + * @param featureId - The feature ID to commit + * @param providedWorktreePath - Optional worktree path + */ + async commitFeature(featureId: string, providedWorktreePath?: string): Promise { + let workDir = this.projectPath; + + if (providedWorktreePath) { + try { + await secureFs.access(providedWorktreePath); + workDir = providedWorktreePath; + } catch { + // Use project path + } + } else { + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const legacyWorktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId); + try { + await secureFs.access(legacyWorktreePath); + workDir = legacyWorktreePath; + } catch { + // Use project path + } + } + + try { + const { stdout: status } = await execAsync('git status --porcelain', { cwd: workDir }); + if (!status.trim()) { + return null; + } + + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const title = + feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`; + const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`; + + await execAsync('git add -A', { cwd: workDir }); + await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: workDir }); + const { stdout: hash } = await execAsync('git rev-parse HEAD', { cwd: workDir }); + + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + passes: true, + message: `Changes committed: ${hash.trim().substring(0, 8)}`, + projectPath: this.projectPath, + }); + + return hash.trim(); + } catch (error) { + logger.error(`Commit failed for ${featureId}:`, error); + return null; + } + } + + // =========================================================================== + // STATUS AND QUERIES (7 methods) + // =========================================================================== + + /** + * Get current status (global across all projects) + */ + getStatus(): AutoModeStatus { + const allRunning = this.concurrencyManager.getAllRunning(); + return { + isRunning: allRunning.length > 0, + runningFeatures: allRunning.map((rf) => rf.featureId), + runningCount: allRunning.length, + }; + } + + /** + * Get status for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + getStatusForProject(branchName: string | null = null): ProjectAutoModeStatus { + const isAutoLoopRunning = this.autoLoopCoordinator.isAutoLoopRunningForProject( + this.projectPath, + branchName + ); + const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( + this.projectPath, + branchName + ); + const runningFeatures = this.concurrencyManager + .getAllRunning() + .filter((f) => f.projectPath === this.projectPath && f.branchName === branchName) + .map((f) => f.featureId); + + return { + isAutoLoopRunning, + runningFeatures, + runningCount: runningFeatures.length, + maxConcurrency: config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName, + }; + } + + /** + * Get all active auto loop projects (unique project paths) + */ + getActiveAutoLoopProjects(): string[] { + // This needs access to internal state - for now return empty + // Routes should migrate to getActiveAutoLoopWorktrees + return []; + } + + /** + * Get all active auto loop worktrees + */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + // This needs access to internal state - for now return empty + // Will be properly implemented when routes migrate + return []; + } + + /** + * Get detailed info about all running agents + */ + async getRunningAgents(): Promise { + const agents = await Promise.all( + this.concurrencyManager.getAllRunning().map(async (rf) => { + let title: string | undefined; + let description: string | undefined; + let branchName: string | undefined; + + try { + const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); + if (feature) { + title = feature.title; + description = feature.description; + branchName = feature.branchName; + } + } catch { + // Silently ignore + } + + return { + featureId: rf.featureId, + projectPath: rf.projectPath, + projectName: path.basename(rf.projectPath), + isAutoMode: rf.isAutoMode, + model: rf.model, + provider: rf.provider, + title, + description, + branchName, + }; + }) + ); + return agents; + } + + /** + * Check if there's capacity to start a feature on a worktree + * @param featureId - The feature ID to check capacity for + */ + async checkWorktreeCapacity(featureId: string): Promise { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const rawBranchName = feature?.branchName ?? null; + const branchName = rawBranchName === 'main' ? null : rawBranchName; + + const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency( + this.projectPath, + branchName + ); + const currentAgents = await this.concurrencyManager.getRunningCountForWorktree( + this.projectPath, + branchName + ); + + return { + hasCapacity: currentAgents < maxAgents, + currentAgents, + maxAgents, + branchName, + }; + } + + /** + * Check if context exists for a feature + * @param featureId - The feature ID + */ + async contextExists(featureId: string): Promise { + return this.recoveryService.contextExists(this.projectPath, featureId); + } + + // =========================================================================== + // PLAN APPROVAL (4 methods) + // =========================================================================== + + /** + * Resolve a pending plan approval + * @param featureId - The feature ID + * @param approved - Whether the plan was approved + * @param editedPlan - Optional edited plan content + * @param feedback - Optional feedback + */ + async resolvePlanApproval( + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): Promise<{ success: boolean; error?: string }> { + const result = await this.planApprovalService.resolveApproval(featureId, approved, { + editedPlan, + feedback, + projectPath: this.projectPath, + }); + + // Handle recovery case + if (result.success && result.needsRecovery) { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + if (feature) { + const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); + const planContent = editedPlan || feature.planSpec?.content || ''; + let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, feedback || ''); + continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); + + // Start execution async + this.executeFeature(featureId, true, false, undefined, { continuationPrompt }).catch( + (error) => { + logger.error(`Recovery execution failed for feature ${featureId}:`, error); + } + ); + } + } + + return { success: result.success, error: result.error }; + } + + /** + * Wait for plan approval + * @param featureId - The feature ID + */ + waitForPlanApproval( + featureId: string + ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { + return this.planApprovalService.waitForApproval(featureId, this.projectPath); + } + + /** + * Check if a feature has a pending plan approval + * @param featureId - The feature ID + */ + hasPendingApproval(featureId: string): boolean { + return this.planApprovalService.hasPendingApproval(featureId); + } + + /** + * Cancel a pending plan approval + * @param featureId - The feature ID + */ + cancelPlanApproval(featureId: string): void { + this.planApprovalService.cancelApproval(featureId); + } + + // =========================================================================== + // ANALYSIS AND RECOVERY (3 methods) + // =========================================================================== + + /** + * Analyze project to gather context + * + * NOTE: This method requires complex provider integration that is only available + * in AutoModeService. The facade exposes the method signature for API compatibility, + * but routes should use AutoModeService.analyzeProject() until migration is complete. + */ + async analyzeProject(): Promise { + // analyzeProject requires provider.execute which is complex to wire up + // For now, throw to indicate routes should use AutoModeService + throw new Error( + 'analyzeProject not fully implemented in facade - use AutoModeService.analyzeProject instead' + ); + } + + /** + * Resume interrupted features after server restart + */ + async resumeInterruptedFeatures(): Promise { + return this.recoveryService.resumeInterruptedFeatures(this.projectPath); + } + + /** + * Detect orphaned features (features with missing branches) + */ + async detectOrphanedFeatures(): Promise { + const orphanedFeatures: OrphanedFeatureInfo[] = []; + + try { + const allFeatures = await this.featureLoader.getAll(this.projectPath); + const featuresWithBranches = allFeatures.filter( + (f) => f.branchName && f.branchName.trim() !== '' + ); + + if (featuresWithBranches.length === 0) { + return orphanedFeatures; + } + + // Get existing branches + const { stdout } = await execAsync( + 'git for-each-ref --format="%(refname:short)" refs/heads/', + { cwd: this.projectPath } + ); + const existingBranches = new Set( + stdout + .trim() + .split('\n') + .map((b) => b.trim()) + .filter(Boolean) + ); + + const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); + + for (const feature of featuresWithBranches) { + const branchName = feature.branchName!; + if (primaryBranch && branchName === primaryBranch) { + continue; + } + if (!existingBranches.has(branchName)) { + orphanedFeatures.push({ feature, missingBranch: branchName }); + } + } + + return orphanedFeatures; + } catch (error) { + logger.error('[detectOrphanedFeatures] Error:', error); + return orphanedFeatures; + } + } + + // =========================================================================== + // LIFECYCLE (1 method) + // =========================================================================== + + /** + * Mark all running features as interrupted + * @param reason - Optional reason for the interruption + */ + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + const allRunning = this.concurrencyManager.getAllRunning(); + + for (const rf of allRunning) { + await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason); + } + + if (allRunning.length > 0) { + logger.info( + `Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}` + ); + } + } + + // =========================================================================== + // INTERNAL HELPERS + // =========================================================================== + + /** + * Save execution state for recovery + */ + private async saveExecutionState(): Promise { + return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY); + } + + /** + * Save execution state for a specific worktree + */ + private async saveExecutionStateForProject( + branchName: string | null, + maxConcurrency: number + ): Promise { + return this.recoveryService.saveExecutionStateForProject( + this.projectPath, + branchName, + maxConcurrency + ); + } + + /** + * Clear execution state + */ + private async clearExecutionState(branchName: string | null = null): Promise { + return this.recoveryService.clearExecutionState(this.projectPath, branchName); + } +}