@flowlab/all
Version:
A cool library focusing on handling various flows
427 lines (366 loc) • 21.2 kB
text/typescript
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);
}
}
}
}