mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
feat(06-01): create AutoModeServiceFacade with all 23 methods
- Create facade.ts with per-project factory pattern - Implement all 23 public methods from RESEARCH.md inventory: - Auto loop control: startAutoLoop, stopAutoLoop, isAutoLoopRunning, getAutoLoopConfig - Feature execution: executeFeature, stopFeature, resumeFeature, followUpFeature, verifyFeature, commitFeature - Status queries: getStatus, getStatusForProject, getActiveAutoLoopProjects, getActiveAutoLoopWorktrees, getRunningAgents, checkWorktreeCapacity, contextExists - Plan approval: resolvePlanApproval, waitForPlanApproval, hasPendingApproval, cancelPlanApproval - Analysis/recovery: analyzeProject, resumeInterruptedFeatures, detectOrphanedFeatures - Lifecycle: markAllRunningFeaturesInterrupted - Use thin delegation pattern to underlying services - Note: followUpFeature and analyzeProject require AutoModeService until full migration
This commit is contained in:
913
apps/server/src/services/auto-mode/facade.ts
Normal file
913
apps/server/src/services/auto-mode/facade.ts
Normal file
@@ -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<number> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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<string | null> {
|
||||||
|
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<RunningAgentInfo[]> {
|
||||||
|
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<WorktreeCapacityInfo> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
return this.recoveryService.resumeInterruptedFeatures(this.projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect orphaned features (features with missing branches)
|
||||||
|
*/
|
||||||
|
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save execution state for a specific worktree
|
||||||
|
*/
|
||||||
|
private async saveExecutionStateForProject(
|
||||||
|
branchName: string | null,
|
||||||
|
maxConcurrency: number
|
||||||
|
): Promise<void> {
|
||||||
|
return this.recoveryService.saveExecutionStateForProject(
|
||||||
|
this.projectPath,
|
||||||
|
branchName,
|
||||||
|
maxConcurrency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear execution state
|
||||||
|
*/
|
||||||
|
private async clearExecutionState(branchName: string | null = null): Promise<void> {
|
||||||
|
return this.recoveryService.clearExecutionState(this.projectPath, branchName);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user