UNPKG

cmte

Version:

Design by Committee™ except it's just you and LLMs

625 lines (559 loc) 28.5 kB
/** * 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 }); } } }