UNPKG

@flowlab/all

Version:

A cool library focusing on handling various flows

427 lines (366 loc) 21.2 kB
import { WorkflowDefinition } from './definition'; import { NodeRegistry } from './nodeRegistry'; import { ILogger, IPersistence, IScheduler, IEventManager } from '../services'; import { IWorkflowContext, INodeContext, NodeStatus, IExecutionRecord } from '../types/runtime'; import { StepConfig, StepType, TaskStepConfig, ConditionStepConfig, SubWorkflowStepConfig } from '../types/config'; import { createWorkflowContext, createNodeContext } from './context'; // Assuming these exist import { WorkflowError, NodeExecutionError, TimeoutError, ConfigurationError, AuthorizationError } from '../errors/index'; import { BaseNode } from '../nodes/baseNode'; import { NodeFunction } from '../types/config'; export interface WorkflowExecutorOptions { nodeRegistry: NodeRegistry; logger: ILogger; persistence?: IPersistence; scheduler?: IScheduler; eventManager?: IEventManager; getWorkflowDefinition: (id: string) => Promise<WorkflowDefinition | undefined>; maxLoopIterations?: number; } export class WorkflowExecutor { private readonly nodeRegistry: NodeRegistry; private readonly logger: ILogger; private readonly persistence?: IPersistence; private readonly scheduler?: IScheduler; private readonly eventManager?: IEventManager; private readonly getWorkflowDefinition: (id: string) => Promise<WorkflowDefinition | undefined>; private readonly maxLoopIterations: number; constructor(options: WorkflowExecutorOptions) { this.nodeRegistry = options.nodeRegistry; this.logger = options.logger; this.persistence = options.persistence; this.scheduler = options.scheduler; this.eventManager = options.eventManager; this.getWorkflowDefinition = options.getWorkflowDefinition; this.maxLoopIterations = options.maxLoopIterations ?? 1000; } async run( definitionOrId: WorkflowDefinition | string, input: Record<string, any>, contextExtras: Partial<IWorkflowContext> = {} ): Promise<IWorkflowContext> { const definition = typeof definitionOrId === 'string' ? await this.getWorkflowDefinition(definitionOrId) : definitionOrId; if (!definition) { throw new ConfigurationError(`Workflow definition '${definitionOrId}' not found.`); } if (!definition.startStepId) { throw new ConfigurationError(`Workflow definition '${definition.id}' has no start step defined.`); } if (!definition.validate()) { // Added validation call throw new ConfigurationError(`Workflow definition '${definition.id}' failed validation.`); } // Initialize context const context = createWorkflowContext(definition.id, definition.name, input, contextExtras); this.logger.info(`Starting workflow run: ${context.workflowId} (Def: ${definition.id})`); await this.emitEvent('workflow.started', context); // Emit event let currentStepId: string | undefined = definition.startStepId; let loopGuard = 0; try { while (currentStepId && loopGuard++ < this.maxLoopIterations) { const stepConfig = definition.getStep(currentStepId); if (!stepConfig) { throw new WorkflowError(`Step '${currentStepId}' not found in workflow '${definition.id}'.`, context.workflowId); } context.logs.push(`Executing step: ${currentStepId} (Type: ${stepConfig.type})`); const stepResult = await this.executeStep(stepConfig, context); // Update history and persistence this.updateHistory(context, stepResult.record); if (this.persistence) await this.persistence.saveState(context); if (stepResult.status === NodeStatus.FAILED || stepResult.status === NodeStatus.CANCELLED) { context.status = stepResult.status; context.output = stepResult.output; // Capture last output on failure this.logger.error(`Workflow run ${context.workflowId} failed at step ${currentStepId}.`, stepResult.error); await this.emitEvent('workflow.failed', context, { stepId: currentStepId, error: stepResult.error?.message }); break; // Stop execution } if (stepResult.status === NodeStatus.COMPENSATING) { // TODO: Implement compensation flow triggering this.logger.warn(`Compensation requested at step ${currentStepId}, stopping normal flow.`); context.status = NodeStatus.FAILED; // Mark workflow as failed if compensation needed? Or specific status? break; } currentStepId = stepResult.nextStepId; // Determine next step if (!currentStepId) { context.status = NodeStatus.COMPLETED; context.output = stepResult.output; // Capture final output this.logger.info(`Workflow run ${context.workflowId} completed successfully.`); await this.emitEvent('workflow.completed', context); break; // Workflow finished } } // End while loop if (loopGuard >= this.maxLoopIterations) { throw new WorkflowError(`Maximum loop iterations (${this.maxLoopIterations}) reached. Potential infinite loop detected.`, context.workflowId); } } catch (error: any) { context.status = NodeStatus.FAILED; context.error = error; // Store workflow-level error this.logger.error(`Workflow run ${context.workflowId} encountered an unhandled error.`, error); await this.emitEvent('workflow.failed', context, { error: error.message }); // No re-throw, return context with FAILED status } finally { context.endTime = new Date(); if (this.persistence) { try { await this.persistence.saveState(context); // Final state save } catch (saveError) { this.logger.error(`Failed to save final workflow state for ${context.workflowId}`, saveError as Error); } } } return context; } // --- Step Execution Logic (Needs detailed implementation) --- private async executeStep(stepConfig: StepConfig, workflowContext: IWorkflowContext): Promise<{ status: NodeStatus; nextStepId?: string; output?: any; error?: Error; record: IExecutionRecord }> { const stepStartTime = new Date(); let stepStatus = NodeStatus.PENDING; let stepOutput: any = undefined; let stepError: Error | undefined = undefined; let nextStepId: string | undefined = stepConfig.nextStepId; // Default next step const record: Partial<IExecutionRecord> = { stepId: stepConfig.id, startTime: stepStartTime, status: NodeStatus.PENDING, }; try { await this.emitEvent('step.started', workflowContext, { stepId: stepConfig.id, type: stepConfig.type }); stepStatus = NodeStatus.RUNNING; record.status = stepStatus; // Update record status switch (stepConfig.type) { case StepType.TASK: const taskResult = await this.executeTaskStep(stepConfig, workflowContext); stepStatus = taskResult.status; stepOutput = taskResult.output; stepError = taskResult.error; record.nodeId = stepConfig.nodeId; // Use default nextStepId unless overridden break; case StepType.CONDITION: const conditionResult = await this.executeConditionStep(stepConfig, workflowContext); stepStatus = NodeStatus.COMPLETED; // Condition itself completes nextStepId = conditionResult.nextStepId; // Condition determines the *actual* next step record.output = { branchTaken: conditionResult.branchKey }; // Record which branch was taken if (!nextStepId) { // No matching branch, potentially end workflow if this was the last path this.logger.warn(`Condition step '${stepConfig.id}' resulted in no matching branch ('${conditionResult.branchKey}'). Ending this path.`); } break; case StepType.PARALLEL: // TODO: Implement parallel execution logic // This involves running multiple steps concurrently and waiting for all. // Needs careful state management and error handling for partial failures. this.logger.warn(`Parallel step execution not fully implemented yet for step: ${stepConfig.id}`); stepStatus = NodeStatus.SKIPPED; // Mark as skipped for now break; case StepType.SUB_WORKFLOW: // TODO: Implement sub-workflow execution // Needs to call executor.run() recursively with the sub-workflow def/id // and handle input/output mapping. this.logger.warn(`SubWorkflow step execution not fully implemented yet for step: ${stepConfig.id}`); stepStatus = NodeStatus.SKIPPED; // Mark as skipped for now break; // TODO: Implement EVENT_TRIGGER, EVENT_LISTENER default: throw new ConfigurationError(`Unsupported step type: ${(stepConfig as any).type}`); } if (stepStatus === NodeStatus.FAILED && stepConfig.compensateOnFailure) { // TODO: Trigger compensation logic this.logger.warn(`Compensation requested for failed step '${stepConfig.id}'. Compensation logic not fully implemented.`); // Potentially change status to COMPENSATING and stop normal flow? stepStatus = NodeStatus.COMPENSATING; // Signal need for compensation } } catch (err: any) { stepStatus = NodeStatus.FAILED; stepError = err instanceof Error ? err : new WorkflowError(String(err), workflowContext.workflowId); this.logger.error(`Error executing step ${stepConfig.id}: ${stepError.message}`, stepError); } finally { record.status = stepStatus; record.endTime = new Date(); record.output = stepOutput; // Record step output record.error = stepError?.message; await this.emitEvent(`step.${stepStatus.toLowerCase()}`, workflowContext, { stepId: stepConfig.id, type: stepConfig.type, error: stepError?.message }); } return { status: stepStatus, nextStepId, output: stepOutput, error: stepError, record: record as IExecutionRecord }; } // --- Task Step Execution with Retries/Timeout --- private async executeTaskStep(config: TaskStepConfig, workflowContext: IWorkflowContext): Promise<{ status: NodeStatus; output?: any; error?: Error }> { const maxRetries = config.retryOptions?.maxRetries ?? 0; const delayMs = config.retryOptions?.delayMs ?? 0; const timeoutMs = config.timeoutMs; // Optional timeout for (let attempt = 0; attempt <= maxRetries; attempt++) { const nodeContext = createNodeContext(workflowContext, config.id, config.nodeId, attempt); const nodeInfo = this.nodeRegistry.get(config.nodeId); if (!nodeInfo) { throw new ConfigurationError(`Node '${config.nodeId}' not registered.`); } // --- Resolve Input Mapping --- this.resolveInputMapping(config, workflowContext, nodeContext); let executionPromise: Promise<void>; let nodeOutput: any = undefined; let nodeError: Error | undefined = undefined; let nodeStatus: NodeStatus = NodeStatus.RUNNING; try { // --- Get Node Implementation --- const implementation = nodeInfo.implementation; let nodeExecuteFn: (ctx: INodeContext) => Promise<void>; if (nodeInfo.isClassInstance) { const nodeInstance = implementation as BaseNode; // --- Optional: Role Check --- if (nodeInstance.requiredRoles && !this.checkRoles(workflowContext.userRole, nodeInstance.requiredRoles)) { throw new AuthorizationError(`User role '${workflowContext.userRole}' not authorized for node '${config.nodeId}'. Required: ${nodeInstance.requiredRoles.join(', ')}`); } // --- Optional: Input Validation --- if (nodeInstance.validate) await nodeInstance.validate(nodeContext); nodeExecuteFn = nodeInstance.execute.bind(nodeInstance); // Bind context } else { // Simple function node const simpleFunc = implementation as NodeFunction; // Wrap simple function to match BaseNode execute signature if needed, // or adjust BaseNode execute signature/context passing. // For simplicity, assume NodeFunction takes INodeContext now. nodeExecuteFn = async (ctx) => { // For simple functions, maybe pass resolved input directly for convenience? const result = await (simpleFunc as any)(ctx, ctx.input); // Adjust signature as needed if (result !== undefined) { ctx.output = result; // Assume direct return is output } }; } // --- Execute with Timeout --- executionPromise = nodeExecuteFn(nodeContext); if (timeoutMs && timeoutMs > 0) { const timeoutPromise = new Promise<never>((_, reject) => setTimeout(() => reject(new TimeoutError(`Node '${config.nodeId}' timed out after ${timeoutMs}ms.`, config.nodeId, config.id)), timeoutMs) ); await Promise.race([executionPromise, timeoutPromise]); } else { await executionPromise; } // --- Execution Success --- nodeStatus = NodeStatus.COMPLETED; nodeOutput = nodeContext.output; // Get output potentially set on context // --- Resolve Output Mapping --- this.resolveOutputMapping(config, nodeContext, workflowContext); break; // Exit retry loop on success } catch (err: any) { // --- Execution Error --- nodeError = err instanceof Error ? err : new NodeExecutionError(String(err), config.nodeId, config.id); nodeContext.error = nodeError; // Record error on node context for logging/history nodeStatus = err instanceof TimeoutError ? NodeStatus.TIMEOUT : NodeStatus.FAILED; this.logger.warn(`Attempt ${attempt + 1}/${maxRetries + 1} failed for node ${config.nodeId} in step ${config.id}: ${nodeError.message}`); if (attempt >= maxRetries) { this.logger.error(`Node ${config.nodeId} failed after ${maxRetries} retries in step ${config.id}.`, nodeError); break; // Exit loop after max retries } if (delayMs > 0) { await new Promise(resolve => setTimeout(resolve, delayMs)); // Wait before retry } } finally { nodeContext.status = nodeStatus; // Final status for this attempt nodeContext.endTime = new Date(); // Log node execution attempt details if necessary this.updateHistory(workflowContext, { // Add attempt info to history? Or just final? stepId: config.id, nodeId: config.nodeId, status: nodeStatus, startTime: nodeContext.startTime, endTime: nodeContext.endTime, error: nodeError?.message, retries: attempt }); } } // End retry loop return { status: NodeStatus.COMPLETED }; } // --- Condition Step Execution --- private async executeConditionStep(config: ConditionStepConfig, workflowContext: IWorkflowContext): Promise<{ nextStepId?: string, branchKey: string | boolean }> { const conditionFn = config.condition; const branchKey = await conditionFn(workflowContext); // Evaluate condition const nextStepId = config.branches[String(branchKey)]; // Find next step based on result this.logger.info(`Condition step '${config.id}' evaluated to '${branchKey}'. Next step: ${nextStepId || 'None'}`); return { nextStepId, branchKey }; } // --- Helper Methods --- private updateHistory(context: IWorkflowContext, record: IExecutionRecord) { // Ensure history is mutable locally if needed, or handle immutability (context.history as IExecutionRecord[]).push(record); // Optional: Limit history size } private resolveInputMapping(stepConfig: TaskStepConfig | SubWorkflowStepConfig, wfCtx: IWorkflowContext, nodeCtx: INodeContext) { const resolvedInput: Record<string, any> = { ...stepConfig.input }; // Start with static input if (stepConfig.inputMapping) { for (const targetField in stepConfig.inputMapping) { const sourcePath = stepConfig.inputMapping[targetField]; resolvedInput[targetField] = this.resolveContextPath(sourcePath, wfCtx); } } // Make resolved input available on node context (nodeCtx as any).input = Object.freeze(resolvedInput); // Make immutable this.logger.debug(`Resolved input for step ${stepConfig.id}:`, resolvedInput); } private resolveOutputMapping(stepConfig: TaskStepConfig | SubWorkflowStepConfig, nodeCtx: INodeContext, wfCtx: IWorkflowContext) { if (stepConfig.outputMapping && nodeCtx.output) { for (const targetPath in stepConfig.outputMapping) { const sourceField = stepConfig.outputMapping[targetPath]; const value = (nodeCtx.output as any)[sourceField]; this.setContextPath(targetPath, value, wfCtx); this.logger.debug(`Mapped output '${sourceField}' to '${targetPath}' for step ${stepConfig.id}`); } } } // Resolve value from context using dot notation (e.g., "variables.user.id", "input.orderId", "steps.step1.output.result") private resolveContextPath(path: string, context: IWorkflowContext): any { const parts = path.split('.'); let current: any = context; if (parts[0] === 'variables') { current = context.variables; parts.shift(); } else if (parts[0] === 'input') { current = context.input; parts.shift(); } else if (parts[0] === 'steps') { // Access output from previous steps via history? Or a dedicated step output cache? // Accessing history might be complex. Let's assume direct variable access for now. // This part needs careful design. For simplicity, only support 'variables' and 'input' for now. this.logger.warn(`Resolving step outputs via path ('${path}') not fully supported yet. Use variables.`); return undefined; } else { // Assume it's a top-level context property or variable implicitly current = context.variables; // Default to checking variables } for (const part of parts) { if (current === null || typeof current !== 'object') return undefined; current = current[part]; } return current; } // Set value in context using dot notation (only supports 'variables' for now) private setContextPath(path: string, value: any, context: IWorkflowContext): void { const parts = path.split('.'); if (parts[0] !== 'variables' || parts.length < 2) { this.logger.error(`Output mapping path '${path}' is invalid. Only 'variables.xxx' is supported.`); return; } let current = context.variables; for (let i = 1; i < parts.length - 1; i++) { const part = parts[i]; if (current[part] === undefined || typeof current[part] !== 'object') { current[part] = {}; // Create nested objects if they don't exist } current = current[part]; } current[parts[parts.length - 1]] = value; } private checkRoles(userRole: string | undefined, requiredRoles: string[]): boolean { if (!requiredRoles || requiredRoles.length === 0) return true; // No roles required if (!userRole) return false; // Roles required but user has none // Simple check: user must have at least one of the required roles // Could be more complex (e.g., all roles required) return requiredRoles.includes(userRole); } // Helper to emit events via the event manager private async emitEvent(eventName: string, context: IWorkflowContext, payload?: Record<string, any>) { if (this.eventManager) { try { await this.eventManager.emit(eventName, { ...(payload || {}), workflowId: context.workflowId, definitionId: context.definitionId }, context); } catch (err) { this.logger.error(`Failed to emit event '${eventName}' for workflow ${context.workflowId}`, err as Error); } } } }