mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +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