refactor(06-04): delete auto-mode-service.ts monolith

- Delete the 2705-line auto-mode-service.ts monolith
- Create AutoModeServiceCompat as compatibility layer for routes
- Create GlobalAutoModeService for cross-project operations
- Update all routes to use AutoModeServiceCompat type
- Add SharedServices interface for state sharing across facades
- Add getActiveProjects/getActiveWorktrees to AutoLoopCoordinator
- Delete obsolete monolith test files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-30 22:05:46 +01:00
parent 50c0b154f4
commit 603cb63dc4
31 changed files with 624 additions and 4998 deletions

View File

@@ -56,7 +56,7 @@ import {
import { createSettingsRoutes } from './routes/settings/index.js';
import { AgentService } from './services/agent-service.js';
import { FeatureLoader } from './services/feature-loader.js';
import { AutoModeService } from './services/auto-mode-service.js';
import { AutoModeServiceCompat } from './services/auto-mode/index.js';
import { getTerminalService } from './services/terminal-service.js';
import { SettingsService } from './services/settings-service.js';
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
@@ -258,7 +258,9 @@ const events: EventEmitter = createEventEmitter();
const settingsService = new SettingsService(DATA_DIR);
const agentService = new AgentService(DATA_DIR, events, settingsService);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events, settingsService);
// Auto-mode services: compatibility layer provides old interface while using new architecture
const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader);
const claudeUsageService = new ClaudeUsageService();
const codexAppServerService = new CodexAppServerService();
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);

View File

