cmte
Version:
Design by Committee™ except it's just you and LLMs
625 lines (559 loc) • 28.5 kB
JavaScript
/**
* WorkflowExecutor - Orchestrates the execution of a workflow
*/
import { createEmptyState, updateContext } from '../state/index.js';
import { SetExecutor } from './set-executor.js';
import { OutputReferenceResolver } from '../task/output-reference-resolver.js';
import { FileCollectionManager } from '../file-collection-manager.js';
import { logger } from '../../utils/logger.js';
import { CommitteePaths } from '../../utils/paths.js';
import getClaudeClient from '../llm/claude-adapter.js';
import getLocalLLMClient from '../llm/local-llm-adapter.js';
import path from 'path';
import fs from 'fs/promises'; // Import fs.promises
import { TemplateRenderer } from '../template-renderer.js';
import chalk from 'chalk';
/**
* Options for the WorkflowExecutor
*/
/**
* Executor for workflow components
*/
export class WorkflowExecutor {
constructor(options) {
this.registry = options.registry;
this.workflowPath = options.workflowPath;
// Store the raw outputPath value from the workflow definition if present
// We need to access the parsed workflow object later to check for structured output
// REMOVED: this.rawOutputPath = options.outputPath; // No longer passed in
this.state = options.state || createEmptyState();
this.savePrompts = options.savePrompts || false;
this.dryRun = options.dryRun || false;
this.apiDryRun = options.apiDryRun || false;
this.lite = options.lite || false;
this.useLocalLLM = options.useLocalLLM ?? false;
this.mockTaskExecution = options.mockTaskExecution || false;
this.initialContext = options.initialContext || {};
this.modelConfig = options.modelConfig;
this.dryRunIssues = []; // Add issue collector
// Add array to store task outputs
this.taskOutputs = [];
// Instantiate shared components
this.outputReferenceResolver = new OutputReferenceResolver();
// Ensure workflowPath (which is the FILE path) is resolved, then get its directory for basePath
const resolvedWorkflowFilePath = options.workflowPath ? path.resolve(options.workflowPath) : process.cwd(); // Should be a file path or cwd
const resolvedWorkflowDir = path.dirname(resolvedWorkflowFilePath);
// Use provided instance or create a new one using the DIR path
this.fileCollectionManager = options.fileCollectionManagerInstance || new FileCollectionManager(resolvedWorkflowDir);
logger.info('Initializing WorkflowExecutor', {
workflowPath: options.workflowPath, // Log original path
basePath: resolvedWorkflowDir, // Log resolved base path for files
savePrompts: this.savePrompts,
dryRun: this.dryRun,
apiDryRun: this.apiDryRun,
lite: this.lite,
useLocalLLM: this.useLocalLLM,
modelConfig: this.modelConfig
});
// Initialize the appropriate LLM client
this.llmClient = this.useLocalLLM ? getLocalLLMClient(this.modelConfig) : getClaudeClient(this.modelConfig);
if (this.dryRun) {
logger.info('DRY RUN MODE: No API calls will be made');
}
if (this.apiDryRun) {
logger.info('API DRY RUN MODE: Using compressed prompts and one-sentence responses');
}
if (this.lite) {
logger.info('LITE MODE: Using smaller, inexpensive models');
if (this.useLocalLLM) {
logger.info(`Using local LLM as lite model: ${this.modelConfig?.model || 'default model'}`);
}
}
if (this.savePrompts) {
logger.info('Saving prompts alongside responses');
}
if (this.mockTaskExecution) {
logger.info('Running in mock task execution mode');
}
if (this.useLocalLLM) {
logger.info('LOCAL LLM MODE: Using local LLM server');
}
// Create the renderer HERE, ensuring it uses the executor's file manager
this.renderer = new TemplateRenderer(this.fileCollectionManager, this.outputReferenceResolver, this);
// LLM Client Initialization
if (this.dryRun) {
logger.info('DRY RUN MODE: No API calls will be made');
}
if (this.apiDryRun) {
logger.info('API DRY RUN MODE: Using compressed prompts and one-sentence responses');
}
if (this.lite) {
logger.info('LITE MODE: Using smaller, inexpensive models');
if (this.useLocalLLM) {
logger.info(`Using local LLM as lite model: ${this.modelConfig?.model || 'default model'}`);
}
}
if (this.savePrompts) {
logger.info('Saving prompts alongside responses');
}
if (this.mockTaskExecution) {
logger.info('Running in mock task execution mode');
}
if (this.useLocalLLM) {
logger.info('LOCAL LLM MODE: Using local LLM server');
}
}
/**
* Method called by SetExecutor to record task output.
* Writes output immediately if not in dry run mode.
* ALSO registers the output with the OutputReferenceResolver.
* @param {number} setIndex - The zero-based index of the set in the workflow.
* @param {string} baseSetName - The base name of the set (without iteration key).
* @param {string} taskName - The name of the task.
* @param {string|null} iterationKey - The iteration key, if applicable.
* @param {any} content - The output content of the task.
*/
async recordTaskOutput(setIndex, baseSetName, taskName, iterationKey, content) {
// Still record in memory for potential future use or different output modes?
// For now, let's keep it simple and focus on immediate writing.
// this.taskOutputs.push({ setName, taskName, iterationKey, content });
logger.debug('Task output received', { setIndex, baseSetName, taskName, iterationKey: iterationKey || 'N/A' });
// --- START: Register Output with Resolver ---
// This is crucial for making outputs available via prior_outputs
try {
if (iterationKey !== null) {
this.outputReferenceResolver.registerIteratedOutput(baseSetName, taskName, iterationKey, content);
logger.debug('Registered iterated output with resolver', { baseSetName, taskName, iterationKey });
} else {
this.outputReferenceResolver.registerOutput(baseSetName, taskName, content);
logger.debug('Registered non-iterated output with resolver', { baseSetName, taskName });
}
} catch (error) {
logger.error(`Failed to register output with OutputReferenceResolver for task ${taskName} in set ${baseSetName}`, { error: error.message });
// Decide if this should halt execution. For now, log and continue.
}
// --- END: Register Output with Resolver ---
if (this.dryRun) {
logger.debug('Dry run: Skipping immediate output file writing.');
return;
}
// --- Immediate File Writing Logic ---
try {
const workflowDir = path.dirname(this.workflowPath);
// Determine base output directory using the path from the loaded workflow config
const definedOutputPath = this.workflow.outputPath;
const outputBaseDir = definedOutputPath
? path.resolve(workflowDir, definedOutputPath)
: path.resolve(workflowDir, 'workflow-output'); // Default directory name
// Format the set index with padding (e.g., 01, 02, ... 10, 11)
const paddedSetIndex = String(setIndex + 1).padStart(2, '0');
// Use baseSetName for the filename structure
let fileName = `${paddedSetIndex}-${baseSetName}.${taskName}`;
if (iterationKey) {
// Sanitize iterationKey slightly for filename safety (replace slashes, etc.)
const safeIterKey = String(iterationKey).replace(/[/\\?%*:|\"<>]/g, '-');
fileName += `[${safeIterKey}]`;
}
fileName += '.md'; // Assuming markdown output for now
const filePath = path.join(outputBaseDir, fileName);
logger.debug(`Preparing to write task output immediately to: ${filePath}`);
await fs.mkdir(path.dirname(filePath), { recursive: true }); // Ensure directory exists
await fs.writeFile(filePath, content);
logger.info(`Wrote task output to: ${filePath}`);
// --- Refined User-Facing Log (Action First) ---
const workflowPrefix = this.isMultiRun ? chalk.dim(` ${path.basename(path.dirname(this.workflowPath))} :`) : '';
const setPrefix = chalk.dim(` ${baseSetName} :`);
const iterStr = iterationKey ? chalk.dim(` [${iterationKey}]`) : '';
// Updated Format: WRITE <workflow> : <set> : <task> [<iter>]
console.log(chalk.green.bold(`WRITE`) + workflowPrefix + setPrefix + chalk.green(` ${taskName}`) + iterStr);
// --- END User-Facing Log ---
} catch (error) {
logger.error(`Failed to write immediate output file for task ${taskName} in set ${baseSetName}`, { error: error.message });
// Decide whether to throw or just log based on desired behavior
// For now, let's log and continue workflow execution
}
// --- End Immediate File Writing Logic ---
}
/**
* Get whether prompts should be saved
*/
shouldSavePrompts() {
return this.savePrompts;
}
/**
* Get whether this is a dry run
*/
isDryRun() {
return this.dryRun;
}
/**
* Get whether this is an API dry run
*/
isApiDryRun() {
return this.apiDryRun;
}
/**
* Get whether to use lite (small) models
*/
useSmallModels() {
return this.lite;
}
/**
* Execute a workflow
* @param workflow The workflow to execute
* @param resuming Whether this execution is resuming after human input
* @returns True if the workflow completed, false if it was interrupted for human input
*/
async executeWorkflow(workflow, resuming = false) {
// Store the loaded workflow config
this.workflow = workflow;
this.dryRunIssues = []; // Clear issues at the start of each workflow run
logger.info(`Executing workflow: ${workflow.name}`, {
workflowPath: this.workflowPath,
dryRun: this.dryRun,
savePrompts: this.savePrompts,
apiDryRun: this.apiDryRun,
lite: this.lite,
outputPathConfig: this.workflow.outputPath // Use the stored raw value for logging
});
// Clear outputs from previous runs (if any)
this.taskOutputs = [];
// Clear resolver at the start, but DO NOT clear collections here
// Collections might be pre-registered (e.g., in tests or future scenarios)
this.outputReferenceResolver.clear();
// Initialize context with global_variables and initialContext
// Merge initialContext FIRST, so globals can override if needed?
// Or globals first, so initialContext can override? Let's assume globals are defaults.
let context = {
...(workflow.global_variables || {}), // Use new key
...this.initialContext,
workflow: {
name: workflow.name,
path: this.workflowPath
}
};
// Register initial file groups defined in the workflow
if (workflow.files && typeof workflow.files === 'object') {
logger.info(`Registering file groups defined in workflow.files...`);
for (const [name, value] of Object.entries(workflow.files)) {
let definition = {};
if (typeof value === 'string') {
// Treat string value as a single include pattern
definition = { include: [value] };
} else if (Array.isArray(value)) {
// Treat array value as an explicit list of includes
definition = { include: value };
} else if (typeof value === 'object' && value !== null) {
// Treat object value as having include/exclude keys
definition = {
include: value.include || [],
exclude: value.exclude || []
};
} else {
logger.warn(`Invalid definition type for file group '${name}'. Skipping.`);
continue; // Skip this entry
}
try {
await this.fileCollectionManager.registerCollection(name, definition);
} catch (error) {
logger.error(`Failed to register file collection '${name}'.`, { error: error.message, definition });
// Optionally re-throw or continue based on desired strictness
throw new Error(`Workflow setup failed: Could not register file collection '${name}'. ${error.message}`);
}
}
logger.info('Finished registering file groups.');
} else if (workflow.collections) {
// Add a warning if the old 'collections' key is used
logger.warn('The `collections:` key in workflow definitions is deprecated. Please use `files:` instead.');
// Optional: Add logic here to handle the old format for backward compatibility if desired
}
// --- START: Interpolate global_variables and iterable_objects with file content ---
logger.info('Interpolating workflow definitions with file content...');
try {
// Interpolate global variables using the initial context
// Assign the result back to ensure context holds interpolated values
context = await this._interpolateObject(context, context); // Pass context as BOTH obj and context for interpolation
// Interpolate iterables separately using the now interpolated context
if (workflow.iterable_objects) {
workflow.iterable_objects = await this._interpolateObject(workflow.iterable_objects, context);
}
} catch (error) {
logger.error('Failed during variable interpolation with file content.', { error: error.message });
throw error; // Re-throw to stop execution
}
logger.info('Finished interpolating workflow definitions.');
// --- END: Interpolate variables with file content ---
logger.debug('Initial context AFTER interpolation', {
contextKeys: Object.keys(context),
// Note: Logging full context might be too verbose if files are large
globalVariableKeys: workflow.global_variables ? Object.keys(workflow.global_variables) : [],
iterableObjectKeys: workflow.iterable_objects ? Object.keys(workflow.iterable_objects) : []
});
// Validate that all required input is available
if (workflow.requiredInput) {
this.validateRequiredInput(workflow.requiredInput, context);
}
// Execute each set defined in the workflow in sequence
if (!workflow.sets || !Array.isArray(workflow.sets)) {
logger.warn(`Workflow ${workflow.name} has no sets defined. Execution will stop.`);
// If no sets, still need to handle potential structured output based on initial context
if (workflow.output && typeof workflow.output === 'object' && workflow.output.filePath && workflow.output.content) {
await this.handleStructuredOutput(workflow, context);
}
return {
status: 'success',
finalContext: this.state.context, // Or whatever final state is relevant
dryRunIssues: this.dryRunIssues
};
}
logger.info(`Executing ${workflow.sets.length} sets sequentially...`);
// --- Main Set Execution Loop ---
for (const [setIndex, setRef] of workflow.sets.entries()) {
const setId = setRef.useSet;
if (!setId) {
logger.error('Invalid set reference in workflow.sets - missing useSet property.', { setRef });
throw new Error('Invalid set reference: missing useSet');
}
logger.info(`Starting set: ${setId}`);
const setDefinition = await this.registry.loadSet(setId);
const setName = setDefinition.name || setId;
// --- Log Set Start (Action First) ---
const workflowPrefix = this.isMultiRun ? chalk.dim(` ${path.basename(path.dirname(this.workflowPath))} :`) : '';
console.log(chalk.yellow.bold(`STARTING SET`) + workflowPrefix + chalk.yellow(` ${setName}`));
// --- End Log Set Start ---
// Determine if iterating
const forEachTarget = setDefinition.for_each;
let itemsToIterateOver = null;
let isFileIteration = false; // Track if iterating over files specifically
if (forEachTarget) {
logger.info(`Processing for_each target: ${forEachTarget} for set ${setName}`);
itemsToIterateOver = this.resolveForEachTarget(forEachTarget, workflow.iterable_objects, context);
if (itemsToIterateOver === null) { // Target not found or empty
logger.warn(`for_each target '${forEachTarget}' not found in iterable_objects or context, or is empty. Skipping set ${setName}.`);
continue; // Skip this set
}
logger.info(`Iterating over ${itemsToIterateOver.length} items for set ${setName}`);
}
// Instantiate SetExecutor - PASS this WorkflowExecutor instance AND setIndex
const setExecutor = new SetExecutor(setDefinition, this.registry, this, context, setIndex, {
// Removed redundant options, they are accessed via 'this' (WorkflowExecutor instance)
// fileCollectionManager: this.fileCollectionManager,
// outputReferenceResolver: this.outputReferenceResolver,
savePrompts: this.savePrompts,
dryRun: this.dryRun,
apiDryRun: this.apiDryRun,
lite: this.lite,
useLocalLLM: this.useLocalLLM,
llmClient: this.llmClient, // Pass the shared client
mockTaskExecution: this.mockTaskExecution,
modelConfig: this.modelConfig
});
// Execute the set (either once or iterating)
let finalSetContext;
let setError = null;
try {
if (itemsToIterateOver) {
finalSetContext = await setExecutor.executeForEach(itemsToIterateOver);
} else {
finalSetContext = await setExecutor.executeOnce(context);
}
// Update the main workflow context ONLY on success
context = updateContext(context, finalSetContext.outputs);
} catch (error) {
setError = error; // Capture error to log failure
logger.error(`Set execution failed: ${setName}`, { error: error.message });
// IMPORTANT: Re-throw the error to stop the *current workflow's* execution
// The multi-workflow runner in cmte.js will catch this via Promise.allSettled
throw error;
} finally {
// --- REMOVE Set End Logs ---
// if (setError) {
// console.log(chalk.red(`END`) + workflowPrefix + chalk.red(` Set: ${setName} ❌ (${setError.message})`));
// } else {
// console.log(chalk.green(`END`) + workflowPrefix + chalk.green(` Set: ${setName} ✔️`));
// }
// --- END Set End Logs ---
logger.info(`Completed processing set: ${setName} (Index: ${setIndex})`); // Keep internal info log
}
} // --- End Set Execution Loop ---
logger.info(`Workflow ${workflow.name} finished executing all sets.`);
// --- START: Output Handling & Dry Run Reporting ---
if (this.dryRun) {
// In dry run, check if structured output would have occurred and log the intended path
if (workflow.output && typeof workflow.output === 'object' && workflow.output.filePath) {
const workflowDir = path.dirname(this.workflowPath);
const dryRunOutputPath = path.resolve(workflowDir, 'dryrun', path.basename(workflow.output.filePath));
logger.info(`[DRY RUN] Would write structured output to: ${dryRunOutputPath}`);
} else {
logger.info('Dry run complete. No structured output defined or no output file path specified.');
}
logger.info('Dry run complete. No actual output files were written.');
} else if (workflow.output && typeof workflow.output === 'object' && workflow.output.filePath && workflow.output.content) {
// Case 1: Explicit structured output is defined - handle only that when NOT in dry run.
logger.info('Handling explicit structured output defined in workflow...');
await this.handleStructuredOutput(workflow, context);
}
// REMOVED: Automatic task output writing at the end is handled by recordTaskOutput now.
// --- END: Output Handling & Dry Run Reporting ---
logger.info(`Workflow ${workflow.name} completed successfully.`);
// Log collected issues before returning
logger.debug(`[WORKFLOW_END] Dry run issues collected for ${workflow.name}:`, this.dryRunIssues);
return {
status: 'success',
finalContext: this.state.context, // Or whatever final state is relevant
dryRunIssues: this.dryRunIssues
};
} // --- End executeWorkflow ---
/**
* Handles writing explicitly defined structured output.
* @param {object} workflow - The parsed workflow definition object.
* @param {object} finalContext - The final context after all sets.
*/
async handleStructuredOutput(workflow, finalContext) {
try {
const workflowDir = path.dirname(this.workflowPath);
const filePath = path.resolve(workflowDir, workflow.output.filePath);
const contentVariable = workflow.output.content;
// Resolve the content using the final context
// We might need a simple resolver here, assuming direct variable access for now
const resolvedContent = this.outputReferenceResolver.resolveVariable(contentVariable, finalContext);
if (resolvedContent === undefined || resolvedContent === null) {
logger.warn(`Structured output content variable '${contentVariable}' resolved to undefined/null. Writing empty file to ${filePath}.`);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '');
} else {
const contentToWrite = typeof resolvedContent === 'object' ? JSON.stringify(resolvedContent, null, 2) : String(resolvedContent);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contentToWrite);
logger.info(`Successfully wrote structured output to: ${filePath}`);
}
} catch (error) {
logger.error(`Failed to write structured output file: ${workflow.output.filePath}`, { error: error.message });
// Decide whether to throw or just log based on desired behavior
throw new Error(`Failed to write structured output: ${error.message}`);
}
}
validateRequiredInput(requiredInput, context) {
const missing = requiredInput.filter(key => !(key in context));
if (missing.length > 0) {
throw new Error(`Workflow execution failed: Missing required input - ${missing.join(', ')}`);
}
}
validateRequiredOutput(requiredOutput) {
if (!requiredOutput || requiredOutput.length === 0) {
return; // Nothing to validate
}
logger.debug('Validating required workflow outputs...', { required: requiredOutput });
const missingOutputs = [];
for (const reference of requiredOutput) {
try {
// Attempt to resolve the reference. We don't need the value, just check if it throws.
this.outputReferenceResolver.resolveReference(reference);
logger.debug(`Required output validated successfully: ${reference}`);
} catch (error) {
// If resolveReference throws, the output is considered missing
logger.warn(`Required output validation failed for: ${reference}`, { error: error.message });
missingOutputs.push(reference);
}
}
if (missingOutputs.length > 0) {
throw new Error(`Workflow execution failed: Missing required output references - ${missingOutputs.join(', ')}`);
}
logger.info('All required workflow outputs validated successfully.');
}
/**
* Interpolates strings within an object that look like {{ files.* }} or other variables.
* MODIFIED: Now accepts the renderer instance and context.
* @param {object} obj - The object to interpolate in place.
* @param {TemplateRenderer} renderer - The renderer instance to use.
* @param {object} renderContext - The context for rendering variables.
*/
async _interpolateObject(obj, context) {
if (!obj || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return Promise.all(obj.map(item => this._interpolateObject(item, context))); // Pass context down
}
const newObj = {};
for (const key in obj) {
if (Object.hasOwnProperty.call(obj, key)) {
const value = obj[key];
if (typeof value === 'string') {
if (value.trim().startsWith('{{files.')) {
const collectionNameMatch = value.match(/\{\{\s*files\.([\w-]+)/);
if (collectionNameMatch && collectionNameMatch[1]) {
const collectionName = collectionNameMatch[1];
if (!this.fileCollectionManager.hasCollection(collectionName)) {
logger.warn(`_interpolateObject: Referenced file collection '${collectionName}' not found in key '${key}'. Replacing with empty string.`);
newObj[key] = '';
continue;
}
}
}
// Pass context to render, using the executor's renderer instance
newObj[key] = await this.renderer.render(value, context, false);
} else if (typeof value === 'object') {
newObj[key] = await this._interpolateObject(value, context); // Pass context down
} else {
newObj[key] = value;
}
}
}
return newObj;
}
/**
* Resolves the target for a for_each directive.
* @param {string} target - The target string from for_each.
* @param {object} iterableObjects - The iterable_objects section from the workflow.
* @param {object} context - The current workflow context.
* @returns {Array|null} An array of items to iterate over, or null if not found/empty.
*/
resolveForEachTarget(target, iterableObjects, context) {
let items = null;
// Priority 1: Check iterable_objects defined in the workflow
if (iterableObjects && typeof iterableObjects === 'object' && target in iterableObjects) {
items = iterableObjects[target];
logger.debug(`Resolved for_each target '${target}' from workflow iterable_objects.`);
}
// Priority 2: Check the main context (could be from initial context or previous sets)
else if (context && typeof context === 'object' && target in context) {
items = context[target];
logger.debug(`Resolved for_each target '${target}' from workflow context.`);
}
// Validate and format the items
if (items === null || items === undefined) {
logger.warn(`For_each target '${target}' resolved to null or undefined.`);
return null;
}
if (Array.isArray(items)) {
// If it's already an array, use it directly
logger.info(`Iterating over array target '${target}' with ${items.length} items.`);
return items.length > 0 ? items : null;
} else if (typeof items === 'object' && items !== null) {
// If it's an object, convert to key-value pairs [{ key: 'key1', value: {..} }, ...]
logger.info(`Iterating over object target '${target}' (converted to key-value pairs).`);
const pairs = Object.entries(items).map(([key, value]) => ({ key, value }));
return pairs.length > 0 ? pairs : null;
} else {
logger.warn(`For_each target '${target}' resolved to an unexpected type: ${typeof items}. Cannot iterate.`);
return null;
}
}
/**
* Adds an issue to the collection if currently in dry run mode.
* @param {'warning' | 'error'} type - The type of issue.
* @param {string} message - The issue description.
*/
addDryRunIssue(type, message) {
if (this.dryRun) {
// Add check and log
if (!this.dryRunIssues) {
logger.error('[addDryRunIssue] Attempted to add issue but this.dryRunIssues is undefined!', { type, message });
this.dryRunIssues = []; // Initialize if missing
}
logger.debug(`[DRY_RUN_ISSUE_ADD] Adding issue - Type: ${type}, Message: ${message.substring(0, 100)}...`);
this.dryRunIssues.push({ type, message });
}
}
}