541 lines
16 KiB
JavaScript
541 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Workflow Executor - Main Orchestration Engine
|
|
*
|
|
* Reads workflow YAML files and executes them with full support for:
|
|
* - Sequential and parallel execution
|
|
* - Dependency management
|
|
* - Error recovery and retry logic
|
|
* - Quality gates and validation
|
|
* - Context management
|
|
* - Execution tracing
|
|
*
|
|
* This is the MAIN entry point for executing BMAD workflows.
|
|
*
|
|
* Usage:
|
|
* node workflow-executor.mjs --workflow <workflow.yaml> --input <user-spec.md>
|
|
*
|
|
* @version 2.0.0
|
|
* @date 2025-11-13
|
|
*/
|
|
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import yaml from 'js-yaml';
|
|
import { createContextBus } from '../context/context-bus.mjs';
|
|
import { executeStep } from './execute-step.mjs';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
|
|
|
|
// ============================================================================
|
|
// Configuration
|
|
// ============================================================================
|
|
|
|
const CONFIG = {
|
|
PATHS: {
|
|
WORKFLOWS: path.join(PROJECT_ROOT, '.claude/workflows'),
|
|
AGENTS: path.join(PROJECT_ROOT, '.claude/agents'),
|
|
CONTEXT: path.join(PROJECT_ROOT, '.claude/context'),
|
|
SCHEMAS: path.join(PROJECT_ROOT, '.claude/schemas'),
|
|
TRACES: path.join(PROJECT_ROOT, '.claude/context/history/traces')
|
|
},
|
|
RETRY: {
|
|
MAX_ATTEMPTS: 2,
|
|
BACKOFF_MS: 2000
|
|
},
|
|
TIMEOUT: {
|
|
STEP_DEFAULT: 600000, // 10 minutes
|
|
PARALLEL_GROUP: 900000 // 15 minutes
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Workflow Executor Class
|
|
// ============================================================================
|
|
|
|
class WorkflowExecutor {
|
|
constructor(workflowPath, options = {}) {
|
|
this.workflowPath = workflowPath;
|
|
this.options = options;
|
|
this.workflow = null;
|
|
this.contextBus = null;
|
|
this.sessionId = null;
|
|
this.executionTrace = [];
|
|
this.startTime = null;
|
|
this.status = 'pending';
|
|
}
|
|
|
|
/**
|
|
* Initialize the workflow executor
|
|
*/
|
|
async initialize() {
|
|
console.log('\n' + '='.repeat(80));
|
|
console.log('BMAD-SPEC-KIT Workflow Executor v2.0');
|
|
console.log('='.repeat(80));
|
|
|
|
// Load workflow definition
|
|
await this.loadWorkflow();
|
|
|
|
// Initialize session
|
|
await this.initializeSession();
|
|
|
|
// Initialize context bus
|
|
await this.initializeContext();
|
|
|
|
console.log(`\n✓ Workflow initialized: ${this.workflow.workflow.name}`);
|
|
console.log(`✓ Session ID: ${this.sessionId}`);
|
|
console.log(`✓ Execution mode: ${this.workflow.execution_strategy?.execution_mode || 'sequential'}\n`);
|
|
}
|
|
|
|
/**
|
|
* Load workflow YAML file
|
|
*/
|
|
async loadWorkflow() {
|
|
try {
|
|
const content = await fs.readFile(this.workflowPath, 'utf-8');
|
|
this.workflow = yaml.load(content);
|
|
|
|
// Validate workflow structure
|
|
if (!this.workflow.workflow || !this.workflow.workflow.name) {
|
|
throw new Error('Invalid workflow: missing workflow.name');
|
|
}
|
|
|
|
// Support both 'sequence' (v1) and 'parallel_groups' (v2) formats
|
|
if (!this.workflow.sequence && !this.workflow.parallel_groups) {
|
|
throw new Error('Invalid workflow: missing sequence or parallel_groups');
|
|
}
|
|
|
|
} catch (error) {
|
|
throw new Error(`Failed to load workflow: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize session
|
|
*/
|
|
async initializeSession() {
|
|
this.sessionId = `bmad-session-${Date.now()}-${Math.random().toString(36).substr(2, 8)}`;
|
|
this.startTime = new Date();
|
|
|
|
// Create session directory
|
|
const sessionDir = path.join(CONFIG.PATHS.CONTEXT, 'sessions', this.sessionId);
|
|
await fs.mkdir(sessionDir, { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* Initialize context bus
|
|
*/
|
|
async initializeContext() {
|
|
const contextSchemaPath = path.join(CONFIG.PATHS.SCHEMAS, 'context_state.schema.json');
|
|
this.contextBus = await createContextBus(contextSchemaPath);
|
|
|
|
// Initialize context structure
|
|
this.contextBus.set('session_id', this.sessionId);
|
|
this.contextBus.set('project_metadata', {
|
|
name: this.options.projectName || 'Unnamed Project',
|
|
workflow_type: this.workflow.workflow.name,
|
|
workflow_version: this.workflow.metadata?.version || '1.0.0',
|
|
created_at: this.startTime.toISOString(),
|
|
estimated_duration: this.workflow.metadata?.estimated_duration || 'unknown'
|
|
});
|
|
|
|
this.contextBus.set('workflow_state', {
|
|
current_step: 0,
|
|
completed_steps: [],
|
|
failed_steps: [],
|
|
skipped_steps: [],
|
|
quality_gates_passed: [],
|
|
quality_gates_failed: [],
|
|
overall_quality_score: 0,
|
|
execution_mode: this.workflow.execution_strategy?.execution_mode || 'sequential',
|
|
paused: false
|
|
});
|
|
|
|
this.contextBus.set('agent_contexts', {});
|
|
this.contextBus.set('global_context', this.options.globalContext || {});
|
|
this.contextBus.set('artifacts', {
|
|
generated: [],
|
|
schemas_used: [],
|
|
context_files: []
|
|
});
|
|
this.contextBus.set('feedback_loops', []);
|
|
this.contextBus.set('checkpoints', []);
|
|
}
|
|
|
|
/**
|
|
* Execute the workflow
|
|
*/
|
|
async execute() {
|
|
try {
|
|
this.status = 'running';
|
|
console.log(`\n${'='.repeat(80)}`);
|
|
console.log(`Starting workflow execution: ${this.workflow.workflow.name}`);
|
|
console.log(`${'='.repeat(80)}\n`);
|
|
|
|
// Determine execution path (v1 sequence or v2 parallel groups)
|
|
if (this.workflow.parallel_groups) {
|
|
await this.executeParallelGroups();
|
|
} else {
|
|
await this.executeSequential();
|
|
}
|
|
|
|
this.status = 'completed';
|
|
await this.finalize();
|
|
|
|
console.log(`\n${'='.repeat(80)}`);
|
|
console.log(`✓ Workflow completed successfully`);
|
|
console.log(`${'='.repeat(80)}\n`);
|
|
|
|
return {
|
|
success: true,
|
|
sessionId: this.sessionId,
|
|
duration: Date.now() - this.startTime.getTime(),
|
|
trace: this.executionTrace
|
|
};
|
|
|
|
} catch (error) {
|
|
this.status = 'failed';
|
|
await this.handleWorkflowFailure(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute workflow in parallel groups (v2)
|
|
*/
|
|
async executeParallelGroups() {
|
|
for (const group of this.workflow.parallel_groups) {
|
|
console.log(`\n--- Group: ${group.group_name || group.group_id} ---`);
|
|
|
|
if (group.parallel) {
|
|
// Execute agents in this group concurrently
|
|
await this.executeParallelGroup(group);
|
|
} else {
|
|
// Execute agents sequentially
|
|
for (const stepConfig of group.agents) {
|
|
await this.executeAgentStep(stepConfig);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a parallel group
|
|
*/
|
|
async executeParallelGroup(group) {
|
|
console.log(`\n⚡ Parallel execution enabled for ${group.agents.length} agents`);
|
|
|
|
const startTime = Date.now();
|
|
const promises = group.agents.map(stepConfig =>
|
|
this.executeAgentStep(stepConfig).catch(error => ({
|
|
error,
|
|
stepConfig
|
|
}))
|
|
);
|
|
|
|
// Wait for all with timeout
|
|
const timeout = group.synchronization?.timeout || CONFIG.TIMEOUT.PARALLEL_GROUP;
|
|
const results = await Promise.race([
|
|
Promise.allSettled(promises),
|
|
this.timeout(timeout, 'Parallel group timeout')
|
|
]);
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Check results
|
|
const failures = results.filter(r => r.status === 'rejected' || r.value?.error);
|
|
const successes = results.filter(r => r.status === 'fulfilled' && !r.value?.error);
|
|
|
|
console.log(`\n✓ Parallel group completed in ${duration}ms`);
|
|
console.log(` Successes: ${successes.length}/${group.agents.length}`);
|
|
if (failures.length > 0) {
|
|
console.log(` Failures: ${failures.length}`);
|
|
}
|
|
|
|
// Handle partial completion
|
|
const partialOk = group.synchronization?.partial_completion === 'allow_with_one_success';
|
|
if (failures.length > 0 && !(partialOk && successes.length > 0)) {
|
|
throw new Error(`Parallel group failed: ${failures.length} agent(s) failed`);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Execute workflow sequentially (v1 compatibility)
|
|
*/
|
|
async executeSequential() {
|
|
for (const stepConfig of this.workflow.sequence) {
|
|
await this.executeAgentStep(stepConfig);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a single agent step
|
|
*/
|
|
async executeAgentStep(stepConfig) {
|
|
const { step, agent, optional } = stepConfig;
|
|
|
|
console.log(`\n[Step ${step}] ${agent}`);
|
|
console.log(`Description: ${stepConfig.description}`);
|
|
|
|
// Check dependencies
|
|
if (stepConfig.depends_on) {
|
|
const ready = this.checkDependencies(stepConfig.depends_on);
|
|
if (!ready) {
|
|
if (optional) {
|
|
console.log(`⊘ Skipping optional step (dependencies not met)`);
|
|
this.contextBus.push('workflow_state.skipped_steps', step);
|
|
return;
|
|
}
|
|
throw new Error(`Dependencies not met for step ${step}: ${stepConfig.depends_on}`);
|
|
}
|
|
}
|
|
|
|
// Update workflow state
|
|
this.contextBus.set('workflow_state.current_step', step);
|
|
|
|
try {
|
|
// Execute agent (this is where we'd spawn with Task tool in production)
|
|
const agentOutput = await this.executeAgent(stepConfig);
|
|
|
|
// Validate, render, and update context using unified API
|
|
if (agentOutput) {
|
|
await executeStep(stepConfig, agentOutput, this.contextBus.context);
|
|
}
|
|
|
|
// Mark step as completed
|
|
this.contextBus.push('workflow_state.completed_steps', step);
|
|
|
|
console.log(`✓ Step ${step} completed`);
|
|
|
|
} catch (error) {
|
|
console.error(`✗ Step ${step} failed: ${error.message}`);
|
|
|
|
// Handle failure
|
|
const shouldRetry = stepConfig.execution?.retry_on_failure &&
|
|
error.retryCount < (CONFIG.RETRY.MAX_ATTEMPTS || 1);
|
|
|
|
if (shouldRetry) {
|
|
console.log(` Retrying step ${step}...`);
|
|
error.retryCount = (error.retryCount || 0) + 1;
|
|
await this.sleep(CONFIG.RETRY.BACKOFF_MS * error.retryCount);
|
|
return this.executeAgentStep(stepConfig);
|
|
}
|
|
|
|
// Record failure
|
|
this.contextBus.push('workflow_state.failed_steps', {
|
|
step_id: step,
|
|
agent: agent,
|
|
error: error.message,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
if (!optional) {
|
|
throw error;
|
|
} else {
|
|
console.log(`⊘ Skipping optional step due to failure`);
|
|
this.contextBus.push('workflow_state.skipped_steps', step);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute agent (placeholder - in production this would use Task tool)
|
|
*/
|
|
async executeAgent(stepConfig) {
|
|
// For now, this is a placeholder that loads agent prompts
|
|
// In production, this would spawn agents using the Task tool
|
|
|
|
console.log(` Loading agent: ${stepConfig.agent}`);
|
|
|
|
// Load agent prompt
|
|
const agentPath = path.join(CONFIG.PATHS.AGENTS, stepConfig.agent, 'prompt.md');
|
|
|
|
try {
|
|
await fs.access(agentPath);
|
|
console.log(` Agent prompt loaded: ${agentPath}`);
|
|
} catch (error) {
|
|
console.log(` ⚠ Agent prompt not found: ${agentPath}`);
|
|
}
|
|
|
|
// In production implementation, this would:
|
|
// 1. Load agent prompt
|
|
// 2. Prepare context from contextBus
|
|
// 3. Spawn agent using Task tool
|
|
// 4. Wait for agent completion
|
|
// 5. Return agent output
|
|
|
|
// For now, return a placeholder indicating the agent would be executed
|
|
return {
|
|
_placeholder: true,
|
|
agent: stepConfig.agent,
|
|
step: stepConfig.step,
|
|
note: 'Agent execution requires Task tool integration - see task-tool-integration.mjs'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if dependencies are met
|
|
*/
|
|
checkDependencies(dependencies) {
|
|
const completed = this.contextBus.get('workflow_state.completed_steps') || [];
|
|
return dependencies.every(dep => completed.includes(dep));
|
|
}
|
|
|
|
/**
|
|
* Finalize workflow execution
|
|
*/
|
|
async finalize() {
|
|
const endTime = new Date();
|
|
const duration = endTime.getTime() - this.startTime.getTime();
|
|
|
|
// Save final context
|
|
const sessionPath = path.join(CONFIG.PATHS.CONTEXT, 'sessions', this.sessionId, 'final-context.json');
|
|
await this.contextBus.saveToFile(sessionPath);
|
|
|
|
// Save execution trace
|
|
const tracePath = path.join(CONFIG.PATHS.TRACES, `${this.sessionId}.json`);
|
|
const trace = {
|
|
session_id: this.sessionId,
|
|
workflow_name: this.workflow.workflow.name,
|
|
started_at: this.startTime.toISOString(),
|
|
completed_at: endTime.toISOString(),
|
|
total_duration_ms: duration,
|
|
status: this.status,
|
|
execution_log: this.executionTrace
|
|
};
|
|
await fs.mkdir(path.dirname(tracePath), { recursive: true });
|
|
await fs.writeFile(tracePath, JSON.stringify(trace, null, 2));
|
|
|
|
console.log(`\n📊 Execution Summary:`);
|
|
console.log(` Duration: ${(duration / 1000).toFixed(2)}s`);
|
|
console.log(` Steps completed: ${this.contextBus.get('workflow_state.completed_steps').length}`);
|
|
console.log(` Steps failed: ${this.contextBus.get('workflow_state.failed_steps').length}`);
|
|
console.log(` Steps skipped: ${this.contextBus.get('workflow_state.skipped_steps').length}`);
|
|
console.log(` Session: ${sessionPath}`);
|
|
console.log(` Trace: ${tracePath}`);
|
|
}
|
|
|
|
/**
|
|
* Handle workflow failure
|
|
*/
|
|
async handleWorkflowFailure(error) {
|
|
console.error(`\n❌ Workflow execution failed: ${error.message}`);
|
|
|
|
// Save failure state
|
|
const failurePath = path.join(CONFIG.PATHS.CONTEXT, 'sessions', this.sessionId, 'failure.json');
|
|
await fs.mkdir(path.dirname(failurePath), { recursive: true });
|
|
await fs.writeFile(failurePath, JSON.stringify({
|
|
error: error.message,
|
|
stack: error.stack,
|
|
context: this.contextBus.export(),
|
|
timestamp: new Date().toISOString()
|
|
}, null, 2));
|
|
|
|
console.error(` Failure details saved: ${failurePath}`);
|
|
}
|
|
|
|
/**
|
|
* Utility: Sleep
|
|
*/
|
|
sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Utility: Timeout
|
|
*/
|
|
timeout(ms, message) {
|
|
return new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error(message)), ms)
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLI Entry Point
|
|
// ============================================================================
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
// Parse arguments
|
|
const parseArg = (flag) => {
|
|
const index = args.indexOf(flag);
|
|
return index >= 0 ? args[index + 1] : null;
|
|
};
|
|
|
|
const workflowFile = parseArg('--workflow');
|
|
const inputFile = parseArg('--input');
|
|
const projectName = parseArg('--project') || 'Unnamed Project';
|
|
|
|
if (!workflowFile) {
|
|
console.error(`
|
|
Usage: node workflow-executor.mjs --workflow <workflow.yaml> [options]
|
|
|
|
Options:
|
|
--workflow <file> Path to workflow YAML file (required)
|
|
--input <file> Input specification file
|
|
--project <name> Project name
|
|
--help Show this help
|
|
|
|
Examples:
|
|
node workflow-executor.mjs --workflow .claude/workflows/greenfield-fullstack-v2.yaml
|
|
node workflow-executor.mjs --workflow greenfield-ui.yaml --input user-spec.md
|
|
`);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
// Resolve workflow path
|
|
let workflowPath = workflowFile;
|
|
if (!path.isAbsolute(workflowPath)) {
|
|
// Try relative to workflows directory
|
|
const relPath = path.join(CONFIG.PATHS.WORKFLOWS, workflowFile);
|
|
try {
|
|
await fs.access(relPath);
|
|
workflowPath = relPath;
|
|
} catch {
|
|
// Use as-is
|
|
}
|
|
}
|
|
|
|
// Create executor
|
|
const executor = new WorkflowExecutor(workflowPath, {
|
|
projectName,
|
|
inputFile
|
|
});
|
|
|
|
// Initialize and execute
|
|
await executor.initialize();
|
|
const result = await executor.execute();
|
|
|
|
console.log('\n✓ Execution complete');
|
|
console.log(` Session ID: ${result.sessionId}`);
|
|
console.log(` Duration: ${(result.duration / 1000).toFixed(2)}s\n`);
|
|
|
|
process.exit(0);
|
|
|
|
} catch (error) {
|
|
console.error('\n❌ Fatal error:', error.message);
|
|
if (error.stack) {
|
|
console.error('\nStack trace:');
|
|
console.error(error.stack);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run if called directly
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main();
|
|
}
|
|
|
|
// Export for use as module
|
|
export { WorkflowExecutor };
|