UNPKG

aios-core

Version:

Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework

1,181 lines (1,047 loc) 36.4 kB
/** * Workflow Executor - Development Cycle Engine * * Story 11.3: Projeto Bob - Development Cycle Workflow * Story 11.5: Session State Persistence (ADR-011) * * Executes the development cycle workflow with: * - Dynamic executor assignment (Story 11.1) * - Terminal spawning for clean context (Story 11.2) * - Session state persistence (Story 11.5) * - Conditional self-healing with CodeRabbit * - Quality gate by different agent * - Human checkpoints (GO/PAUSE/REVIEW/ABORT) * * @module core/orchestration/workflow-executor * @version 1.1.0 */ 'use strict'; const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); const yaml = require('js-yaml'); // Import dependencies from Story 11.1, 11.2, and 11.5 const ExecutorAssignment = require('./executor-assignment'); const TerminalSpawner = require('./terminal-spawner'); const { SessionState, ActionType } = require('./session-state'); // Constants const DEFAULT_TIMEOUT_MS = 7200000; // 2 hours const CHECKPOINT_TIMEOUT_MS = 1800000; // 30 minutes const STATE_SAVE_INTERVAL_MS = 300000; // 5 minutes /** * Workflow phase status * @enum {string} */ const PhaseStatus = { PENDING: 'pending', RUNNING: 'running', COMPLETED: 'completed', FAILED: 'failed', SKIPPED: 'skipped', }; /** * Checkpoint decision options * @enum {string} */ const CheckpointDecision = { GO: 'GO', PAUSE: 'PAUSE', REVIEW: 'REVIEW', ABORT: 'ABORT', }; /** * Workflow execution state * @typedef {Object} WorkflowState * @property {string} workflowId - Workflow identifier * @property {string} currentPhase - Current phase ID * @property {string} currentStory - Path to current story file * @property {string} executor - Current executor agent * @property {string} qualityGate - Current quality gate agent * @property {number} attemptCount - Number of attempts for current phase * @property {Date} startedAt - Workflow start time * @property {Date} lastUpdated - Last state update time * @property {Object} phaseResults - Results from each phase * @property {Object} accumulatedContext - Context accumulated across phases */ /** * Workflow Executor class */ class WorkflowExecutor { /** * Creates a new WorkflowExecutor instance * @param {string} projectRoot - Project root directory * @param {Object} options - Executor options */ constructor(projectRoot, options = {}) { this.projectRoot = projectRoot; this.options = { debug: false, autoResume: true, saveState: true, useSessionState: true, // Story 11.5: Use unified session state ...options, }; this.workflowPath = path.join( projectRoot, '.aios-core/development/workflows/development-cycle.yaml', ); this.statePath = path.join(projectRoot, '.aios/workflow-state/'); this.configPath = path.join(projectRoot, '.aios-core/core-config.yaml'); this.workflow = null; this.state = null; this.config = null; // Story 11.5: Session State Manager (ADR-011) this.sessionState = new SessionState(projectRoot, { debug: options.debug }); // Story 12.6: Phase change callbacks for observability this._phaseChangeCallbacks = []; this._agentSpawnCallbacks = []; this._terminalSpawnCallbacks = []; } /** * Registers a callback for phase change events (Story 12.6 - AC1) * @param {Function} callback - Callback function (phase, storyId, executor) => void * @returns {void} */ onPhaseChange(callback) { if (typeof callback === 'function') { this._phaseChangeCallbacks.push(callback); } } /** * Registers a callback for agent spawn events (Story 12.6 - AC1) * @param {Function} callback - Callback function (agent, task) => void * @returns {void} */ onAgentSpawn(callback) { if (typeof callback === 'function') { this._agentSpawnCallbacks.push(callback); } } /** * Registers a callback for terminal spawn events (Story 12.6 - AC1) * @param {Function} callback - Callback function (agent, pid, task) => void * @returns {void} */ onTerminalSpawn(callback) { if (typeof callback === 'function') { this._terminalSpawnCallbacks.push(callback); } } /** * Emits phase change to all registered callbacks (Story 12.6) * @param {string} phase - Phase name * @param {string} storyId - Story ID * @param {string} executor - Executor agent * @private */ _emitPhaseChange(phase, storyId, executor) { for (const callback of this._phaseChangeCallbacks) { try { callback(phase, storyId, executor); } catch (error) { if (this.options.debug) { console.log(`[WorkflowExecutor] Phase change callback error: ${error.message}`); } } } } /** * Emits agent spawn to all registered callbacks (Story 12.6) * @param {string} agent - Agent ID * @param {string} task - Task being executed * @private */ _emitAgentSpawn(agent, task) { for (const callback of this._agentSpawnCallbacks) { try { callback(agent, task); } catch (error) { if (this.options.debug) { console.log(`[WorkflowExecutor] Agent spawn callback error: ${error.message}`); } } } } /** * Emits terminal spawn to all registered callbacks (Story 12.6) * @param {string} agent - Agent ID * @param {number} pid - Process ID * @param {string} task - Task being executed * @private */ _emitTerminalSpawn(agent, pid, task) { for (const callback of this._terminalSpawnCallbacks) { try { callback(agent, pid, task); } catch (error) { if (this.options.debug) { console.log(`[WorkflowExecutor] Terminal spawn callback error: ${error.message}`); } } } } /** * Loads the workflow definition * @returns {Promise<Object>} Workflow definition */ async loadWorkflow() { const content = await fs.readFile(this.workflowPath, 'utf8'); this.workflow = yaml.load(content); return this.workflow; } /** * Loads the core configuration * @returns {Promise<Object>} Core configuration */ async loadConfig() { try { const content = await fs.readFile(this.configPath, 'utf8'); this.config = yaml.load(content); } catch (error) { if (this.options.debug) { console.log(`[WorkflowExecutor] Config not found at ${this.configPath}, using defaults`); console.log(`[WorkflowExecutor] Error: ${error.message}`); } this.config = { coderabbit_integration: { enabled: false } }; } return this.config; } /** * Initializes or resumes workflow state * @param {string} storyPath - Path to story file * @returns {Promise<WorkflowState>} Workflow state */ async initializeState(storyPath) { const stateFile = this.getStateFilePath(storyPath); // Check for existing state if (this.options.autoResume && fsSync.existsSync(stateFile)) { const content = await fs.readFile(stateFile, 'utf8'); this.state = yaml.load(content); this.state.lastUpdated = new Date(); if (this.options.debug) { console.log(`[WorkflowExecutor] Resumed state from: ${stateFile}`); } return this.state; } // Create new state this.state = { workflowId: 'development-cycle', currentPhase: '1_validation', currentStory: storyPath, executor: null, qualityGate: null, attemptCount: 0, startedAt: new Date(), lastUpdated: new Date(), phaseResults: {}, accumulatedContext: {}, }; await this.saveState(); return this.state; } /** * Gets the state file path for a story * @param {string} storyPath - Path to story file * @returns {string} State file path */ getStateFilePath(storyPath) { const storyName = path.basename(storyPath, '.story.md'); return path.join(this.statePath, `${storyName}-state.yaml`); } /** * Saves the current workflow state * @returns {Promise<void>} */ async saveState() { if (!this.options.saveState) return; await fs.mkdir(this.statePath, { recursive: true }); const stateFile = this.getStateFilePath(this.state.currentStory); this.state.lastUpdated = new Date(); await fs.writeFile(stateFile, yaml.dump(this.state)); // Story 11.5: Also update unified session state (ADR-011) await this.syncToSessionState(); } /** * Syncs internal workflow state to unified session state (Story 11.5) * @returns {Promise<void>} */ async syncToSessionState() { if (!this.options.useSessionState) return; try { // Load or create session state const sessionExists = await this.sessionState.exists(); if (!sessionExists) { // Session state will be created by Bob orchestrator // We just update if it exists return; } await this.sessionState.loadSessionState(); // Map phase ID to phase name const phaseNameMap = { '1_validation': 'validation', '2_development': 'development', '3_self_healing': 'self_healing', '4_quality_gate': 'quality_gate', '5_push': 'push', '6_checkpoint': 'checkpoint', }; const phaseName = phaseNameMap[this.state.currentPhase] || this.state.currentPhase; await this.sessionState.updateSessionState({ workflow: { current_phase: phaseName, attempt_count: this.state.attemptCount, phase_results: this.state.phaseResults, }, last_action: { type: ActionType.PHASE_CHANGE, story: this.state.currentStory, phase: phaseName, }, context_snapshot: { last_executor: this.state.executor, }, }); if (this.options.debug) { console.log(`[WorkflowExecutor] Synced to session state: phase=${phaseName}`); } } catch (error) { // Non-fatal - log and continue if (this.options.debug) { console.log(`[WorkflowExecutor] Session state sync failed: ${error.message}`); } } } /** * Reads story metadata from YAML frontmatter * @param {string} storyPath - Path to story file * @returns {Promise<Object>} Story metadata */ async readStoryMetadata(storyPath) { const content = await fs.readFile(storyPath, 'utf8'); // Extract YAML from markdown frontmatter const yamlMatch = content.match(/```yaml\n([\s\S]*?)```/); if (!yamlMatch) { throw new Error(`No YAML frontmatter found in story: ${storyPath}`); } return yaml.load(yamlMatch[1]); } /** * Executes the development cycle workflow * @param {string} storyPath - Path to story file * @param {Object} epicContext - Optional epic context * @returns {Promise<Object>} Execution result */ async execute(storyPath, epicContext = {}) { // Load workflow and config await this.loadWorkflow(); await this.loadConfig(); // Initialize state await this.initializeState(storyPath); // Read story metadata const storyMetadata = await this.readStoryMetadata(storyPath); this.state.executor = storyMetadata.executor; this.state.qualityGate = storyMetadata.quality_gate; if (this.options.debug) { console.log('[WorkflowExecutor] Starting workflow execution'); console.log(` Story: ${storyPath}`); console.log(` Executor: ${this.state.executor}`); console.log(` Quality Gate: ${this.state.qualityGate}`); } // Execute phases let currentPhase = this.state.currentPhase; let continueExecution = true; while (continueExecution) { const phaseResult = await this.executePhase(currentPhase, storyPath, epicContext); this.state.phaseResults[currentPhase] = phaseResult; await this.saveState(); if (phaseResult.status === PhaseStatus.FAILED) { const errorHandler = this.getErrorHandler(currentPhase, phaseResult); const handlerResult = await this.handleError(errorHandler, phaseResult); if (handlerResult.retry) { this.state.attemptCount++; continue; } else if (handlerResult.nextPhase) { currentPhase = handlerResult.nextPhase; continue; } else { continueExecution = false; } } else if (phaseResult.status === PhaseStatus.COMPLETED) { currentPhase = this.getNextPhase(currentPhase, phaseResult); if (!currentPhase) { continueExecution = false; } else if (currentPhase === 'workflow_paused') { continueExecution = false; } else if (currentPhase === 'workflow_aborted') { continueExecution = false; } this.state.currentPhase = currentPhase; this.state.attemptCount = 0; } else if (phaseResult.status === PhaseStatus.SKIPPED) { currentPhase = this.getNextPhase(currentPhase, phaseResult); this.state.currentPhase = currentPhase; } } return { success: this.state.phaseResults['6_checkpoint']?.decision !== CheckpointDecision.ABORT, state: this.state, phaseResults: this.state.phaseResults, }; } /** * Executes a single workflow phase * @param {string} phaseId - Phase identifier * @param {string} storyPath - Path to story file * @param {Object} epicContext - Epic context * @returns {Promise<Object>} Phase execution result */ async executePhase(phaseId, storyPath, epicContext) { const phase = this.workflow.workflow.phases[phaseId]; if (!phase) { return { status: PhaseStatus.FAILED, error: `Phase not found: ${phaseId}` }; } if (this.options.debug) { console.log(`[WorkflowExecutor] Executing phase: ${phaseId}`); } // Check condition if (phase.condition) { const conditionMet = this.evaluateCondition(phase.condition); if (!conditionMet) { return { status: PhaseStatus.SKIPPED, reason: 'Condition not met' }; } } // Resolve dynamic agent const agent = this.resolveAgent(phase.agent); // Story 12.6: Emit phase change for observability (AC1, AC2) const storyId = path.basename(storyPath, '.story.md'); this._emitPhaseChange(phaseId, storyId, agent); // Execute based on phase type switch (phaseId) { case '1_validation': return this.executeValidationPhase(phase, agent, storyPath, epicContext); case '2_development': return this.executeDevelopmentPhase(phase, agent, storyPath); case '3_self_healing': return this.executeSelfHealingPhase(phase, agent); case '4_quality_gate': return this.executeQualityGatePhase(phase, agent, storyPath); case '5_push': return this.executePushPhase(phase, agent, storyPath); case '6_checkpoint': return this.executeCheckpointPhase(phase, agent, storyPath); default: return { status: PhaseStatus.FAILED, error: `Unknown phase: ${phaseId}` }; } } /** * Resolves dynamic agent references * @param {string} agentRef - Agent reference (may be dynamic) * @returns {string} Resolved agent ID */ resolveAgent(agentRef) { if (agentRef === '${story.executor}') { return this.state.executor; } if (agentRef === '${story.quality_gate}') { return this.state.qualityGate; } return agentRef; } /** * Evaluates a condition expression * @param {string} condition - Condition expression * @returns {boolean} Condition result */ evaluateCondition(condition) { // Handle CodeRabbit integration check if (condition.includes('coderabbit_integration.enabled')) { return this.config?.coderabbit_integration?.enabled === true; } return true; } /** * Executes Phase 1: Story Validation * @param {Object} phase - Phase configuration * @param {string} agent - Agent ID * @param {string} storyPath - Path to story file * @param {Object} epicContext - Epic context * @returns {Promise<Object>} Phase result */ async executeValidationPhase(phase, agent, storyPath, epicContext) { try { // Validate story has required fields const storyMetadata = await this.readStoryMetadata(storyPath); const issues = []; if (!storyMetadata.executor) { issues.push('Story must have an executor assigned'); } if (!storyMetadata.quality_gate) { issues.push('Story must have a quality_gate assigned'); } if (storyMetadata.executor === storyMetadata.quality_gate) { issues.push('Executor and Quality Gate must be different agents'); } // Validate executor assignment using Story 11.1 if (storyMetadata.executor && storyMetadata.quality_gate) { const validation = ExecutorAssignment.validateExecutorAssignment({ executor: storyMetadata.executor, quality_gate: storyMetadata.quality_gate, quality_gate_tools: storyMetadata.quality_gate_tools || ['code_review'], }); if (!validation.isValid) { issues.push(...validation.errors); } } if (issues.length > 0) { return { status: PhaseStatus.FAILED, validation_result: { passed: false, issues }, }; } // Update accumulated context with epic info this.state.accumulatedContext = { ...this.state.accumulatedContext, ...epicContext, validatedAt: new Date(), }; return { status: PhaseStatus.COMPLETED, validation_result: { passed: true, score: 100, issues: [] }, }; } catch (error) { return { status: PhaseStatus.FAILED, error: error.message, }; } } /** * Executes Phase 2: Development (Dynamic Executor) * @param {Object} phase - Phase configuration * @param {string} agent - Agent ID (dynamic) * @param {string} storyPath - Path to story file * @returns {Promise<Object>} Phase result */ async executeDevelopmentPhase(phase, agent, storyPath) { try { if (this.options.debug) { console.log(`[WorkflowExecutor] Spawning ${agent} for development`); } // Story 12.6: Emit agent spawn for observability (AC1) this._emitAgentSpawn(agent, 'development'); // Use terminal spawning (Story 11.2) if (phase.spawn_in_terminal && TerminalSpawner.isSpawnerAvailable()) { const context = { story: storyPath, files: [], instructions: `Execute *develop for story: ${storyPath}`, metadata: this.state.accumulatedContext, }; const result = await TerminalSpawner.spawnAgent(agent.replace('@', ''), 'develop', { context, timeout: DEFAULT_TIMEOUT_MS, debug: this.options.debug, }); // Story 12.6: Emit terminal spawn for observability (AC1) if (result.pid) { this._emitTerminalSpawn(agent, result.pid, 'development'); } return { status: result.success ? PhaseStatus.COMPLETED : PhaseStatus.FAILED, implementation: { files_created: [], files_modified: [], tests_added: [], }, output: result.output, outputFile: result.outputFile, }; } // Fallback: Return pending for manual execution return { status: PhaseStatus.COMPLETED, implementation: { files_created: [], files_modified: [], tests_added: [], }, note: 'Terminal spawning not available, manual execution required', }; } catch (error) { return { status: PhaseStatus.FAILED, error: error.message, }; } } /** * Executes Phase 3: Self-Healing (Conditional) * Uses CodeRabbit CLI to detect and auto-fix issues. * @param {Object} phase - Phase configuration * @param {string} agent - Agent ID * @returns {Promise<Object>} Phase result */ async executeSelfHealingPhase(phase, agent) { try { const maxIterations = phase.config?.max_iterations || this.config?.coderabbit_integration?.self_healing?.max_iterations || 3; const severityFilter = phase.config?.severity_filter || ['CRITICAL', 'HIGH']; let iterations = 0; const issuesFixed = []; const issuesRemaining = []; // Check if CodeRabbit is available const coderabbitConfig = this.config?.coderabbit_integration; if (!coderabbitConfig?.enabled) { if (this.options.debug) { console.log('[WorkflowExecutor] CodeRabbit not enabled, skipping self-healing'); } return { status: PhaseStatus.SKIPPED, reason: 'CodeRabbit integration not enabled', }; } // Self-healing loop while (iterations < maxIterations) { iterations++; if (this.options.debug) { console.log(`[WorkflowExecutor] Self-healing iteration ${iterations}/${maxIterations}`); } // Run CodeRabbit analysis const analysisResult = await this.runCodeRabbitAnalysis(coderabbitConfig); if (!analysisResult.success) { if (this.options.debug) { console.log(`[WorkflowExecutor] CodeRabbit analysis failed: ${analysisResult.error}`); } // Graceful degradation - continue without self-healing if (coderabbitConfig.graceful_degradation?.skip_if_not_installed) { return { status: PhaseStatus.COMPLETED, healed_code: { iterations, issues_fixed: issuesFixed, issues_remaining: issuesRemaining, note: analysisResult.error || coderabbitConfig.graceful_degradation?.fallback_message, }, }; } break; } // Filter issues by severity const relevantIssues = analysisResult.issues.filter( (issue) => severityFilter.includes(issue.severity), ); if (relevantIssues.length === 0) { if (this.options.debug) { console.log('[WorkflowExecutor] No relevant issues found, self-healing complete'); } break; } // Attempt to fix issues for (const issue of relevantIssues) { const fixed = await this.attemptAutoFix(issue); if (fixed) { issuesFixed.push({ file: issue.file, line: issue.line, severity: issue.severity, message: issue.message, fixedAt: new Date().toISOString(), }); } else { issuesRemaining.push({ file: issue.file, line: issue.line, severity: issue.severity, message: issue.message, }); } } // If no issues were fixed in this iteration, stop if (issuesFixed.length === 0 && iterations > 1) { if (this.options.debug) { console.log('[WorkflowExecutor] No issues fixed in iteration, stopping'); } break; } } return { status: PhaseStatus.COMPLETED, healed_code: { iterations, issues_fixed: issuesFixed, issues_remaining: issuesRemaining, }, }; } catch (error) { if (this.options.debug) { console.log(`[WorkflowExecutor] Self-healing error: ${error.message}`); } return { status: PhaseStatus.FAILED, error: error.message, }; } } /** * Runs CodeRabbit analysis on the codebase * @param {Object} coderabbitConfig - CodeRabbit configuration * @returns {Promise<Object>} Analysis result with issues array */ async runCodeRabbitAnalysis(coderabbitConfig) { try { const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); // Build command based on installation mode let command; if (coderabbitConfig.installation_mode === 'wsl') { const wslPath = this.projectRoot.replace(/^([A-Z]):/, (_, drive) => `/mnt/${drive.toLowerCase()}`).replace(/\\/g, '/'); command = `wsl bash -c 'cd "${wslPath}" && ~/.local/bin/coderabbit --prompt-only -t uncommitted 2>&1'`; } else { command = 'coderabbit --prompt-only -t uncommitted'; } if (this.options.debug) { console.log(`[WorkflowExecutor] Running CodeRabbit: ${command}`); } const { stdout, stderr } = await execAsync(command, { timeout: (coderabbitConfig.self_healing?.timeout_minutes || 30) * 60 * 1000, maxBuffer: 10 * 1024 * 1024, // 10MB }); // Parse CodeRabbit output to extract issues const issues = this.parseCodeRabbitOutput(stdout); return { success: true, issues, rawOutput: stdout, }; } catch (error) { // Handle command not found or execution errors if (error.code === 'ENOENT' || error.message?.includes('not found')) { return { success: false, error: 'CodeRabbit CLI not installed', issues: [], }; } return { success: false, error: error.message, issues: [], }; } } /** * Parses CodeRabbit CLI output to extract issues * @param {string} output - Raw CodeRabbit output * @returns {Array} Array of issue objects */ parseCodeRabbitOutput(output) { const issues = []; if (!output) return issues; // CodeRabbit outputs issues in various formats // Try to parse structured format first (JSON-like blocks) const jsonMatch = output.match(/```json\n([\s\S]*?)```/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[1]); if (Array.isArray(parsed)) { return parsed.map((item) => ({ file: item.file || item.path || 'unknown', line: item.line || item.lineNumber || 0, severity: item.severity || 'MEDIUM', message: item.message || item.description || '', suggestion: item.suggestion || item.fix || null, })); } } catch { // Not valid JSON, continue with text parsing } } // Parse text-based output (line-by-line issues) const lines = output.split('\n'); const severityPattern = /\[(CRITICAL|HIGH|MEDIUM|LOW)\]/i; const filePattern = /([^\s:]+):(\d+)/; for (const line of lines) { const severityMatch = line.match(severityPattern); const fileMatch = line.match(filePattern); if (severityMatch) { issues.push({ file: fileMatch ? fileMatch[1] : 'unknown', line: fileMatch ? parseInt(fileMatch[2], 10) : 0, severity: severityMatch[1].toUpperCase(), message: line.replace(severityPattern, '').replace(filePattern, '').trim(), suggestion: null, }); } } return issues; } /** * Attempts to auto-fix a single issue * @param {Object} issue - Issue to fix * @returns {Promise<boolean>} True if fixed successfully */ async attemptAutoFix(issue) { // For MVP, we don't attempt actual auto-fixes // This would require integration with an AI model to generate fixes // Just log the attempt and return false if (this.options.debug) { console.log(`[WorkflowExecutor] Would auto-fix: ${issue.file}:${issue.line} - ${issue.message}`); } // If issue has a suggestion, we could apply it // For now, mark as not fixed - requires human review return false; } /** * Executes Phase 4: Quality Gate * @param {Object} phase - Phase configuration * @param {string} agent - Agent ID (quality gate) * @param {string} storyPath - Path to story file * @returns {Promise<Object>} Phase result */ async executeQualityGatePhase(phase, agent, storyPath) { try { // Validate that quality gate is different from executor if (agent === this.state.executor) { return { status: PhaseStatus.FAILED, error: 'Quality Gate agent must be different from executor', }; } if (this.options.debug) { console.log(`[WorkflowExecutor] Spawning ${agent} for quality gate`); } // Story 12.6: Emit agent spawn for observability (AC1) this._emitAgentSpawn(agent, 'quality_gate'); // Use terminal spawning if (phase.spawn_in_terminal && TerminalSpawner.isSpawnerAvailable()) { const context = { story: storyPath, files: [], instructions: `Execute quality review for story: ${storyPath}`, metadata: { executor: this.state.executor, implementation: this.state.phaseResults['2_development']?.implementation, }, }; const result = await TerminalSpawner.spawnAgent(agent.replace('@', ''), 'quality-review', { context, timeout: DEFAULT_TIMEOUT_MS / 4, // 30 minutes debug: this.options.debug, }); // Story 12.6: Emit terminal spawn for observability (AC1) if (result.pid) { this._emitTerminalSpawn(agent, result.pid, 'quality_gate'); } return { status: result.success ? PhaseStatus.COMPLETED : PhaseStatus.FAILED, review_result: { verdict: result.success ? 'APPROVED' : 'NEEDS_WORK', score: result.success ? 90 : 60, findings: [], recommendations: [], }, output: result.output, }; } // Fallback return { status: PhaseStatus.COMPLETED, review_result: { verdict: 'APPROVED', score: 100, findings: [], recommendations: [], }, note: 'Terminal spawning not available, manual review required', }; } catch (error) { return { status: PhaseStatus.FAILED, error: error.message, }; } } /** * Executes Phase 5: Push & PR * @param {Object} phase - Phase configuration * @param {string} agent - Agent ID (@devops) * @param {string} storyPath - Path to story file * @returns {Promise<Object>} Phase result */ async executePushPhase(phase, agent, storyPath) { try { if (this.options.debug) { console.log(`[WorkflowExecutor] Spawning ${agent} for push`); } // Story 12.6: Emit agent spawn for observability (AC1) this._emitAgentSpawn(agent, 'push'); // Use terminal spawning if (phase.spawn_in_terminal && TerminalSpawner.isSpawnerAvailable()) { const context = { story: storyPath, files: [], instructions: `Execute *pre-push and *push for story: ${storyPath}`, metadata: { review_result: this.state.phaseResults['4_quality_gate']?.review_result, }, }; const result = await TerminalSpawner.spawnAgent(agent.replace('@', ''), 'push-and-pr', { context, timeout: DEFAULT_TIMEOUT_MS / 12, // 10 minutes debug: this.options.debug, }); // Story 12.6: Emit terminal spawn for observability (AC1) if (result.pid) { this._emitTerminalSpawn(agent, result.pid, 'push'); } return { status: result.success ? PhaseStatus.COMPLETED : PhaseStatus.FAILED, push_result: { commit_hash: '', branch: 'main', }, pr_url: '', output: result.output, }; } // Fallback return { status: PhaseStatus.COMPLETED, push_result: { commit_hash: '', branch: 'main', }, pr_url: '', note: 'Terminal spawning not available, manual push required', }; } catch (error) { return { status: PhaseStatus.FAILED, error: error.message, }; } } /** * Executes Phase 6: Checkpoint (Human Decision) * @param {Object} phase - Phase configuration * @param {string} agent - Agent ID (@po) * @param {string} storyPath - Path to story file * @returns {Promise<Object>} Phase result */ async executeCheckpointPhase(phase, agent, storyPath) { // This phase requires human interaction // In a real implementation, this would wait for user input if (this.options.debug) { console.log('[WorkflowExecutor] Checkpoint reached - awaiting human decision'); } // For now, return with GO decision (would be replaced by actual elicitation) return { status: PhaseStatus.COMPLETED, decision: CheckpointDecision.GO, next_story: null, awaiting_input: true, options: { GO: 'Continue to next story', PAUSE: 'Save state and stop', REVIEW: 'Show what was done', ABORT: 'Stop the epic', }, }; } /** * Gets the next phase based on current phase result * @param {string} currentPhase - Current phase ID * @param {Object} result - Phase result * @returns {string|null} Next phase ID or null */ getNextPhase(currentPhase, result) { const phase = this.workflow.workflow.phases[currentPhase]; if (!phase) return null; if (result.status === PhaseStatus.COMPLETED) { // Handle checkpoint decisions if (currentPhase === '6_checkpoint') { switch (result.decision) { case CheckpointDecision.GO: return '1_validation'; // Loop back case CheckpointDecision.PAUSE: return 'workflow_paused'; case CheckpointDecision.ABORT: return 'workflow_aborted'; case CheckpointDecision.REVIEW: return '6_checkpoint'; // Stay at checkpoint after review default: return null; } } return phase.on_success; } if (result.status === PhaseStatus.SKIPPED) { return phase.on_skip || phase.on_success; } return null; } /** * Gets the error handler for a phase * @param {string} phaseId - Phase ID * @param {Object} result - Phase result * @returns {string} Error handler ID */ getErrorHandler(phaseId, result) { const phase = this.workflow.workflow.phases[phaseId]; return phase?.on_failure || 'default_error_handler'; } /** * Handles workflow errors * @param {string} handlerId - Error handler ID * @param {Object} result - Phase result * @returns {Promise<Object>} Handler result */ async handleError(handlerId, result) { const handler = this.workflow.workflow.error_handlers?.[handlerId]; if (!handler) { return { retry: false, nextPhase: null }; } // Check max attempts const maxAttempts = handler.actions?.find((a) => a.max_attempts)?.max_attempts || 3; if (this.state.attemptCount >= maxAttempts) { return { retry: false, nextPhase: null, escalate: true }; } // Determine action if (handlerId === 'return_to_development') { return { retry: false, nextPhase: '2_development' }; } if (handlerId === 'return_to_quality_gate') { return { retry: false, nextPhase: '4_quality_gate' }; } return { retry: true }; } } /** * Creates a new workflow executor instance * @param {string} projectRoot - Project root directory * @param {Object} options - Executor options * @returns {WorkflowExecutor} Workflow executor instance */ function createWorkflowExecutor(projectRoot, options = {}) { return new WorkflowExecutor(projectRoot, options); } /** * Executes the development cycle for a story * @param {string} storyPath - Path to story file * @param {Object} options - Execution options * @returns {Promise<Object>} Execution result */ async function executeDevelopmentCycle(storyPath, options = {}) { const projectRoot = options.projectRoot || process.cwd(); const executor = new WorkflowExecutor(projectRoot, options); return executor.execute(storyPath, options.epicContext || {}); } module.exports = { WorkflowExecutor, createWorkflowExecutor, executeDevelopmentCycle, PhaseStatus, CheckpointDecision, DEFAULT_TIMEOUT_MS, CHECKPOINT_TIMEOUT_MS, };