@@ -1,13 +1,12 @@
/**
* Auto Mode routes - HTTP API for autonomous feature implementation
*
* Uses the AutoModeService for real feature execution with Claude Agent SDK.
* Supports optional facadeFactory for per-project facade creation during migration.
* Uses AutoModeServiceCompat which provides the old interface while
* delegating to GlobalAutoModeService and per-project facades.
*/
import { Router } from 'express';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createStopFeatureHandler } from './routes/stop-feature.js';
import { createStatusHandler } from './routes/status.js';
@@ -24,81 +23,63 @@ import { createApprovePlanHandler } from './routes/approve-plan.js';
import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
/**
* Create auto-mode routes with optional facade factory.
* Create auto-mode routes.
*
* @param autoModeService - The AutoModeService instance (for backward compatibility)
* @param facadeFactory - Optional factory for creating per-project facades
* @param autoModeService - AutoModeServiceCompat instance
*/
export function createAutoModeRoutes(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
): Router {
export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router {
const router = Router();
// Auto loop control routes
router.post(
'/start',
validatePathParams('projectPath'),
createStartHandler(autoModeService, facadeFactory)
);
router.post(
'/stop',
validatePathParams('projectPath'),
createStopHandler(autoModeService, facadeFactory)
);
router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
// Note: stop-feature doesn't have projectPath, so we pass undefined for facade.
// When we fully migrate, we can update stop-feature to use a different approach.
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
router.post(
'/status',
validatePathParams('projectPath?'),
createStatusHandler(autoModeService, facadeFactory)
);
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
router.post(
'/run-feature',
validatePathParams('projectPath'),
createRunFeatureHandler(autoModeService, facadeFactory)
createRunFeatureHandler(autoModeService)
);
router.post(
'/verify-feature',
validatePathParams('projectPath'),
createVerifyFeatureHandler(autoModeService, facadeFactory)
createVerifyFeatureHandler(autoModeService)
);
router.post(
'/resume-feature',
validatePathParams('projectPath'),
createResumeFeatureHandler(autoModeService, facadeFactory)
createResumeFeatureHandler(autoModeService)
);
router.post(
'/context-exists',
validatePathParams('projectPath'),
createContextExistsHandler(autoModeService, facadeFactory)
createContextExistsHandler(autoModeService)
);
router.post(
'/analyze-project',
validatePathParams('projectPath'),
createAnalyzeProjectHandler(autoModeService, facadeFactory)
createAnalyzeProjectHandler(autoModeService)
);
router.post(
'/follow-up-feature',
validatePathParams('projectPath', 'imagePaths[]'),
createFollowUpFeatureHandler(autoModeService, facadeFactory)
createFollowUpFeatureHandler(autoModeService)
);
router.post(
'/commit-feature',
validatePathParams('projectPath', 'worktreePath?'),
createCommitFeatureHandler(autoModeService, facadeFactory)
createCommitFeatureHandler(autoModeService)
);
router.post(
'/approve-plan',
validatePathParams('projectPath'),
createApprovePlanHandler(autoModeService, facadeFactory)
createApprovePlanHandler(autoModeService)
);
router.post(
'/resume-interrupted',
validatePathParams('projectPath'),
createResumeInterruptedHandler(autoModeService, facadeFactory)
createResumeInterruptedHandler(autoModeService)
);
return router;

View File

@@ -3,17 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createAnalyzeProjectHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
@@ -23,19 +19,6 @@ export function createAnalyzeProjectHandler(
return;
}
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
// Start analysis in background
facade.analyzeProject().catch((error) => {
logger.error(`[AutoMode] Project analysis error:`, error);
});
res.json({ success: true, message: 'Project analysis started' });
return;
}
// Legacy path: use autoModeService directly
// Start analysis in background
autoModeService.analyzeProject(projectPath).catch((error) => {
logger.error(`[AutoMode] Project analysis error:`, error);

View File

@@ -3,17 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createApprovePlanHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { featureId, approved, editedPlan, feedback, projectPath } = req.body as {
@@ -50,37 +46,13 @@ export function createApprovePlanHandler(
}${feedback ? ` - Feedback: ${feedback}` : ''}`
);
// Use facade if factory is provided and projectPath is available
if (facadeFactory && projectPath) {
const facade = facadeFactory(projectPath);
const result = await facade.resolvePlanApproval(featureId, approved, editedPlan, feedback);
if (!result.success) {
res.status(500).json({
success: false,
error: result.error,
});
return;
}
res.json({
success: true,
approved,
message: approved
? 'Plan approved - implementation will continue'
: 'Plan rejected - feature execution stopped',
});
return;
}
// Legacy path: use autoModeService directly
// Resolve the pending approval (with recovery support)
const result = await autoModeService.resolvePlanApproval(
projectPath || '',
featureId,
approved,
editedPlan,
feedback,
projectPath
feedback
);
if (!result.success) {

View File

@@ -3,18 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create commit feature handler with transition compatibility.
* Accepts either autoModeService (legacy) or facadeFactory (new).
*/
export function createCommitFeatureHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, worktreePath } = req.body as {
@@ -31,15 +23,6 @@ export function createCommitFeatureHandler(
return;
}
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
const commitHash = await facade.commitFeature(featureId, worktreePath);
res.json({ success: true, commitHash });
return;
}
// Legacy path: use autoModeService directly
const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath);
res.json({ success: true, commitHash });
} catch (error) {

View File

@@ -3,18 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create context exists handler with transition compatibility.
* Accepts either autoModeService (legacy) or facadeFactory (new).
*/
export function createContextExistsHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {
@@ -30,15 +22,6 @@ export function createContextExistsHandler(
return;
}
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
const exists = await facade.contextExists(featureId);
res.json({ success: true, exists });
return;
}
// Legacy path: use autoModeService directly
const exists = await autoModeService.contextExists(projectPath, featureId);
res.json({ success: true, exists });
} catch (error) {

View File

@@ -3,17 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createFollowUpFeatureHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as {
@@ -32,40 +28,14 @@ export function createFollowUpFeatureHandler(
return;
}
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
facade
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
.followUpFeature(featureId, prompt, imagePaths, useWorktrees ?? false)
.catch((error) => {
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when follow-up completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });
return;
}
// Legacy path: use autoModeService directly
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
autoModeService
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
.catch((error) => {
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when follow-up completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -3,17 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createResumeFeatureHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
@@ -30,20 +26,6 @@ export function createResumeFeatureHandler(
return;
}
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
// Start resume in background
// Default to false - worktrees should only be used when explicitly enabled
facade.resumeFeature(featureId, useWorktrees ?? false).catch((error) => {
logger.error(`Resume feature ${featureId} error:`, error);
});
res.json({ success: true });
return;
}
// Legacy path: use autoModeService directly
// Start resume in background
// Default to false - worktrees should only be used when explicitly enabled
autoModeService

View File

@@ -7,8 +7,7 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
const logger = createLogger('ResumeInterrupted');
@@ -16,10 +15,7 @@ interface ResumeInterruptedRequest {
projectPath: string;
}
export function createResumeInterruptedHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
const { projectPath } = req.body as ResumeInterruptedRequest;
@@ -31,13 +27,7 @@ export function createResumeInterruptedHandler(
logger.info(`Checking for interrupted features in ${projectPath}`);
try {
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
await facade.resumeInterruptedFeatures();
} else {
await autoModeService.resumeInterruptedFeatures(projectPath);
}
await autoModeService.resumeInterruptedFeatures(projectPath);
res.json({
success: true,

View File

@@ -3,17 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createRunFeatureHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, useWorktrees } = req.body as {
@@ -30,45 +26,6 @@ export function createRunFeatureHandler(
return;
}
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
// Check per-worktree capacity before starting
const capacity = await facade.checkWorktreeCapacity(featureId);
if (!capacity.hasCapacity) {
const worktreeDesc = capacity.branchName
? `worktree "${capacity.branchName}"`
: 'main worktree';
res.status(429).json({
success: false,
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
details: {
currentAgents: capacity.currentAgents,
maxAgents: capacity.maxAgents,
branchName: capacity.branchName,
},
});
return;
}
// Start execution in background
// executeFeature derives workDir from feature.branchName
facade
.executeFeature(featureId, useWorktrees ?? false, false)
.catch((error) => {
logger.error(`Feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when execution completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });
return;
}
// Legacy path: use autoModeService directly
// Check per-worktree capacity before starting
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
if (!capacity.hasCapacity) {
@@ -93,10 +50,6 @@ export function createRunFeatureHandler(
.executeFeature(projectPath, featureId, useWorktrees ?? false, false)
.catch((error) => {
logger.error(`Feature ${featureId} error:`, error);
})
.finally(() => {
// Release the starting slot when execution completes (success or error)
// Note: The feature should be in runningFeatures by this point
});
res.json({ success: true });

View File

@@ -3,17 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
export function createStartHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createStartHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, maxConcurrency } = req.body as {
@@ -36,40 +32,6 @@ export function createStartHandler(
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
// Check if already running
if (facade.isAutoLoopRunning(normalizedBranchName)) {
res.json({
success: true,
message: `Auto mode is already running for ${worktreeDesc}`,
alreadyRunning: true,
branchName: normalizedBranchName,
});
return;
}
// Start the auto loop for this project/worktree
const resolvedMaxConcurrency = await facade.startAutoLoop(
normalizedBranchName,
maxConcurrency
);
logger.info(
`Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
);
res.json({
success: true,
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
branchName: normalizedBranchName,
});
return;
}
// Legacy path: use autoModeService directly
// Check if already running
if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({

View File

@@ -6,19 +6,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create status handler with transition compatibility.
* Accepts either autoModeService (legacy) or facade (new).
* When facade is provided, creates a per-project facade for the request.
* Create status handler.
*/
export function createStatusHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createStatusHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {
@@ -31,24 +25,6 @@ export function createStatusHandler(
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
const projectStatus = facade.getStatusForProject(normalizedBranchName);
res.json({
success: true,
isRunning: projectStatus.runningCount > 0,
isAutoLoopRunning: projectStatus.isAutoLoopRunning,
runningFeatures: projectStatus.runningFeatures,
runningCount: projectStatus.runningCount,
maxConcurrency: projectStatus.maxConcurrency,
projectPath,
branchName: normalizedBranchName,
});
return;
}
// Legacy path: use autoModeService directly
const projectStatus = autoModeService.getStatusForProject(
projectPath,
normalizedBranchName
@@ -66,8 +42,7 @@ export function createStatusHandler(
return;
}
// Fall back to global status for backward compatibility
// Global status uses autoModeService (facade is per-project)
// Global status for backward compatibility
const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects();
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();

View File

@@ -3,19 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create stop feature handler with transition compatibility.
* Accepts either autoModeService (legacy) or facade (new).
* Note: stopFeature is feature-scoped (not project-scoped), so a single facade can be used.
*/
export function createStopFeatureHandler(
autoModeService: AutoModeService,
facade?: AutoModeServiceFacade
) {
export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { featureId } = req.body as { featureId: string };
@@ -25,10 +16,7 @@ export function createStopFeatureHandler(
return;
}
// Use facade if provided, otherwise fall back to autoModeService
const stopped = facade
? await facade.stopFeature(featureId)
: await autoModeService.stopFeature(featureId);
const stopped = await autoModeService.stopFeature(featureId);
res.json({ success: true, stopped });
} catch (error) {
logError(error, 'Stop feature failed');

View File

@@ -3,21 +3,13 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('AutoMode');
/**
* Create stop handler with transition compatibility.
* Accepts either autoModeService (legacy) or facadeFactory (new).
*/
export function createStopHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createStopHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName } = req.body as {
@@ -39,38 +31,6 @@ export function createStopHandler(
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
// Check if running
if (!facade.isAutoLoopRunning(normalizedBranchName)) {
res.json({
success: true,
message: `Auto mode is not running for ${worktreeDesc}`,
wasRunning: false,
branchName: normalizedBranchName,
});
return;
}
// Stop the auto loop for this project/worktree
const runningCount = await facade.stopAutoLoop(normalizedBranchName);
logger.info(
`Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
);
res.json({
success: true,
message: 'Auto mode stopped',
runningFeaturesCount: runningCount,
branchName: normalizedBranchName,
});
return;
}
// Legacy path: use autoModeService directly
// Check if running
if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({

View File

@@ -3,18 +3,10 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create verify feature handler with transition compatibility.
* Accepts either autoModeService (legacy) or facadeFactory (new).
*/
export function createVerifyFeatureHandler(
autoModeService: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
) {
export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {
@@ -30,15 +22,6 @@ export function createVerifyFeatureHandler(
return;
}
// Use facade if factory is provided, otherwise fall back to autoModeService
if (facadeFactory) {
const facade = facadeFactory(projectPath);
const passes = await facade.verifyFeature(featureId);
res.json({ success: true, passes });
return;
}
// Legacy path: use autoModeService directly
const passes = await autoModeService.verifyFeature(projectPath, featureId);
res.json({ success: true, passes });
} catch (error) {

View File

@@ -5,8 +5,7 @@
import { Router } from 'express';
import { FeatureLoader } from '../../services/feature-loader.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
@@ -25,15 +24,14 @@ export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService,
events?: EventEmitter,
autoModeService?: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
autoModeService?: AutoModeServiceCompat
): Router {
const router = Router();
router.post(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService, facadeFactory)
createListHandler(featureLoader, autoModeService)
);
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(

View File

@@ -7,8 +7,7 @@
import type { Request, Response } from 'express';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
@@ -16,8 +15,7 @@ const logger = createLogger('FeaturesListRoute');
export function createListHandler(
featureLoader: FeatureLoader,
autoModeService?: AutoModeService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
autoModeService?: AutoModeServiceCompat
) {
return async (req: Request, res: Response): Promise<void> => {
try {
@@ -34,21 +32,7 @@ export function createListHandler(
// This detects features whose branches no longer exist (e.g., after merge/delete)
// We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves
if (facadeFactory) {
const facade = facadeFactory(projectPath);
facade.detectOrphanedFeatures().then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
}
});
} else if (autoModeService) {
if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
if (orphanedFeatures.length > 0) {
logger.info(

View File

@@ -4,31 +4,23 @@
import { Router } from 'express';
import type { FeatureLoader } from '../../services/feature-loader.js';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import type { SettingsService } from '../../services/settings-service.js';
import type { NotificationService } from '../../services/notification-service.js';
import { createOverviewHandler } from './routes/overview.js';
export function createProjectsRoutes(
featureLoader: FeatureLoader,
autoModeService: AutoModeService,
autoModeService: AutoModeServiceCompat,
settingsService: SettingsService,
notificationService: NotificationService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
notificationService: NotificationService
): Router {
const router = Router();
// GET /overview - Get aggregate status for all projects
router.get(
'/overview',
createOverviewHandler(
featureLoader,
autoModeService,
settingsService,
notificationService,
facadeFactory
)
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
);
return router;

View File

@@ -9,9 +9,8 @@
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type {
AutoModeServiceFacade,
AutoModeServiceCompat,
RunningAgentInfo,
ProjectAutoModeStatus,
} from '../../../services/auto-mode/index.js';
@@ -152,10 +151,9 @@ function getLastActivityAt(features: Feature[]): string | undefined {
export function createOverviewHandler(
featureLoader: FeatureLoader,
autoModeService: AutoModeService,
autoModeService: AutoModeServiceCompat,
settingsService: SettingsService,
notificationService: NotificationService,
facadeFactory?: (projectPath: string) => AutoModeServiceFacade
notificationService: NotificationService
) {
return async (_req: Request, res: Response): Promise<void> => {
try {
@@ -164,15 +162,7 @@ export function createOverviewHandler(
const projectRefs: ProjectRef[] = settings.projects || [];
// Get all running agents once to count live running features per project
// Use facade if available, otherwise fall back to autoModeService
let allRunningAgents: RunningAgentInfo[];
if (facadeFactory && projectRefs.length > 0) {
// For running agents, we can use any project's facade since it's a global query
const facade = facadeFactory(projectRefs[0].path);
allRunningAgents = await facade.getRunningAgents();
} else {
allRunningAgents = await autoModeService.getRunningAgents();
}
const allRunningAgents: RunningAgentInfo[] = await autoModeService.getRunningAgents();
// Collect project statuses in parallel
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
@@ -183,13 +173,10 @@ export function createOverviewHandler(
const totalFeatures = features.length;
// Get auto-mode status for this project (main worktree, branchName = null)
let autoModeStatus: ProjectAutoModeStatus;
if (facadeFactory) {
const facade = facadeFactory(projectRef.path);
autoModeStatus = facade.getStatusForProject(null);
} else {
autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
}
const autoModeStatus: ProjectAutoModeStatus = autoModeService.getStatusForProject(
projectRef.path,
null
);
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
// Count live running features for this project (across all branches)

View File

@@ -3,10 +3,10 @@
*/
import { Router } from 'express';
import type { AutoModeService } from '../../services/auto-mode-service.js';
import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js';
import { createIndexHandler } from './routes/index.js';
export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
export function createRunningAgentsRoutes(autoModeService: AutoModeServiceCompat): Router {
const router = Router();
router.get('/', createIndexHandler(autoModeService));

View File

@@ -3,29 +3,16 @@
*/
import type { Request, Response } from 'express';
import type { AutoModeService } from '../../../services/auto-mode-service.js';
import type { AutoModeServiceFacade } from '../../../services/auto-mode/index.js';
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js';
import { getAllRunningGenerations } from '../../app-spec/common.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
/**
* Create index handler with transition compatibility.
* Accepts either autoModeService (legacy) or facade (new).
* Note: getRunningAgents is global (not per-project), so facade is created
* with an empty path for global queries.
*/
export function createIndexHandler(
autoModeService: AutoModeService,
facade?: AutoModeServiceFacade
) {
export function createIndexHandler(autoModeService: AutoModeServiceCompat) {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Use facade if provided, otherwise fall back to autoModeService
const runningAgents = facade
? [...(await facade.getRunningAgents())]
: [...(await autoModeService.getRunningAgents())];
const runningAgents = [...(await autoModeService.getRunningAgents())];
const backlogPlanStatus = getBacklogPlanStatus();
const backlogPlanDetails = getRunningDetails();

View File

@@ -363,6 +363,36 @@ export class AutoLoopCoordinator {
return projectState?.config ?? null;
}
/**
* Get all active auto loop worktrees with their project paths and branch names
*/
getActiveWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = [];
for (const [, state] of this.autoLoopsByProject) {
if (state.isRunning) {
activeWorktrees.push({
projectPath: state.config.projectPath,
branchName: state.branchName,
});
}
}
return activeWorktrees;
}
/**
* Get all projects that have auto mode running (returns unique project paths)
* @deprecated Use getActiveWorktrees instead for full worktree information
*/
getActiveProjects(): string[] {
const activeProjects = new Set<string>();
for (const [, state] of this.autoLoopsByProject) {
if (state.isRunning) {
activeProjects.add(state.config.projectPath);
}
}
return Array.from(activeProjects);
}
/**
* Get count of running features for a specific worktree
* Delegates to ConcurrencyManager.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
/**
* Compatibility Shim - Provides AutoModeService-like interface using the new architecture
*
* This allows existing routes to work without major changes during the transition.
* Routes receive this shim which delegates to GlobalAutoModeService and facades.
*
* This is a TEMPORARY shim - routes should be updated to use the new interface directly.
*/
import type { Feature } from '@automaker/types';
import type { EventEmitter } from '../../lib/events.js';
import { GlobalAutoModeService } from './global-service.js';
import { AutoModeServiceFacade } from './facade.js';
import type { SettingsService } from '../settings-service.js';
import type { FeatureLoader } from '../feature-loader.js';
import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js';
/**
* AutoModeServiceCompat wraps GlobalAutoModeService and facades to provide
* the old AutoModeService interface that routes expect.
*/
export class AutoModeServiceCompat {
private readonly globalService: GlobalAutoModeService;
private readonly facadeOptions: FacadeOptions;
constructor(
events: EventEmitter,
settingsService: SettingsService | null,
featureLoader: FeatureLoader
) {
this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader);
const sharedServices = this.globalService.getSharedServices();
this.facadeOptions = {
events,
settingsService,
featureLoader,
sharedServices,
};
}
/**
* Get the global service for direct access
*/
getGlobalService(): GlobalAutoModeService {
return this.globalService;
}
/**
* Create a facade for a specific project
*/
createFacade(projectPath: string): AutoModeServiceFacade {
return AutoModeServiceFacade.create(projectPath, this.facadeOptions);
}
// ===========================================================================
// GLOBAL OPERATIONS (delegated to GlobalAutoModeService)
// ===========================================================================
getStatus(): AutoModeStatus {
return this.globalService.getStatus();
}
getActiveAutoLoopProjects(): string[] {
return this.globalService.getActiveAutoLoopProjects();
}
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
return this.globalService.getActiveAutoLoopWorktrees();
}
async getRunningAgents(): Promise<RunningAgentInfo[]> {
return this.globalService.getRunningAgents();
}
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
return this.globalService.markAllRunningFeaturesInterrupted(reason);
}
// ===========================================================================
// PER-PROJECT OPERATIONS (delegated to facades)
// ===========================================================================
getStatusForProject(
projectPath: string,
branchName: string | null = null
): {
isAutoLoopRunning: boolean;
runningFeatures: string[];
runningCount: number;
maxConcurrency: number;
branchName: string | null;
} {
const facade = this.createFacade(projectPath);
return facade.getStatusForProject(branchName);
}
isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
const facade = this.createFacade(projectPath);
return facade.isAutoLoopRunning(branchName);
}
async startAutoLoopForProject(
projectPath: string,
branchName: string | null = null,
maxConcurrency?: number
): Promise<number> {
const facade = this.createFacade(projectPath);
return facade.startAutoLoop(branchName, maxConcurrency);
}
async stopAutoLoopForProject(
projectPath: string,
branchName: string | null = null
): Promise<number> {
const facade = this.createFacade(projectPath);
return facade.stopAutoLoop(branchName);
}
async executeFeature(
projectPath: string,
featureId: string,
useWorktrees = false,
isAutoMode = false,
providedWorktreePath?: string,
options?: { continuationPrompt?: string; _calledInternally?: boolean }
): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.executeFeature(
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
options
);
}
async stopFeature(featureId: string): Promise<boolean> {
// Stop feature is tricky - we need to find which project the feature is running in
// The concurrency manager tracks this
const runningAgents = await this.getRunningAgents();
const agent = runningAgents.find((a) => a.featureId === featureId);
if (agent) {
const facade = this.createFacade(agent.projectPath);
return facade.stopFeature(featureId);
}
return false;
}
async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.resumeFeature(featureId, useWorktrees);
}
async followUpFeature(
projectPath: string,
featureId: string,
prompt: string,
imagePaths?: string[],
useWorktrees = true
): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.followUpFeature(featureId, prompt, imagePaths, useWorktrees);
}
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
const facade = this.createFacade(projectPath);
return facade.verifyFeature(featureId);
}
async commitFeature(
projectPath: string,
featureId: string,
providedWorktreePath?: string
): Promise<string | null> {
const facade = this.createFacade(projectPath);
return facade.commitFeature(featureId, providedWorktreePath);
}
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
const facade = this.createFacade(projectPath);
return facade.contextExists(featureId);
}
async analyzeProject(projectPath: string): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.analyzeProject();
}
async resolvePlanApproval(
projectPath: string,
featureId: string,
approved: boolean,
editedPlan?: string,
feedback?: string
): Promise<{ success: boolean; error?: string }> {
const facade = this.createFacade(projectPath);
return facade.resolvePlanApproval(featureId, approved, editedPlan, feedback);
}
async resumeInterruptedFeatures(projectPath: string): Promise<void> {
const facade = this.createFacade(projectPath);
return facade.resumeInterruptedFeatures();
}
async checkWorktreeCapacity(
projectPath: string,
featureId: string
): Promise<{
hasCapacity: boolean;
currentAgents: number;
maxAgents: number;
branchName: string | null;
}> {
const facade = this.createFacade(projectPath);
return facade.checkWorktreeCapacity(featureId);
}
async detectOrphanedFeatures(
projectPath: string
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
const facade = this.createFacade(projectPath);
return facade.detectOrphanedFeatures();
}
}

View File

@@ -86,12 +86,20 @@ export class AutoModeServiceFacade {
* @param options - Configuration options including events, settingsService, featureLoader
*/
static create(projectPath: string, options: FacadeOptions): AutoModeServiceFacade {
const { events, settingsService = null, featureLoader = new FeatureLoader() } = options;
const {
events,
settingsService = null,
featureLoader = new FeatureLoader(),
sharedServices,
} = options;
// Create core services
const eventBus = new TypedEventBus(events);
const worktreeResolver = new WorktreeResolver();
const concurrencyManager = new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p));
// Use shared services if provided, otherwise create new ones
// Shared services allow multiple facades to share state (e.g., running features, auto loops)
const eventBus = sharedServices?.eventBus ?? new TypedEventBus(events);
const worktreeResolver = sharedServices?.worktreeResolver ?? new WorktreeResolver();
const concurrencyManager =
sharedServices?.concurrencyManager ??
new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p));
const featureStateManager = new FeatureStateManager(events, featureLoader);
const planApprovalService = new PlanApprovalService(
eventBus,
@@ -151,36 +159,39 @@ export class AutoModeServiceFacade {
}
);
// 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)
);
// AutoLoopCoordinator - use shared if provided, otherwise create new
// Note: When using shared autoLoopCoordinator, callbacks are already set up by the global service
const autoLoopCoordinator =
sharedServices?.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(

View File

@@ -0,0 +1,200 @@
/**
* GlobalAutoModeService - Global operations for auto-mode that span across all projects
*
* This service manages global state and operations that are not project-specific:
* - Overall status (all running features across all projects)
* - Active auto loop projects and worktrees
* - Graceful shutdown (mark all features as interrupted)
*
* Per-project operations should use AutoModeServiceFacade instead.
*/
import path from 'path';
import type { Feature } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../lib/events.js';
import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js';
import { WorktreeResolver } from '../worktree-resolver.js';
import { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
import { FeatureStateManager } from '../feature-state-manager.js';
import { FeatureLoader } from '../feature-loader.js';
import type { SettingsService } from '../settings-service.js';
import type { SharedServices, AutoModeStatus, RunningAgentInfo } from './types.js';
const logger = createLogger('GlobalAutoModeService');
/**
* GlobalAutoModeService provides global operations for auto-mode.
*
* Created once at server startup, shared across all facades.
*/
export class GlobalAutoModeService {
private readonly eventBus: TypedEventBus;
private readonly concurrencyManager: ConcurrencyManager;
private readonly autoLoopCoordinator: AutoLoopCoordinator;
private readonly worktreeResolver: WorktreeResolver;
private readonly featureStateManager: FeatureStateManager;
private readonly featureLoader: FeatureLoader;
constructor(
events: EventEmitter,
settingsService: SettingsService | null,
featureLoader: FeatureLoader = new FeatureLoader()
) {
this.featureLoader = featureLoader;
this.eventBus = new TypedEventBus(events);
this.worktreeResolver = new WorktreeResolver();
this.concurrencyManager = new ConcurrencyManager((p) =>
this.worktreeResolver.getCurrentBranch(p)
);
this.featureStateManager = new FeatureStateManager(events, featureLoader);
// Create AutoLoopCoordinator with callbacks
// These callbacks use placeholders since GlobalAutoModeService doesn't execute features
// Feature execution is done via facades
this.autoLoopCoordinator = new AutoLoopCoordinator(
this.eventBus,
this.concurrencyManager,
settingsService,
// executeFeatureFn - not used by global service, routes handle execution
async () => {
throw new Error('executeFeatureFn not available in GlobalAutoModeService');
},
// getBacklogFeaturesFn
(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)
)
),
// saveExecutionStateFn - placeholder
async () => {},
// clearExecutionStateFn - placeholder
async () => {},
// resetStuckFeaturesFn
(pPath) => this.featureStateManager.resetStuckFeatures(pPath),
// isFeatureDoneFn
(feature) =>
feature.status === 'completed' ||
feature.status === 'verified' ||
feature.status === 'waiting_approval',
// isFeatureRunningFn
(featureId) => this.concurrencyManager.isRunning(featureId)
);
}
/**
* Get the shared services for use by facades.
* This allows facades to share state with the global service.
*/
getSharedServices(): SharedServices {
return {
eventBus: this.eventBus,
concurrencyManager: this.concurrencyManager,
autoLoopCoordinator: this.autoLoopCoordinator,
worktreeResolver: this.worktreeResolver,
};
}
// ===========================================================================
// GLOBAL STATUS (3 methods)
// ===========================================================================
/**
* Get global status (all projects combined)
*/
getStatus(): AutoModeStatus {
const allRunning = this.concurrencyManager.getAllRunning();
return {
isRunning: allRunning.length > 0,
runningFeatures: allRunning.map((rf) => rf.featureId),
runningCount: allRunning.length,
};
}
/**
* Get all active auto loop projects (unique project paths)
*/
getActiveAutoLoopProjects(): string[] {
return this.autoLoopCoordinator.getActiveProjects();
}
/**
* Get all active auto loop worktrees
*/
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
return this.autoLoopCoordinator.getActiveWorktrees();
}
// ===========================================================================
// RUNNING AGENTS (1 method)
// ===========================================================================
/**
* 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;
}
// ===========================================================================
// LIFECYCLE (1 method)
// ===========================================================================
/**
* Mark all running features as interrupted.
* Called during graceful shutdown.
*
* @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'}`
);
}
}
}

View File

@@ -2,13 +2,16 @@
* Auto Mode Service Module
*
* Entry point for auto-mode functionality. Exports:
* - AutoModeServiceFacade: Clean facade for auto-mode operations
* - GlobalAutoModeService: Global operations that span all projects
* - AutoModeServiceFacade: Per-project facade for auto-mode operations
* - createAutoModeFacade: Convenience factory function
* - Types for route consumption
*/
// Main facade export
// Main exports
export { GlobalAutoModeService } from './global-service.js';
export { AutoModeServiceFacade } from './facade.js';
export { AutoModeServiceCompat } from './compat.js';
// Convenience factory function
import { AutoModeServiceFacade } from './facade.js';
@@ -49,11 +52,13 @@ export function createAutoModeFacade(
// Type exports from types.ts
export type {
FacadeOptions,
SharedServices,
AutoModeStatus,
ProjectAutoModeStatus,
WorktreeCapacityInfo,
RunningAgentInfo,
OrphanedFeatureInfo,
GlobalAutoModeOperations,
} from './types.js';
// Re-export types from extracted services for route convenience

View File

@@ -11,6 +11,10 @@ import type { EventEmitter } from '../../lib/events.js';
import type { Feature, ModelProvider } from '@automaker/types';
import type { SettingsService } from '../settings-service.js';
import type { FeatureLoader } from '../feature-loader.js';
import type { ConcurrencyManager } from '../concurrency-manager.js';
import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js';
import type { WorktreeResolver } from '../worktree-resolver.js';
import type { TypedEventBus } from '../typed-event-bus.js';
// Re-export types from extracted services for route consumption
export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js';
@@ -25,6 +29,20 @@ export type { PlanApprovalResult, ResolveApprovalResult } from '../plan-approval
export type { ExecutionState } from '../recovery-service.js';
/**
* Shared services that can be passed to facades to enable state sharing
*/
export interface SharedServices {
/** TypedEventBus for typed event emission */
eventBus: TypedEventBus;
/** ConcurrencyManager for tracking running features across all projects */
concurrencyManager: ConcurrencyManager;
/** AutoLoopCoordinator for managing auto loop state across all projects */
autoLoopCoordinator: AutoLoopCoordinator;
/** WorktreeResolver for git worktree operations */
worktreeResolver: WorktreeResolver;
}
/**
* Options for creating an AutoModeServiceFacade instance
*/
@@ -35,6 +53,8 @@ export interface FacadeOptions {
settingsService?: SettingsService | null;
/** FeatureLoader for loading feature data (optional, defaults to new FeatureLoader()) */
featureLoader?: FeatureLoader;
/** Shared services for state sharing across facades (optional) */
sharedServices?: SharedServices;
}
/**
@@ -89,3 +109,20 @@ export interface OrphanedFeatureInfo {
feature: Feature;
missingBranch: string;
}
/**
* Interface describing global auto-mode operations (not project-specific).
* Used by routes that need global state access.
*/
export interface GlobalAutoModeOperations {
/** Get global status (all projects combined) */
getStatus(): AutoModeStatus;
/** Get all active auto loop projects (unique project paths) */
getActiveAutoLoopProjects(): string[];
/** Get all active auto loop worktrees */
getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }>;
/** Get detailed info about all running agents */
getRunningAgents(): Promise<RunningAgentInfo[]>;
/** Mark all running features as interrupted (for graceful shutdown) */
markAllRunningFeaturesInterrupted(reason?: string): Promise<void>;
}

View File

@@ -1,694 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AutoModeService } from '@/services/auto-mode-service.js';
import { ProviderFactory } from '@/providers/provider-factory.js';
import { FeatureLoader } from '@/services/feature-loader.js';
import {
createTestGitRepo,
createTestFeature,
listBranches,
listWorktrees,
branchExists,
worktreeExists,
type TestRepo,
} from '../helpers/git-test-repo.js';
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
vi.mock('@/providers/provider-factory.js');
describe('auto-mode-service.ts (integration)', () => {
let service: AutoModeService;
let testRepo: TestRepo;
let featureLoader: FeatureLoader;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
beforeEach(async () => {
vi.clearAllMocks();
service = new AutoModeService(mockEvents as any);
featureLoader = new FeatureLoader();
testRepo = await createTestGitRepo();
});
afterEach(async () => {
// Stop any running auto loops
await service.stopAutoLoop();
// Cleanup test repo
if (testRepo) {
await testRepo.cleanup();
}
});
describe('worktree operations', () => {
it('should use existing git worktree for feature', async () => {
const branchName = 'feature/test-feature-1';
// Create a test feature with branchName set
await createTestFeature(testRepo.path, 'test-feature-1', {
id: 'test-feature-1',
category: 'test',
description: 'Test feature',
status: 'pending',
branchName: branchName,
});
// Create worktree before executing (worktrees are now created when features are added/edited)
const worktreesDir = path.join(testRepo.path, '.worktrees');
const worktreePath = path.join(worktreesDir, 'test-feature-1');
await fs.mkdir(worktreesDir, { recursive: true });
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, {
cwd: testRepo.path,
});
// Mock provider to complete quickly
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Feature implemented' }],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Execute feature with worktrees enabled
await service.executeFeature(
testRepo.path,
'test-feature-1',
true, // useWorktrees
false // isAutoMode
);
// Verify branch exists (was created when worktree was created)
const branches = await listBranches(testRepo.path);
expect(branches).toContain(branchName);
// Verify worktree exists and is being used
// The service should have found and used the worktree (check via logs)
// We can verify the worktree exists by checking git worktree list
const worktrees = await listWorktrees(testRepo.path);
expect(worktrees.length).toBeGreaterThan(0);
// Verify that at least one worktree path contains our feature ID
const worktreePathsMatch = worktrees.some(
(wt) => wt.includes('test-feature-1') || wt.includes('.worktrees')
);
expect(worktreePathsMatch).toBe(true);
// Note: Worktrees are not automatically cleaned up by the service
// This is expected behavior - manual cleanup is required
}, 30000);
it('should handle error gracefully', async () => {
await createTestFeature(testRepo.path, 'test-feature-error', {
id: 'test-feature-error',
category: 'test',
description: 'Test feature that errors',
status: 'pending',
});
// Mock provider that throws error
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
throw new Error('Provider error');
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Execute feature (should handle error)
await service.executeFeature(testRepo.path, 'test-feature-error', true, false);
// Verify feature status was updated to backlog (error status)
const feature = await featureLoader.get(testRepo.path, 'test-feature-error');
expect(feature?.status).toBe('backlog');
}, 30000);
it('should work without worktrees', async () => {
await createTestFeature(testRepo.path, 'test-no-worktree', {
id: 'test-no-worktree',
category: 'test',
description: 'Test without worktree',
status: 'pending',
skipTests: true,
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Execute without worktrees
await service.executeFeature(
testRepo.path,
'test-no-worktree',
false, // useWorktrees = false
false
);
// Feature should be updated successfully
const feature = await featureLoader.get(testRepo.path, 'test-no-worktree');
expect(feature?.status).toBe('waiting_approval');
}, 30000);
});
describe('feature execution', () => {
it('should execute feature and update status', async () => {
await createTestFeature(testRepo.path, 'feature-exec-1', {
id: 'feature-exec-1',
category: 'ui',
description: 'Execute this feature',
status: 'pending',
skipTests: true,
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Implemented the feature' }],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
await service.executeFeature(
testRepo.path,
'feature-exec-1',
false, // Don't use worktrees so agent output is saved to main project
false
);
// Check feature status was updated
const feature = await featureLoader.get(testRepo.path, 'feature-exec-1');
expect(feature?.status).toBe('waiting_approval');
// Check agent output was saved
const agentOutput = await featureLoader.getAgentOutput(testRepo.path, 'feature-exec-1');
expect(agentOutput).toBeTruthy();
expect(agentOutput).toContain('Implemented the feature');
}, 30000);
it('should handle feature not found', async () => {
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Try to execute non-existent feature
await service.executeFeature(testRepo.path, 'nonexistent-feature', true, false);
// Should emit error event
expect(mockEvents.emit).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
featureId: 'nonexistent-feature',
error: expect.stringContaining('not found'),
})
);
}, 30000);
it('should prevent duplicate feature execution', async () => {
await createTestFeature(testRepo.path, 'feature-dup', {
id: 'feature-dup',
category: 'test',
description: 'Duplicate test',
status: 'pending',
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
// Simulate slow execution
await new Promise((resolve) => setTimeout(resolve, 500));
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Start first execution
const promise1 = service.executeFeature(testRepo.path, 'feature-dup', false, false);
// Try to start second execution (should throw)
await expect(
service.executeFeature(testRepo.path, 'feature-dup', false, false)
).rejects.toThrow('already running');
await promise1;
}, 30000);
it('should use feature-specific model', async () => {
await createTestFeature(testRepo.path, 'feature-model', {
id: 'feature-model',
category: 'test',
description: 'Model test',
status: 'pending',
model: 'claude-sonnet-4-20250514',
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
await service.executeFeature(testRepo.path, 'feature-model', false, false);
// Should have used claude-sonnet-4-20250514
expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514');
}, 30000);
});
describe('auto loop', () => {
it('should start and stop auto loop', async () => {
const startPromise = service.startAutoLoop(testRepo.path, 2);
// Give it time to start
await new Promise((resolve) => setTimeout(resolve, 100));
// Stop the loop
const runningCount = await service.stopAutoLoop();
expect(runningCount).toBe(0);
await startPromise.catch(() => {}); // Cleanup
}, 10000);
it('should process pending features in auto loop', async () => {
// Create multiple pending features
await createTestFeature(testRepo.path, 'auto-1', {
id: 'auto-1',
category: 'test',
description: 'Auto feature 1',
status: 'pending',
skipTests: true,
});
await createTestFeature(testRepo.path, 'auto-2', {
id: 'auto-2',
category: 'test',
description: 'Auto feature 2',
status: 'pending',
skipTests: true,
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Start auto loop
const startPromise = service.startAutoLoop(testRepo.path, 2);
// Wait for features to be processed
await new Promise((resolve) => setTimeout(resolve, 3000));
// Stop the loop
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Check that features were updated
const feature1 = await featureLoader.get(testRepo.path, 'auto-1');
const feature2 = await featureLoader.get(testRepo.path, 'auto-2');
// At least one should have been processed
const processedCount = [feature1, feature2].filter(
(f) => f?.status === 'waiting_approval' || f?.status === 'in_progress'
).length;
expect(processedCount).toBeGreaterThan(0);
}, 15000);
it('should respect max concurrency', async () => {
// Create 5 features
for (let i = 1; i <= 5; i++) {
await createTestFeature(testRepo.path, `concurrent-${i}`, {
id: `concurrent-${i}`,
category: 'test',
description: `Concurrent feature ${i}`,
status: 'pending',
});
}
let concurrentCount = 0;
let maxConcurrent = 0;
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
// Simulate work
await new Promise((resolve) => setTimeout(resolve, 500));
concurrentCount--;
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Start with max concurrency of 2
const startPromise = service.startAutoLoop(testRepo.path, 2);
// Wait for some features to be processed
await new Promise((resolve) => setTimeout(resolve, 3000));
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Max concurrent should not exceed 2
expect(maxConcurrent).toBeLessThanOrEqual(2);
}, 15000);
it('should emit auto mode events', async () => {
const startPromise = service.startAutoLoop(testRepo.path, 1);
// Wait for start event
await new Promise((resolve) => setTimeout(resolve, 100));
// Check start event was emitted
const startEvent = mockEvents.emit.mock.calls.find((call) =>
call[1]?.message?.includes('Auto mode started')
);
expect(startEvent).toBeTruthy();
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Check stop event was emitted (emitted immediately by stopAutoLoop)
const stopEvent = mockEvents.emit.mock.calls.find(
(call) =>
call[1]?.type === 'auto_mode_stopped' || call[1]?.message?.includes('Auto mode stopped')
);
expect(stopEvent).toBeTruthy();
}, 10000);
});
describe('error handling', () => {
it('should handle provider errors gracefully', async () => {
await createTestFeature(testRepo.path, 'error-feature', {
id: 'error-feature',
category: 'test',
description: 'Error test',
status: 'pending',
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
throw new Error('Provider execution failed');
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
// Should not throw
await service.executeFeature(testRepo.path, 'error-feature', true, false);
// Feature should be marked as backlog (error status)
const feature = await featureLoader.get(testRepo.path, 'error-feature');
expect(feature?.status).toBe('backlog');
}, 30000);
it('should continue auto loop after feature error', async () => {
await createTestFeature(testRepo.path, 'fail-1', {
id: 'fail-1',
category: 'test',
description: 'Will fail',
status: 'pending',
});
await createTestFeature(testRepo.path, 'success-1', {
id: 'success-1',
category: 'test',
description: 'Will succeed',
status: 'pending',
});
let callCount = 0;
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
callCount++;
if (callCount === 1) {
throw new Error('First feature fails');
}
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
const startPromise = service.startAutoLoop(testRepo.path, 1);
// Wait for both features to be attempted
await new Promise((resolve) => setTimeout(resolve, 5000));
await service.stopAutoLoop();
await startPromise.catch(() => {});
// Both features should have been attempted
expect(callCount).toBeGreaterThanOrEqual(1);
}, 15000);
});
describe('planning mode', () => {
it('should execute feature with skip planning mode', async () => {
await createTestFeature(testRepo.path, 'skip-plan-feature', {
id: 'skip-plan-feature',
category: 'test',
description: 'Feature with skip planning',
status: 'pending',
planningMode: 'skip',
skipTests: true,
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Feature implemented' }],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
await service.executeFeature(testRepo.path, 'skip-plan-feature', false, false);
const feature = await featureLoader.get(testRepo.path, 'skip-plan-feature');
expect(feature?.status).toBe('waiting_approval');
}, 30000);
it('should execute feature with lite planning mode without approval', async () => {
await createTestFeature(testRepo.path, 'lite-plan-feature', {
id: 'lite-plan-feature',
category: 'test',
description: 'Feature with lite planning',
status: 'pending',
planningMode: 'lite',
requirePlanApproval: false,
skipTests: true,
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'text',
text: '[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented',
},
],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
await service.executeFeature(testRepo.path, 'lite-plan-feature', false, false);
const feature = await featureLoader.get(testRepo.path, 'lite-plan-feature');
expect(feature?.status).toBe('waiting_approval');
}, 30000);
it('should emit planning_started event for spec mode', async () => {
await createTestFeature(testRepo.path, 'spec-plan-feature', {
id: 'spec-plan-feature',
category: 'test',
description: 'Feature with spec planning',
status: 'pending',
planningMode: 'spec',
requirePlanApproval: false,
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Spec generated\n\n[SPEC_GENERATED] Review the spec.' },
],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
await service.executeFeature(testRepo.path, 'spec-plan-feature', false, false);
// Check planning_started event was emitted
const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'spec');
expect(planningEvent).toBeTruthy();
}, 30000);
it('should handle feature with full planning mode', async () => {
await createTestFeature(testRepo.path, 'full-plan-feature', {
id: 'full-plan-feature',
category: 'test',
description: 'Feature with full planning',
status: 'pending',
planningMode: 'full',
requirePlanApproval: false,
});
const mockProvider = {
getName: () => 'claude',
executeQuery: async function* () {
yield {
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Full spec with phases\n\n[SPEC_GENERATED] Review.' },
],
},
};
yield {
type: 'result',
subtype: 'success',
};
},
};
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
await service.executeFeature(testRepo.path, 'full-plan-feature', false, false);
// Check planning_started event was emitted with full mode
const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'full');
expect(planningEvent).toBeTruthy();
}, 30000);
it('should track pending approval correctly', async () => {
// Initially no pending approvals
expect(service.hasPendingApproval('non-existent')).toBe(false);
});
it('should cancel pending approval gracefully', () => {
// Should not throw when cancelling non-existent approval
expect(() => service.cancelPlanApproval('non-existent')).not.toThrow();
});
it('should resolve approval with error for non-existent feature', async () => {
const result = await service.resolvePlanApproval(
'non-existent',
true,
undefined,
undefined,
undefined
);
expect(result.success).toBe(false);
expect(result.error).toContain('No pending approval');
});
});
});

View File

@@ -1,346 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AutoModeService } from '@/services/auto-mode-service.js';
describe('auto-mode-service.ts - Planning Mode', () => {
let service: AutoModeService;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
service = new AutoModeService(mockEvents as any);
});
afterEach(async () => {
// Clean up any running processes
await service.stopAutoLoop().catch(() => {});
});
describe('getPlanningPromptPrefix', () => {
// Access private method through any cast for testing
const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature);
};
it('should return empty string for skip mode', async () => {
const feature = { id: 'test', planningMode: 'skip' as const };
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return empty string when planningMode is undefined', async () => {
const feature = { id: 'test' };
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toBe('');
});
it('should return lite prompt for lite mode without approval', async () => {
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: false,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('Planning Phase (Lite Mode)');
expect(result).toContain('[PLAN_GENERATED]');
expect(result).toContain('Feature Request');
});
it('should return lite_with_approval prompt for lite mode with approval', async () => {
const feature = {
id: 'test',
planningMode: 'lite' as const,
requirePlanApproval: true,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Planning Phase (Lite Mode)');
expect(result).toContain('[SPEC_GENERATED]');
expect(result).toContain(
'DO NOT proceed with implementation until you receive explicit approval'
);
});
it('should return spec prompt for spec mode', async () => {
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Specification Phase (Spec Mode)');
expect(result).toContain('```tasks');
expect(result).toContain('T001');
expect(result).toContain('[TASK_START]');
expect(result).toContain('[TASK_COMPLETE]');
});
it('should return full prompt for full mode', async () => {
const feature = {
id: 'test',
planningMode: 'full' as const,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('## Full Specification Phase (Full SDD Mode)');
expect(result).toContain('Phase 1: Foundation');
expect(result).toContain('Phase 2: Core Implementation');
expect(result).toContain('Phase 3: Integration & Testing');
});
it('should include the separator and Feature Request header', async () => {
const feature = {
id: 'test',
planningMode: 'spec' as const,
};
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('---');
expect(result).toContain('## Feature Request');
});
it('should instruct agent to NOT output exploration text', async () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const feature = { id: 'test', planningMode: mode };
const result = await getPlanningPromptPrefix(service, feature);
// All modes should have the IMPORTANT instruction about not outputting exploration text
expect(result).toContain('IMPORTANT: Do NOT output exploration text');
expect(result).toContain('Silently analyze the codebase first');
}
});
});
describe('parseTasksFromSpec (via module)', () => {
// We need to test the module-level function
// Import it directly for testing
it('should parse tasks from a valid tasks block', async () => {
// This tests the internal logic through integration
// The function is module-level, so we verify behavior through the service
const specContent = `
## Specification
\`\`\`tasks
- [ ] T001: Create user model | File: src/models/user.ts
- [ ] T002: Add API endpoint | File: src/routes/users.ts
- [ ] T003: Write unit tests | File: tests/user.test.ts
\`\`\`
`;
// Since parseTasksFromSpec is a module-level function,
// we verify its behavior indirectly through plan parsing
expect(specContent).toContain('T001');
expect(specContent).toContain('T002');
expect(specContent).toContain('T003');
});
it('should handle tasks block with phases', () => {
const specContent = `
\`\`\`tasks
## Phase 1: Setup
- [ ] T001: Initialize project | File: package.json
- [ ] T002: Configure TypeScript | File: tsconfig.json
## Phase 2: Implementation
- [ ] T003: Create main module | File: src/index.ts
\`\`\`
`;
expect(specContent).toContain('Phase 1');
expect(specContent).toContain('Phase 2');
expect(specContent).toContain('T001');
expect(specContent).toContain('T003');
});
});
describe('plan approval flow', () => {
it('should track pending approvals correctly', () => {
expect(service.hasPendingApproval('test-feature')).toBe(false);
});
it('should allow cancelling non-existent approval without error', () => {
expect(() => service.cancelPlanApproval('non-existent')).not.toThrow();
});
it('should return running features count after stop', async () => {
const count = await service.stopAutoLoop();
expect(count).toBe(0);
});
});
describe('resolvePlanApproval', () => {
it('should return error when no pending approval exists', async () => {
const result = await service.resolvePlanApproval(
'non-existent-feature',
true,
undefined,
undefined,
undefined
);
expect(result.success).toBe(false);
expect(result.error).toContain('No pending approval');
});
it('should handle approval with edited plan', async () => {
// Without a pending approval, this should fail gracefully
const result = await service.resolvePlanApproval(
'test-feature',
true,
'Edited plan content',
undefined,
undefined
);
expect(result.success).toBe(false);
});
it('should handle rejection with feedback', async () => {
const result = await service.resolvePlanApproval(
'test-feature',
false,
undefined,
'Please add more details',
undefined
);
expect(result.success).toBe(false);
});
});
describe('buildFeaturePrompt', () => {
const defaultTaskExecutionPrompts = {
implementationInstructions: 'Test implementation instructions',
playwrightVerificationInstructions: 'Test playwright instructions',
};
const buildFeaturePrompt = (
svc: any,
feature: any,
taskExecutionPrompts = defaultTaskExecutionPrompts
) => {
return svc.buildFeaturePrompt(feature, taskExecutionPrompts);
};
it('should include feature ID and description', () => {
const feature = {
id: 'feat-123',
description: 'Add user authentication',
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('feat-123');
expect(result).toContain('Add user authentication');
});
it('should include specification when present', () => {
const feature = {
id: 'feat-123',
description: 'Test feature',
spec: 'Detailed specification here',
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('Specification:');
expect(result).toContain('Detailed specification here');
});
it('should include image paths when present', () => {
const feature = {
id: 'feat-123',
description: 'Test feature',
imagePaths: [
{ path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' },
'/tmp/image2.jpg',
],
};
const result = buildFeaturePrompt(service, feature);
expect(result).toContain('Context Images Attached');
expect(result).toContain('image1.png');
expect(result).toContain('/tmp/image2.jpg');
});
it('should include implementation instructions', () => {
const feature = {
id: 'feat-123',
description: 'Test feature',
};
const result = buildFeaturePrompt(service, feature);
// The prompt should include the implementation instructions passed to it
expect(result).toContain('Test implementation instructions');
expect(result).toContain('Test playwright instructions');
});
});
describe('extractTitleFromDescription', () => {
const extractTitle = (svc: any, description: string) => {
return svc.extractTitleFromDescription(description);
};
it("should return 'Untitled Feature' for empty description", () => {
expect(extractTitle(service, '')).toBe('Untitled Feature');
expect(extractTitle(service, ' ')).toBe('Untitled Feature');
});
it('should return first line if under 60 characters', () => {
const description = 'Add user login\nWith email validation';
expect(extractTitle(service, description)).toBe('Add user login');
});
it('should truncate long first lines to 60 characters', () => {
const description =
'This is a very long feature description that exceeds the sixty character limit significantly';
const result = extractTitle(service, description);
expect(result.length).toBe(60);
expect(result).toContain('...');
});
});
describe('PLANNING_PROMPTS structure', () => {
const getPlanningPromptPrefix = (svc: any, feature: any) => {
return svc.getPlanningPromptPrefix(feature);
};
it('should have all required planning modes', async () => {
const modes = ['lite', 'spec', 'full'] as const;
for (const mode of modes) {
const feature = { id: 'test', planningMode: mode };
const result = await getPlanningPromptPrefix(service, feature);
expect(result.length).toBeGreaterThan(100);
}
});
it('lite prompt should include correct structure', async () => {
const feature = { id: 'test', planningMode: 'lite' as const };
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('Goal');
expect(result).toContain('Approach');
expect(result).toContain('Files to Touch');
expect(result).toContain('Tasks');
expect(result).toContain('Risks');
});
it('spec prompt should include task format instructions', async () => {
const feature = { id: 'test', planningMode: 'spec' as const };
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('Problem');
expect(result).toContain('Solution');
expect(result).toContain('Acceptance Criteria');
expect(result).toContain('GIVEN-WHEN-THEN');
expect(result).toContain('Implementation Tasks');
expect(result).toContain('Verification');
});
it('full prompt should include phases', async () => {
const feature = { id: 'test', planningMode: 'full' as const };
const result = await getPlanningPromptPrefix(service, feature);
expect(result).toContain('1. **Problem Statement**');
expect(result).toContain('2. **User Story**');
expect(result).toContain('4. **Technical Context**');
expect(result).toContain('5. **Non-Goals**');
expect(result).toContain('Phase 1');
expect(result).toContain('Phase 2');
expect(result).toContain('Phase 3');
});
});
describe('status management', () => {
it('should report correct status', () => {
const status = service.getStatus();
expect(status.runningFeatures).toEqual([]);
expect(status.isRunning).toBe(false);
expect(status.runningCount).toBe(0);
});
});
});

View File

@@ -1,752 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AutoModeService } from '@/services/auto-mode-service.js';
import type { Feature } from '@automaker/types';
describe('auto-mode-service.ts', () => {
let service: AutoModeService;
const mockEvents = {
subscribe: vi.fn(),
emit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
service = new AutoModeService(mockEvents as any);
});
describe('constructor', () => {
it('should initialize with event emitter', () => {
expect(service).toBeDefined();
});
});
describe('startAutoLoop', () => {
it('should throw if auto mode is already running', async () => {
// Start first loop
const promise1 = service.startAutoLoop('/test/project', 3);
// Try to start second loop
await expect(service.startAutoLoop('/test/project', 3)).rejects.toThrow('already running');
// Cleanup
await service.stopAutoLoop();
await promise1.catch(() => {});
});
it('should emit auto mode start event', async () => {
const promise = service.startAutoLoop('/test/project', 3);
// Give it time to emit the event
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockEvents.emit).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
message: expect.stringContaining('Auto mode started'),
})
);
// Cleanup
await service.stopAutoLoop();
await promise.catch(() => {});
});
});
describe('stopAutoLoop', () => {
it('should stop the auto loop', async () => {
const promise = service.startAutoLoop('/test/project', 3);
const runningCount = await service.stopAutoLoop();
expect(runningCount).toBe(0);
await promise.catch(() => {});
});
it('should return 0 when not running', async () => {
const runningCount = await service.stopAutoLoop();
expect(runningCount).toBe(0);
});
});
describe('getRunningAgents', () => {
// Helper to access private concurrencyManager
const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
// Helper to add a running feature via concurrencyManager
const addRunningFeature = (
svc: AutoModeService,
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
) => {
getConcurrencyManager(svc).acquire(feature);
};
// Helper to get the featureLoader and mock its get method
const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).featureLoader = { get: mockFn };
};
it('should return empty array when no agents are running', async () => {
const result = await service.getRunningAgents();
expect(result).toEqual([]);
});
it('should return running agents with basic info when feature data is not available', async () => {
// Arrange: Add a running feature via concurrencyManager
addRunningFeature(service, {
featureId: 'feature-123',
projectPath: '/test/project/path',
isAutoMode: true,
});
// Mock featureLoader.get to return null (feature not found)
const getMock = vi.fn().mockResolvedValue(null);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-123',
projectPath: '/test/project/path',
projectName: 'path',
isAutoMode: true,
title: undefined,
description: undefined,
});
});
it('should return running agents with title and description when feature data is available', async () => {
// Arrange
addRunningFeature(service, {
featureId: 'feature-456',
projectPath: '/home/user/my-project',
isAutoMode: false,
});
const mockFeature: Partial<Feature> = {
id: 'feature-456',
title: 'Implement user authentication',
description: 'Add login and signup functionality',
category: 'auth',
};
const getMock = vi.fn().mockResolvedValue(mockFeature);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-456',
projectPath: '/home/user/my-project',
projectName: 'my-project',
isAutoMode: false,
title: 'Implement user authentication',
description: 'Add login and signup functionality',
});
expect(getMock).toHaveBeenCalledWith('/home/user/my-project', 'feature-456');
});
it('should handle multiple running agents', async () => {
// Arrange
addRunningFeature(service, {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
addRunningFeature(service, {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
const getMock = vi
.fn()
.mockResolvedValueOnce({
id: 'feature-1',
title: 'Feature One',
description: 'Description one',
})
.mockResolvedValueOnce({
id: 'feature-2',
title: 'Feature Two',
description: 'Description two',
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(2);
expect(getMock).toHaveBeenCalledTimes(2);
});
it('should silently handle errors when fetching feature data', async () => {
// Arrange
addRunningFeature(service, {
featureId: 'feature-error',
projectPath: '/project-error',
isAutoMode: true,
});
const getMock = vi.fn().mockRejectedValue(new Error('Database connection failed'));
mockFeatureLoaderGet(service, getMock);
// Act - should not throw
const result = await service.getRunningAgents();
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
featureId: 'feature-error',
projectPath: '/project-error',
projectName: 'project-error',
isAutoMode: true,
title: undefined,
description: undefined,
});
});
it('should handle feature with title but no description', async () => {
// Arrange
addRunningFeature(service, {
featureId: 'feature-title-only',
projectPath: '/project',
isAutoMode: false,
});
const getMock = vi.fn().mockResolvedValue({
id: 'feature-title-only',
title: 'Only Title',
// description is undefined
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].title).toBe('Only Title');
expect(result[0].description).toBeUndefined();
});
it('should handle feature with description but no title', async () => {
// Arrange
addRunningFeature(service, {
featureId: 'feature-desc-only',
projectPath: '/project',
isAutoMode: false,
});
const getMock = vi.fn().mockResolvedValue({
id: 'feature-desc-only',
description: 'Only description, no title',
// title is undefined
});
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].title).toBeUndefined();
expect(result[0].description).toBe('Only description, no title');
});
it('should extract projectName from nested paths correctly', async () => {
// Arrange
addRunningFeature(service, {
featureId: 'feature-nested',
projectPath: '/home/user/workspace/projects/my-awesome-project',
isAutoMode: true,
});
const getMock = vi.fn().mockResolvedValue(null);
mockFeatureLoaderGet(service, getMock);
// Act
const result = await service.getRunningAgents();
// Assert
expect(result[0].projectName).toBe('my-awesome-project');
});
it('should fetch feature data in parallel for multiple agents', async () => {
// Arrange: Add multiple running features
for (let i = 1; i <= 5; i++) {
addRunningFeature(service, {
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
isAutoMode: i % 2 === 0,
});
}
// Track call order
const callOrder: string[] = [];
const getMock = vi.fn().mockImplementation(async (projectPath: string, featureId: string) => {
callOrder.push(featureId);
// Simulate async delay to verify parallel execution
await new Promise((resolve) => setTimeout(resolve, 10));
return { id: featureId, title: `Title for ${featureId}` };
});
mockFeatureLoaderGet(service, getMock);
// Act
const startTime = Date.now();
const result = await service.getRunningAgents();
const duration = Date.now() - startTime;
// Assert
expect(result).toHaveLength(5);
expect(getMock).toHaveBeenCalledTimes(5);
// If executed in parallel, total time should be ~10ms (one batch)
// If sequential, it would be ~50ms (5 * 10ms)
// Allow some buffer for execution overhead
expect(duration).toBeLessThan(40);
});
});
describe('detectOrphanedFeatures', () => {
// Helper to mock featureLoader.getAll
const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).featureLoader = { getAll: mockFn };
};
// Helper to mock getExistingBranches
const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => {
(svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches));
};
it('should return empty array when no features have branch names', async () => {
const getAllMock = vi.fn().mockResolvedValue([
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' },
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' },
] satisfies Feature[]);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'develop']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should return empty array when all feature branches exist', async () => {
const getAllMock = vi.fn().mockResolvedValue([
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'feature-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'feature-2',
},
] satisfies Feature[]);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should detect orphaned features with missing branches', async () => {
const features: Feature[] = [
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'feature-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'deleted-branch',
},
{ id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toHaveLength(1);
expect(result[0].feature.id).toBe('f2');
expect(result[0].missingBranch).toBe('deleted-branch');
});
it('should detect multiple orphaned features', async () => {
const features: Feature[] = [
{
id: 'f1',
title: 'Feature 1',
description: 'desc',
category: 'test',
branchName: 'orphan-1',
},
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'orphan-2',
},
{
id: 'f3',
title: 'Feature 3',
description: 'desc',
category: 'test',
branchName: 'valid-branch',
},
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'valid-branch']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toHaveLength(2);
expect(result.map((r) => r.feature.id)).toContain('f1');
expect(result.map((r) => r.feature.id)).toContain('f2');
});
it('should return empty array when getAll throws error', async () => {
const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features'));
mockFeatureLoaderGetAll(service, getAllMock);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should ignore empty branchName strings', async () => {
const features: Feature[] = [
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' },
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' },
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main']);
const result = await service.detectOrphanedFeatures('/test/project');
expect(result).toEqual([]);
});
it('should skip features whose branchName matches the primary branch', async () => {
const features: Feature[] = [
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' },
{
id: 'f2',
title: 'Feature 2',
description: 'desc',
category: 'test',
branchName: 'orphaned',
},
];
const getAllMock = vi.fn().mockResolvedValue(features);
mockFeatureLoaderGetAll(service, getAllMock);
mockGetExistingBranches(service, ['main', 'develop']);
// Mock getCurrentBranch to return 'main'
(service as any).getCurrentBranch = vi.fn().mockResolvedValue('main');
const result = await service.detectOrphanedFeatures('/test/project');
// Only f2 should be orphaned (orphaned branch doesn't exist)
expect(result).toHaveLength(1);
expect(result[0].feature.id).toBe('f2');
});
});
describe('markFeatureInterrupted', () => {
// Helper to mock featureStateManager.markFeatureInterrupted
const mockFeatureStateManagerMarkInterrupted = (
svc: AutoModeService,
mockFn: ReturnType<typeof vi.fn>
) => {
(svc as any).featureStateManager.markFeatureInterrupted = mockFn;
};
it('should delegate to featureStateManager.markFeatureInterrupted', async () => {
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', undefined);
});
it('should pass reason to featureStateManager.markFeatureInterrupted', async () => {
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'server shutdown');
});
it('should propagate errors from featureStateManager', async () => {
const markMock = vi.fn().mockRejectedValue(new Error('Update failed'));
mockFeatureStateManagerMarkInterrupted(service, markMock);
await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow(
'Update failed'
);
});
});
describe('markAllRunningFeaturesInterrupted', () => {
// Helper to access private concurrencyManager
const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
// Helper to add a running feature via concurrencyManager
const addRunningFeatureForInterrupt = (
svc: AutoModeService,
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
) => {
getConcurrencyManager(svc).acquire(feature);
};
// Helper to mock featureStateManager.markFeatureInterrupted
const mockFeatureStateManagerMarkInterrupted = (
svc: AutoModeService,
mockFn: ReturnType<typeof vi.fn>
) => {
(svc as any).featureStateManager.markFeatureInterrupted = mockFn;
};
it('should do nothing when no features are running', async () => {
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markAllRunningFeaturesInterrupted();
expect(markMock).not.toHaveBeenCalled();
});
it('should mark a single running feature as interrupted', async () => {
addRunningFeatureForInterrupt(service, {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markAllRunningFeaturesInterrupted();
expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown');
});
it('should mark multiple running features as interrupted', async () => {
addRunningFeatureForInterrupt(service, {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
addRunningFeatureForInterrupt(service, {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
addRunningFeatureForInterrupt(service, {
featureId: 'feature-3',
projectPath: '/project-a',
isAutoMode: true,
});
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markAllRunningFeaturesInterrupted();
expect(markMock).toHaveBeenCalledTimes(3);
expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown');
expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown');
expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'server shutdown');
});
it('should mark features in parallel', async () => {
for (let i = 1; i <= 5; i++) {
addRunningFeatureForInterrupt(service, {
featureId: `feature-${i}`,
projectPath: `/project-${i}`,
isAutoMode: true,
});
}
const callOrder: string[] = [];
const markMock = vi
.fn()
.mockImplementation(async (_path: string, featureId: string, _reason?: string) => {
callOrder.push(featureId);
await new Promise((resolve) => setTimeout(resolve, 10));
});
mockFeatureStateManagerMarkInterrupted(service, markMock);
const startTime = Date.now();
await service.markAllRunningFeaturesInterrupted();
const duration = Date.now() - startTime;
expect(markMock).toHaveBeenCalledTimes(5);
// If executed in parallel, total time should be ~10ms
// If sequential, it would be ~50ms (5 * 10ms)
expect(duration).toBeLessThan(40);
});
it('should continue marking other features when one fails', async () => {
addRunningFeatureForInterrupt(service, {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
addRunningFeatureForInterrupt(service, {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
const markMock = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Failed to update'));
mockFeatureStateManagerMarkInterrupted(service, markMock);
// Should not throw even though one feature failed
await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow();
expect(markMock).toHaveBeenCalledTimes(2);
});
it('should use provided reason', async () => {
addRunningFeatureForInterrupt(service, {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markAllRunningFeaturesInterrupted('manual stop');
expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'manual stop');
});
it('should use default reason when none provided', async () => {
addRunningFeatureForInterrupt(service, {
featureId: 'feature-1',
projectPath: '/project/path',
isAutoMode: true,
});
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markAllRunningFeaturesInterrupted();
expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown');
});
it('should call markFeatureInterrupted for all running features (pipeline status handling delegated to FeatureStateManager)', async () => {
addRunningFeatureForInterrupt(service, {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
addRunningFeatureForInterrupt(service, {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
addRunningFeatureForInterrupt(service, {
featureId: 'feature-3',
projectPath: '/project-c',
isAutoMode: true,
});
// FeatureStateManager handles pipeline status preservation internally
const markMock = vi.fn().mockResolvedValue(undefined);
mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markAllRunningFeaturesInterrupted();
// All running features should have markFeatureInterrupted called
// (FeatureStateManager internally preserves pipeline statuses)
expect(markMock).toHaveBeenCalledTimes(3);
expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown');
expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown');
expect(markMock).toHaveBeenCalledWith('/project-c', 'feature-3', 'server shutdown');
});
});
describe('isFeatureRunning', () => {
// Helper to access private concurrencyManager
const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
// Helper to add a running feature via concurrencyManager
const addRunningFeatureForIsRunning = (
svc: AutoModeService,
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
) => {
getConcurrencyManager(svc).acquire(feature);
};
it('should return false when no features are running', () => {
expect(service.isFeatureRunning('feature-123')).toBe(false);
});
it('should return true when the feature is running', () => {
addRunningFeatureForIsRunning(service, {
featureId: 'feature-123',
projectPath: '/project/path',
isAutoMode: true,
});
expect(service.isFeatureRunning('feature-123')).toBe(true);
});
it('should return false for non-running feature when others are running', () => {
addRunningFeatureForIsRunning(service, {
featureId: 'feature-other',
projectPath: '/project/path',
isAutoMode: true,
});
expect(service.isFeatureRunning('feature-123')).toBe(false);
});
it('should correctly track multiple running features', () => {
addRunningFeatureForIsRunning(service, {
featureId: 'feature-1',
projectPath: '/project-a',
isAutoMode: true,
});
addRunningFeatureForIsRunning(service, {
featureId: 'feature-2',
projectPath: '/project-b',
isAutoMode: false,
});
expect(service.isFeatureRunning('feature-1')).toBe(true);
expect(service.isFeatureRunning('feature-2')).toBe(true);
expect(service.isFeatureRunning('feature-3')).toBe(false);
});
});
});