mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
refactor: extract auto-mode-service into modular services
Reduce auto-mode-service.ts from 1308 to 516 lines (60% reduction) by extracting reusable functionality into shared packages and services: - Add feature prompt builders to @automaker/prompts (buildFeaturePrompt, buildFollowUpPrompt, buildContinuationPrompt, extractTitleFromDescription) - Add planning prompts and task parsing to @automaker/prompts - Add stream processor utilities to @automaker/utils (sleep, processStream) - Add git commit utilities to @automaker/git-utils (commitAll, hasUncommittedChanges) - Create ProjectAnalyzer service for project analysis - Create FeatureVerificationService for verify/commit operations - Extend FeatureLoader with updateStatus, updatePlanSpec, getPending methods - Expand FeatureStatus type to include all used statuses - Add PlanSpec and ParsedTask types to @automaker/types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
163
apps/server/src/services/auto-mode/feature-verification.ts
Normal file
163
apps/server/src/services/auto-mode/feature-verification.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Feature Verification Service - Handles verification and commit operations
|
||||
*
|
||||
* Provides functionality to verify feature implementations (lint, typecheck, test, build)
|
||||
* and commit changes to git.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
runVerificationChecks,
|
||||
hasUncommittedChanges,
|
||||
commitAll,
|
||||
shortHash,
|
||||
} from '@automaker/git-utils';
|
||||
import { extractTitleFromDescription } from '@automaker/prompts';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('FeatureVerification');
|
||||
|
||||
export interface VerificationResult {
|
||||
success: boolean;
|
||||
failedCheck?: string;
|
||||
}
|
||||
|
||||
export interface CommitResult {
|
||||
hash: string | null;
|
||||
shortHash?: string;
|
||||
}
|
||||
|
||||
export class FeatureVerificationService {
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for a feature (checks for worktree)
|
||||
*/
|
||||
async resolveWorkDir(projectPath: string, featureId: string): Promise<string> {
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
return worktreePath;
|
||||
} catch {
|
||||
return projectPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a feature's implementation by running checks
|
||||
*/
|
||||
async verify(projectPath: string, featureId: string): Promise<VerificationResult> {
|
||||
const workDir = await this.resolveWorkDir(projectPath, featureId);
|
||||
|
||||
const result = await runVerificationChecks(workDir);
|
||||
|
||||
if (result.success) {
|
||||
this.emitEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: 'All verification checks passed',
|
||||
});
|
||||
} else {
|
||||
this.emitEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: false,
|
||||
message: `Verification failed: ${result.failedCheck}`,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit feature changes
|
||||
*/
|
||||
async commit(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
feature: Feature | null,
|
||||
providedWorktreePath?: string
|
||||
): Promise<CommitResult> {
|
||||
let workDir = projectPath;
|
||||
|
||||
if (providedWorktreePath) {
|
||||
try {
|
||||
await secureFs.access(providedWorktreePath);
|
||||
workDir = providedWorktreePath;
|
||||
} catch {
|
||||
// Use project path
|
||||
}
|
||||
} else {
|
||||
workDir = await this.resolveWorkDir(projectPath, featureId);
|
||||
}
|
||||
|
||||
// Check for changes
|
||||
const hasChanges = await hasUncommittedChanges(workDir);
|
||||
if (!hasChanges) {
|
||||
return { hash: null };
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
const title = feature
|
||||
? extractTitleFromDescription(feature.description)
|
||||
: `Feature ${featureId}`;
|
||||
const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`;
|
||||
|
||||
// Commit changes
|
||||
const hash = await commitAll(workDir, commitMessage);
|
||||
|
||||
if (hash) {
|
||||
const short = shortHash(hash);
|
||||
this.emitEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Changes committed: ${short}`,
|
||||
});
|
||||
return { hash, shortHash: short };
|
||||
}
|
||||
|
||||
logger.error(`Commit failed for ${featureId}`);
|
||||
return { hash: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context (agent-output.md) exists for a feature
|
||||
*/
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
try {
|
||||
await secureFs.access(contextPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing context for a feature
|
||||
*/
|
||||
async loadContext(projectPath: string, featureId: string): Promise<string | null> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const contextPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
try {
|
||||
return (await secureFs.readFile(contextPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(eventType: string, data: Record<string, unknown>): void {
|
||||
this.events.emit('auto-mode:event', { type: eventType, ...data });
|
||||
}
|
||||
}
|
||||
28
apps/server/src/services/auto-mode/index.ts
Normal file
28
apps/server/src/services/auto-mode/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Auto Mode Services
|
||||
*
|
||||
* Re-exports all auto-mode related services and types.
|
||||
*/
|
||||
|
||||
// Services
|
||||
export { PlanApprovalService } from './plan-approval-service.js';
|
||||
export { TaskExecutor } from './task-executor.js';
|
||||
export { WorktreeManager, worktreeManager } from './worktree-manager.js';
|
||||
export { OutputWriter, createFeatureOutputWriter } from './output-writer.js';
|
||||
export { ProjectAnalyzer } from './project-analyzer.js';
|
||||
export { FeatureVerificationService } from './feature-verification.js';
|
||||
export type { VerificationResult, CommitResult } from './feature-verification.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
RunningFeature,
|
||||
AutoLoopState,
|
||||
AutoModeConfig,
|
||||
PendingApproval,
|
||||
ApprovalResult,
|
||||
FeatureExecutionOptions,
|
||||
RunAgentOptions,
|
||||
FeatureWithPlanning,
|
||||
TaskExecutionContext,
|
||||
TaskProgress,
|
||||
} from './types.js';
|
||||
154
apps/server/src/services/auto-mode/output-writer.ts
Normal file
154
apps/server/src/services/auto-mode/output-writer.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Output Writer - Incremental file writing for agent output
|
||||
*
|
||||
* Handles debounced file writes to avoid excessive I/O during streaming.
|
||||
* Used to persist agent output to agent-output.md in the feature directory.
|
||||
*/
|
||||
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('OutputWriter');
|
||||
|
||||
/**
|
||||
* Handles incremental, debounced file writing for agent output
|
||||
*/
|
||||
export class OutputWriter {
|
||||
private content = '';
|
||||
private writeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly debounceMs: number;
|
||||
private readonly outputPath: string;
|
||||
|
||||
/**
|
||||
* Create a new output writer
|
||||
*
|
||||
* @param outputPath - Full path to the output file
|
||||
* @param debounceMs - Debounce interval for writes (default: 500ms)
|
||||
* @param initialContent - Optional initial content to start with
|
||||
*/
|
||||
constructor(outputPath: string, debounceMs = 500, initialContent = '') {
|
||||
this.outputPath = outputPath;
|
||||
this.debounceMs = debounceMs;
|
||||
this.content = initialContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to the output
|
||||
*
|
||||
* Schedules a debounced write to the file.
|
||||
*/
|
||||
append(text: string): void {
|
||||
this.content += text;
|
||||
this.scheduleWrite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text with automatic separator handling
|
||||
*
|
||||
* Ensures proper spacing between sections.
|
||||
*/
|
||||
appendWithSeparator(text: string): void {
|
||||
if (this.content.length > 0 && !this.content.endsWith('\n\n')) {
|
||||
if (this.content.endsWith('\n')) {
|
||||
this.content += '\n';
|
||||
} else {
|
||||
this.content += '\n\n';
|
||||
}
|
||||
}
|
||||
this.append(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a tool use entry
|
||||
*/
|
||||
appendToolUse(toolName: string, input?: unknown): void {
|
||||
if (this.content.length > 0 && !this.content.endsWith('\n')) {
|
||||
this.content += '\n';
|
||||
}
|
||||
this.content += `\n🔧 Tool: ${toolName}\n`;
|
||||
if (input) {
|
||||
this.content += `Input: ${JSON.stringify(input, null, 2)}\n`;
|
||||
}
|
||||
this.scheduleWrite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current accumulated content
|
||||
*/
|
||||
getContent(): string {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set content directly (for follow-up sessions with previous content)
|
||||
*/
|
||||
setContent(content: string): void {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced write
|
||||
*/
|
||||
private scheduleWrite(): void {
|
||||
if (this.writeTimeout) {
|
||||
clearTimeout(this.writeTimeout);
|
||||
}
|
||||
this.writeTimeout = setTimeout(() => {
|
||||
this.flush().catch((error) => {
|
||||
logger.error('Failed to flush output', error);
|
||||
});
|
||||
}, this.debounceMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush content to disk immediately
|
||||
*
|
||||
* Call this to ensure all content is written, e.g., at the end of execution.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (this.writeTimeout) {
|
||||
clearTimeout(this.writeTimeout);
|
||||
this.writeTimeout = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await secureFs.mkdir(path.dirname(this.outputPath), { recursive: true });
|
||||
await secureFs.writeFile(this.outputPath, this.content);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to write to ${this.outputPath}`, error);
|
||||
// Don't throw - file write errors shouldn't crash execution
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending writes
|
||||
*/
|
||||
cancel(): void {
|
||||
if (this.writeTimeout) {
|
||||
clearTimeout(this.writeTimeout);
|
||||
this.writeTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an output writer for a feature
|
||||
*
|
||||
* @param featureDir - The feature directory path
|
||||
* @param previousContent - Optional content from previous session
|
||||
* @returns Configured output writer
|
||||
*/
|
||||
export function createFeatureOutputWriter(
|
||||
featureDir: string,
|
||||
previousContent?: string
|
||||
): OutputWriter {
|
||||
const outputPath = path.join(featureDir, 'agent-output.md');
|
||||
|
||||
// If there's previous content, add a follow-up separator
|
||||
const initialContent = previousContent
|
||||
? `${previousContent}\n\n---\n\n## Follow-up Session\n\n`
|
||||
: '';
|
||||
|
||||
return new OutputWriter(outputPath, 500, initialContent);
|
||||
}
|
||||
236
apps/server/src/services/auto-mode/plan-approval-service.ts
Normal file
236
apps/server/src/services/auto-mode/plan-approval-service.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Plan Approval Service - Handles plan/spec approval workflow
|
||||
*
|
||||
* Manages the async approval flow where:
|
||||
* 1. Agent generates a spec with [SPEC_GENERATED] marker
|
||||
* 2. Service emits plan_approval_required event
|
||||
* 3. User reviews and approves/rejects via API
|
||||
* 4. Service resolves the waiting promise to continue execution
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { PlanSpec, PlanningMode } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { PendingApproval, ApprovalResult } from './types.js';
|
||||
|
||||
const logger = createLogger('PlanApprovalService');
|
||||
|
||||
/**
|
||||
* Manages plan approval workflow for spec-driven development
|
||||
*/
|
||||
export class PlanApprovalService {
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for plan approval from the user
|
||||
*
|
||||
* Returns a promise that resolves when the user approves or rejects
|
||||
* the plan via the API.
|
||||
*
|
||||
* @param featureId - The feature awaiting approval
|
||||
* @param projectPath - The project path
|
||||
* @returns Promise resolving to approval result
|
||||
*/
|
||||
waitForApproval(featureId: string, projectPath: string): Promise<ApprovalResult> {
|
||||
logger.debug(`Registering pending approval for feature ${featureId}`);
|
||||
logger.debug(
|
||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingApprovals.set(featureId, {
|
||||
resolve,
|
||||
reject,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
logger.debug(`Pending approval registered for feature ${featureId}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending plan approval
|
||||
*
|
||||
* Called when the user approves or rejects the plan via API.
|
||||
*
|
||||
* @param featureId - The feature ID
|
||||
* @param approved - Whether the plan was approved
|
||||
* @param editedPlan - Optional edited plan content
|
||||
* @param feedback - Optional user feedback
|
||||
* @returns Result indicating success or error
|
||||
*/
|
||||
resolve(
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
): { success: boolean; error?: string; projectPath?: string } {
|
||||
logger.debug(`resolvePlanApproval called for feature ${featureId}, approved=${approved}`);
|
||||
logger.debug(
|
||||
`Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
|
||||
);
|
||||
|
||||
const pending = this.pendingApprovals.get(featureId);
|
||||
|
||||
if (!pending) {
|
||||
logger.warn(`No pending approval found for feature ${featureId}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `No pending approval for feature ${featureId}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(`Found pending approval for feature ${featureId}, resolving...`);
|
||||
|
||||
// Resolve the promise with all data including feedback
|
||||
pending.resolve({ approved, editedPlan, feedback });
|
||||
this.pendingApprovals.delete(featureId);
|
||||
|
||||
return { success: true, projectPath: pending.projectPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending plan approval
|
||||
*
|
||||
* Called when a feature is stopped while waiting for approval.
|
||||
*
|
||||
* @param featureId - The feature ID to cancel
|
||||
*/
|
||||
cancel(featureId: string): void {
|
||||
logger.debug(`cancelPlanApproval called for feature ${featureId}`);
|
||||
const pending = this.pendingApprovals.get(featureId);
|
||||
|
||||
if (pending) {
|
||||
logger.debug(`Found and cancelling pending approval for feature ${featureId}`);
|
||||
pending.reject(new Error('Plan approval cancelled - feature was stopped'));
|
||||
this.pendingApprovals.delete(featureId);
|
||||
} else {
|
||||
logger.debug(`No pending approval to cancel for feature ${featureId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature has a pending plan approval
|
||||
*
|
||||
* @param featureId - The feature ID to check
|
||||
* @returns True if there's a pending approval
|
||||
*/
|
||||
hasPending(featureId: string): boolean {
|
||||
return this.pendingApprovals.has(featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project path for a pending approval
|
||||
*
|
||||
* Useful for recovery scenarios where we need to know which
|
||||
* project a pending approval belongs to.
|
||||
*
|
||||
* @param featureId - The feature ID
|
||||
* @returns The project path or undefined
|
||||
*/
|
||||
getProjectPath(featureId: string): string | undefined {
|
||||
return this.pendingApprovals.get(featureId)?.projectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending approval feature IDs
|
||||
*
|
||||
* @returns Array of feature IDs with pending approvals
|
||||
*/
|
||||
getAllPending(): string[] {
|
||||
return Array.from(this.pendingApprovals.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a plan-related event
|
||||
*/
|
||||
emitPlanEvent(
|
||||
eventType: string,
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
data: Record<string, unknown> = {}
|
||||
): void {
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: eventType,
|
||||
featureId,
|
||||
projectPath,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan approval required event
|
||||
*/
|
||||
emitApprovalRequired(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
planContent: string,
|
||||
planningMode: PlanningMode,
|
||||
planVersion: number
|
||||
): void {
|
||||
this.emitPlanEvent('plan_approval_required', featureId, projectPath, {
|
||||
planContent,
|
||||
planningMode,
|
||||
planVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan approved event
|
||||
*/
|
||||
emitApproved(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
hasEdits: boolean,
|
||||
planVersion: number
|
||||
): void {
|
||||
this.emitPlanEvent('plan_approved', featureId, projectPath, {
|
||||
hasEdits,
|
||||
planVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan rejected event
|
||||
*/
|
||||
emitRejected(featureId: string, projectPath: string, feedback?: string): void {
|
||||
this.emitPlanEvent('plan_rejected', featureId, projectPath, { feedback });
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan auto-approved event
|
||||
*/
|
||||
emitAutoApproved(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
planContent: string,
|
||||
planningMode: PlanningMode
|
||||
): void {
|
||||
this.emitPlanEvent('plan_auto_approved', featureId, projectPath, {
|
||||
planContent,
|
||||
planningMode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit plan revision requested event
|
||||
*/
|
||||
emitRevisionRequested(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
feedback: string | undefined,
|
||||
hasEdits: boolean,
|
||||
planVersion: number
|
||||
): void {
|
||||
this.emitPlanEvent('plan_revision_requested', featureId, projectPath, {
|
||||
feedback,
|
||||
hasEdits,
|
||||
planVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
111
apps/server/src/services/auto-mode/project-analyzer.ts
Normal file
111
apps/server/src/services/auto-mode/project-analyzer.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Project Analyzer - Analyzes project structure and context
|
||||
*
|
||||
* Provides project analysis functionality using Claude to understand
|
||||
* codebase architecture, patterns, and conventions.
|
||||
*/
|
||||
|
||||
import type { ExecuteOptions } from '@automaker/types';
|
||||
import { createLogger, classifyError } from '@automaker/utils';
|
||||
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
|
||||
import { getAutomakerDir } from '@automaker/platform';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
|
||||
import { processStream } from '../../lib/stream-processor.js';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
|
||||
const logger = createLogger('ProjectAnalyzer');
|
||||
|
||||
const ANALYSIS_PROMPT = `Analyze this project and provide a summary of:
|
||||
1. Project structure and architecture
|
||||
2. Main technologies and frameworks used
|
||||
3. Key components and their responsibilities
|
||||
4. Build and test commands
|
||||
5. Any existing conventions or patterns
|
||||
|
||||
Format your response as a structured markdown document.`;
|
||||
|
||||
export class ProjectAnalyzer {
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze project to gather context
|
||||
*/
|
||||
async analyze(projectPath: string): Promise<void> {
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const analysisFeatureId = `analysis-${Date.now()}`;
|
||||
|
||||
this.emitEvent('auto_mode_feature_start', {
|
||||
featureId: analysisFeatureId,
|
||||
projectPath,
|
||||
feature: {
|
||||
id: analysisFeatureId,
|
||||
title: 'Project Analysis',
|
||||
description: 'Analyzing project structure',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const analysisModel = resolveModelString(undefined, DEFAULT_MODELS.claude);
|
||||
const provider = ProviderFactory.getProviderForModel(analysisModel);
|
||||
|
||||
const options: ExecuteOptions = {
|
||||
prompt: ANALYSIS_PROMPT,
|
||||
model: analysisModel,
|
||||
maxTurns: 5,
|
||||
cwd: projectPath,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
};
|
||||
|
||||
const stream = provider.executeQuery(options);
|
||||
let analysisResult = '';
|
||||
|
||||
const result = await processStream(stream, {
|
||||
onText: (text) => {
|
||||
analysisResult += text;
|
||||
this.emitEvent('auto_mode_progress', {
|
||||
featureId: analysisFeatureId,
|
||||
content: text,
|
||||
projectPath,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
analysisResult = result.text || analysisResult;
|
||||
|
||||
// Save analysis
|
||||
const automakerDir = getAutomakerDir(projectPath);
|
||||
const analysisPath = path.join(automakerDir, 'project-analysis.md');
|
||||
await secureFs.mkdir(automakerDir, { recursive: true });
|
||||
await secureFs.writeFile(analysisPath, analysisResult);
|
||||
|
||||
this.emitEvent('auto_mode_feature_complete', {
|
||||
featureId: analysisFeatureId,
|
||||
passes: true,
|
||||
message: 'Project analysis completed',
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
this.emitEvent('auto_mode_error', {
|
||||
featureId: analysisFeatureId,
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(eventType: string, data: Record<string, unknown>): void {
|
||||
this.events.emit('auto-mode:event', { type: eventType, ...data });
|
||||
}
|
||||
}
|
||||
268
apps/server/src/services/auto-mode/task-executor.ts
Normal file
268
apps/server/src/services/auto-mode/task-executor.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Task Executor - Multi-agent task execution for spec-driven development
|
||||
*
|
||||
* Handles the sequential execution of parsed tasks from a spec,
|
||||
* where each task gets its own focused agent call.
|
||||
*/
|
||||
|
||||
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { BaseProvider } from '../../providers/base-provider.js';
|
||||
import { buildTaskPrompt } from '@automaker/prompts';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { processStream } from '../../lib/stream-processor.js';
|
||||
import type { TaskExecutionContext, TaskProgress } from './types.js';
|
||||
|
||||
const logger = createLogger('TaskExecutor');
|
||||
|
||||
/**
|
||||
* Handles multi-agent task execution for spec-driven development
|
||||
*/
|
||||
export class TaskExecutor {
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all tasks sequentially
|
||||
*
|
||||
* Each task gets its own focused agent call with context about
|
||||
* completed and remaining tasks.
|
||||
*
|
||||
* @param tasks - Parsed tasks from the spec
|
||||
* @param context - Execution context including provider, model, etc.
|
||||
* @param provider - The provider to use for execution
|
||||
* @yields TaskProgress events for each task
|
||||
*/
|
||||
async *executeAll(
|
||||
tasks: ParsedTask[],
|
||||
context: TaskExecutionContext,
|
||||
provider: BaseProvider
|
||||
): AsyncGenerator<TaskProgress> {
|
||||
logger.info(
|
||||
`Starting multi-agent execution: ${tasks.length} tasks for feature ${context.featureId}`
|
||||
);
|
||||
|
||||
for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
|
||||
const task = tasks[taskIndex];
|
||||
|
||||
// Check for abort
|
||||
if (context.abortController.signal.aborted) {
|
||||
throw new Error('Feature execution aborted');
|
||||
}
|
||||
|
||||
// Emit task started
|
||||
logger.info(`Starting task ${task.id}: ${task.description}`);
|
||||
this.emitTaskEvent('auto_mode_task_started', context, {
|
||||
taskId: task.id,
|
||||
taskDescription: task.description,
|
||||
taskIndex,
|
||||
tasksTotal: tasks.length,
|
||||
});
|
||||
|
||||
yield {
|
||||
taskId: task.id,
|
||||
taskIndex,
|
||||
tasksTotal: tasks.length,
|
||||
status: 'started',
|
||||
};
|
||||
|
||||
// Build focused prompt for this task
|
||||
const taskPrompt = buildTaskPrompt(
|
||||
task,
|
||||
tasks,
|
||||
taskIndex,
|
||||
context.planContent,
|
||||
context.userFeedback
|
||||
);
|
||||
|
||||
// Execute task with dedicated agent call
|
||||
const taskOptions: ExecuteOptions = {
|
||||
prompt: taskPrompt,
|
||||
model: context.model,
|
||||
maxTurns: Math.min(context.maxTurns, 50), // Limit turns per task
|
||||
cwd: context.workDir,
|
||||
allowedTools: context.allowedTools,
|
||||
abortController: context.abortController,
|
||||
};
|
||||
|
||||
const taskStream = provider.executeQuery(taskOptions);
|
||||
|
||||
// Process task stream
|
||||
let taskOutput = '';
|
||||
try {
|
||||
const result = await processStream(taskStream, {
|
||||
onText: (text) => {
|
||||
taskOutput += text;
|
||||
this.emitProgressEvent(context.featureId, text);
|
||||
},
|
||||
onToolUse: (name, input) => {
|
||||
this.emitToolEvent(context.featureId, name, input);
|
||||
},
|
||||
});
|
||||
taskOutput = result.text;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Task ${task.id} failed: ${errorMessage}`);
|
||||
yield {
|
||||
taskId: task.id,
|
||||
taskIndex,
|
||||
tasksTotal: tasks.length,
|
||||
status: 'failed',
|
||||
output: errorMessage,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Emit task completed
|
||||
logger.info(`Task ${task.id} completed for feature ${context.featureId}`);
|
||||
this.emitTaskEvent('auto_mode_task_complete', context, {
|
||||
taskId: task.id,
|
||||
tasksCompleted: taskIndex + 1,
|
||||
tasksTotal: tasks.length,
|
||||
});
|
||||
|
||||
// Check for phase completion
|
||||
const phaseComplete = this.checkPhaseComplete(task, tasks, taskIndex);
|
||||
|
||||
yield {
|
||||
taskId: task.id,
|
||||
taskIndex,
|
||||
tasksTotal: tasks.length,
|
||||
status: 'completed',
|
||||
output: taskOutput,
|
||||
phaseComplete,
|
||||
};
|
||||
|
||||
// Emit phase complete if needed
|
||||
if (phaseComplete !== undefined) {
|
||||
this.emitPhaseComplete(context, phaseComplete);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`All ${tasks.length} tasks completed for feature ${context.featureId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single task (for cases where you don't need the full loop)
|
||||
*
|
||||
* @param task - The task to execute
|
||||
* @param allTasks - All tasks for context
|
||||
* @param taskIndex - Index of this task
|
||||
* @param context - Execution context
|
||||
* @param provider - The provider to use
|
||||
* @returns Task output text
|
||||
*/
|
||||
async executeOne(
|
||||
task: ParsedTask,
|
||||
allTasks: ParsedTask[],
|
||||
taskIndex: number,
|
||||
context: TaskExecutionContext,
|
||||
provider: BaseProvider
|
||||
): Promise<string> {
|
||||
const taskPrompt = buildTaskPrompt(
|
||||
task,
|
||||
allTasks,
|
||||
taskIndex,
|
||||
context.planContent,
|
||||
context.userFeedback
|
||||
);
|
||||
|
||||
const taskOptions: ExecuteOptions = {
|
||||
prompt: taskPrompt,
|
||||
model: context.model,
|
||||
maxTurns: Math.min(context.maxTurns, 50),
|
||||
cwd: context.workDir,
|
||||
allowedTools: context.allowedTools,
|
||||
abortController: context.abortController,
|
||||
};
|
||||
|
||||
const taskStream = provider.executeQuery(taskOptions);
|
||||
|
||||
const result = await processStream(taskStream, {
|
||||
onText: (text) => {
|
||||
this.emitProgressEvent(context.featureId, text);
|
||||
},
|
||||
onToolUse: (name, input) => {
|
||||
this.emitToolEvent(context.featureId, name, input);
|
||||
},
|
||||
});
|
||||
|
||||
return result.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if completing this task completes a phase
|
||||
*/
|
||||
private checkPhaseComplete(
|
||||
task: ParsedTask,
|
||||
allTasks: ParsedTask[],
|
||||
taskIndex: number
|
||||
): number | undefined {
|
||||
if (!task.phase) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextTask = allTasks[taskIndex + 1];
|
||||
if (!nextTask || nextTask.phase !== task.phase) {
|
||||
// Phase changed or no more tasks
|
||||
const phaseMatch = task.phase.match(/Phase\s*(\d+)/i);
|
||||
return phaseMatch ? parseInt(phaseMatch[1], 10) : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a task-related event
|
||||
*/
|
||||
private emitTaskEvent(
|
||||
eventType: string,
|
||||
context: TaskExecutionContext,
|
||||
data: Record<string, unknown>
|
||||
): void {
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: eventType,
|
||||
featureId: context.featureId,
|
||||
projectPath: context.projectPath,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit progress event for text output
|
||||
*/
|
||||
private emitProgressEvent(featureId: string, content: string): void {
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: 'auto_mode_progress',
|
||||
featureId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tool use event
|
||||
*/
|
||||
private emitToolEvent(featureId: string, tool: string, input: unknown): void {
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: 'auto_mode_tool',
|
||||
featureId,
|
||||
tool,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit phase complete event
|
||||
*/
|
||||
private emitPhaseComplete(context: TaskExecutionContext, phaseNumber: number): void {
|
||||
this.events.emit('auto-mode:event', {
|
||||
type: 'auto_mode_phase_complete',
|
||||
featureId: context.featureId,
|
||||
projectPath: context.projectPath,
|
||||
phaseNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
121
apps/server/src/services/auto-mode/types.ts
Normal file
121
apps/server/src/services/auto-mode/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Internal types for AutoModeService
|
||||
*
|
||||
* These types are used internally by the auto-mode services
|
||||
* and are not exported to the public API.
|
||||
*/
|
||||
|
||||
import type { PlanningMode, PlanSpec } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Running feature state
|
||||
*/
|
||||
export interface RunningFeature {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
worktreePath: string | null;
|
||||
branchName: string | null;
|
||||
abortController: AbortController;
|
||||
isAutoMode: boolean;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-loop configuration
|
||||
*/
|
||||
export interface AutoLoopState {
|
||||
projectPath: string;
|
||||
maxConcurrency: number;
|
||||
abortController: AbortController;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-mode configuration
|
||||
*/
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending plan approval state
|
||||
*/
|
||||
export interface PendingApproval {
|
||||
resolve: (result: ApprovalResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of plan approval
|
||||
*/
|
||||
export interface ApprovalResult {
|
||||
approved: boolean;
|
||||
editedPlan?: string;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a feature
|
||||
*/
|
||||
export interface FeatureExecutionOptions {
|
||||
continuationPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for running the agent
|
||||
*/
|
||||
export interface RunAgentOptions {
|
||||
projectPath: string;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature with planning fields for internal use
|
||||
*/
|
||||
export interface FeatureWithPlanning {
|
||||
id: string;
|
||||
description: string;
|
||||
spec?: string;
|
||||
model?: string;
|
||||
imagePaths?: Array<string | { path: string; filename?: string; mimeType?: string }>;
|
||||
branchName?: string;
|
||||
skipTests?: boolean;
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
planSpec?: PlanSpec;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task execution context
|
||||
*/
|
||||
export interface TaskExecutionContext {
|
||||
workDir: string;
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
model: string;
|
||||
maxTurns: number;
|
||||
allowedTools?: string[];
|
||||
abortController: AbortController;
|
||||
planContent: string;
|
||||
userFeedback?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task progress event
|
||||
*/
|
||||
export interface TaskProgress {
|
||||
taskId: string;
|
||||
taskIndex: number;
|
||||
tasksTotal: number;
|
||||
status: 'started' | 'completed' | 'failed';
|
||||
output?: string;
|
||||
phaseComplete?: number;
|
||||
}
|
||||
157
apps/server/src/services/auto-mode/worktree-manager.ts
Normal file
157
apps/server/src/services/auto-mode/worktree-manager.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Worktree Manager - Git worktree operations for feature isolation
|
||||
*
|
||||
* Handles finding and resolving git worktrees for feature branches.
|
||||
* Worktrees are created when features are added/edited, this service
|
||||
* finds existing worktrees for execution.
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('WorktreeManager');
|
||||
|
||||
/**
|
||||
* Result of resolving a working directory
|
||||
*/
|
||||
export interface WorkDirResult {
|
||||
/** The resolved working directory path */
|
||||
workDir: string;
|
||||
/** The worktree path if using a worktree, null otherwise */
|
||||
worktreePath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages git worktree operations for feature isolation
|
||||
*/
|
||||
export class WorktreeManager {
|
||||
/**
|
||||
* Find existing worktree path for a branch
|
||||
*
|
||||
* Parses `git worktree list --porcelain` output to find the worktree
|
||||
* associated with a specific branch.
|
||||
*
|
||||
* @param projectPath - The main project path
|
||||
* @param branchName - The branch to find a worktree for
|
||||
* @returns The absolute path to the worktree, or null if not found
|
||||
*/
|
||||
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const lines = stdout.split('\n');
|
||||
let currentPath: string | null = null;
|
||||
let currentBranch: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
} else if (line === '' && currentPath && currentBranch) {
|
||||
// End of a worktree entry
|
||||
if (currentBranch === branchName) {
|
||||
// Resolve to absolute path - git may return relative paths
|
||||
// On Windows, this is critical for cwd to work correctly
|
||||
const resolvedPath = path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
return resolvedPath;
|
||||
}
|
||||
currentPath = null;
|
||||
currentBranch = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check the last entry (if file doesn't end with newline)
|
||||
if (currentPath && currentBranch && currentBranch === branchName) {
|
||||
const resolvedPath = path.isAbsolute(currentPath)
|
||||
? path.resolve(currentPath)
|
||||
: path.resolve(projectPath, currentPath);
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to find worktree for branch ${branchName}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for feature execution
|
||||
*
|
||||
* If worktrees are enabled and a branch name is provided, attempts to
|
||||
* find an existing worktree. Falls back to the project path if no
|
||||
* worktree is found.
|
||||
*
|
||||
* @param projectPath - The main project path
|
||||
* @param branchName - Optional branch name to look for
|
||||
* @param useWorktrees - Whether to use worktrees
|
||||
* @returns The resolved work directory and worktree path
|
||||
*/
|
||||
async resolveWorkDir(
|
||||
projectPath: string,
|
||||
branchName: string | undefined,
|
||||
useWorktrees: boolean
|
||||
): Promise<WorkDirResult> {
|
||||
let worktreePath: string | null = null;
|
||||
|
||||
if (useWorktrees && branchName) {
|
||||
worktreePath = await this.findWorktreeForBranch(projectPath, branchName);
|
||||
|
||||
if (worktreePath) {
|
||||
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
} else {
|
||||
logger.warn(`Worktree for branch "${branchName}" not found, using project path`);
|
||||
}
|
||||
}
|
||||
|
||||
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||
|
||||
return { workDir, worktreePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a valid worktree
|
||||
*
|
||||
* @param worktreePath - Path to check
|
||||
* @returns True if the path is a valid git worktree
|
||||
*/
|
||||
async isValidWorktree(worktreePath: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if .git file exists (worktrees have a .git file, not directory)
|
||||
const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the branch name for a worktree
|
||||
*
|
||||
* @param worktreePath - Path to the worktree
|
||||
* @returns The branch name or null if not a valid worktree
|
||||
*/
|
||||
async getWorktreeBranch(worktreePath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for convenience
|
||||
export const worktreeManager = new WorktreeManager();
|
||||
Reference in New Issue
Block a user