UNPKG

@hemia/workflow-engine

Version:

Motor de flujos de trabajo flexible y extensible desarrollado por Hemia Technologies.

453 lines (447 loc) 20.6 kB
import { evalCondition, interpolateStepParams, setOutput, logError, DefaultExecutionContext } from '@hemia/workflow-core'; class FlowController { constructor(workflow) { this.workflow = workflow; } getFirstStep() { return this.workflow.steps[0] ?? null; } async getNextSteps(current, context) { const next = current.next ?? []; const result = []; for (const n of next) { if (!n.condition) { result.push(this.findStepById(n.id)); continue; } try { const pass = await evalValue(n.condition, context); if (pass) { result.push(this.findStepById(n.id)); } } catch (e) { context.log(`❌ Error evaluando condición '${n.condition}': ${e}`); } } return result; } findStepById(id) { const step = this.workflow.steps.find(s => s.id === id); if (!step) throw new Error(`Step ${id} no encontrado`); return step; } } async function evalValue(expr, context) { return await evalCondition(expr, context); } class StepExecutor { static async execute(node, step, context) { try { context.log(`➡️ Ejecutando paso ${step.id} (${step.type}) - stepExecutor.ts`); const params = await interpolateStepParams(step.params, context); const result = await node.execute(params, context); if (result.success && result.output !== undefined) { setOutput(step.id, result.output, context); } return result; } catch (error) { logError(context, `❌ Error ejecutando paso ${step.id} - stepExecutor.ts`, error); return { success: false, error: { code: 'STEP_EXEC_ERROR', message: error.message, details: error.stack, }, }; } } } class WorkflowEngine { constructor(workflow, executionId = crypto.randomUUID(), nodeRegistry, saveContextFn, loadContextFn, saveWaitInfoFn, clearWaitInfoFn, validateBearerToken) { this.workflow = workflow; this.executionId = executionId; this.nodeRegistry = nodeRegistry; this.saveContextFn = saveContextFn; this.loadContextFn = loadContextFn; this.saveWaitInfoFn = saveWaitInfoFn; this.clearWaitInfoFn = clearWaitInfoFn; this.validateBearerToken = validateBearerToken; this.context = new DefaultExecutionContext(this.executionId, workflow.id, ''); // Inicializar config global si viene definida en el workflow const wfAny = workflow; const config = wfAny.config ?? workflow.metadata?.config; if (config) { this.context.setVariable('config', config); } } ensureConfigInContext() { const wfAny = this.workflow; const wfConfig = wfAny.config ?? this.workflow.metadata?.config ?? {}; const current = this.context.variables?.config ?? {}; const merged = { ...wfConfig, ...current }; this.context.setVariable('config', merged); } async run() { await this.loadContext(); this.ensureConfigInContext(); await this.clearWaitInfo(); const controller = new FlowController(this.workflow); let currentSteps = [controller.getFirstStep()].filter(Boolean); while (currentSteps.length > 0) { const nextSteps = []; for (const step of currentSteps) { this.context.currentStepId = step.id; const node = this.nodeRegistry.get(step.type); if (!node) throw new Error(`No node registered for type ${step.type}`); const result = await StepExecutor.execute(node, step, this.context); const isWaiting = !result.success && result.error && (result.error.code === 'WAITING' || result.error.code === 'PAUSED'); if (isWaiting) { this.context.log(`⏸️ Pausado en paso ${step.id} - workflowEngine.ts`); await this.saveContext('WAITING'); const waitInfo = this.buildWaitInfo(step, result); await this.saveWaitInfo(waitInfo, 'WAITING'); // const d: any = result.error?.details ?? {}; // const waitInfo: WaitInfo = { // workflowId: this.workflow.id, // executionId: this.executionId, // stepId: step.id, // reason: d.reason ?? 'external', // formId: d.formId ?? null, // metadata: d.metadata ?? null, // timeout: d.timeout ?? null, // timeoutMs: d.timeoutMs ?? null, // webhookPath: `/v0/webhooks/${this.workflow.id}/${this.executionId}/${step.id}`, // webhookMethod: step.type === 'wait-for-webhook' ? 'POST' : 'GET' // }; return { status: 'WAITING', wait: waitInfo }; } if (!result.success) { const routed = await this.routeError(step); if (routed) { await this.saveContext('RUNNING'); nextSteps.push(routed); continue; } this.context.log(`❌ Paso ${step.id} falló`, result.error); await this.saveContext('FAILED'); await this.clearWaitInfo(); return { status: 'FAILED', error: result.error }; } await this.saveContext(); if (step.type === 'wait-for-webhook') { this.context.log(`⏸️ Pausado en paso ${step.id} esperando webhook`); const variableData = this.context.getVariable?.(`waiting_${step.id}`) ?? {}; const waitInfo = this.buildWaitInfo(step, { error: { details: variableData } }); await this.saveWaitInfo(waitInfo, 'WAITING'); // const d: any = this.context.getVariable?.(`waiting_${step.id}`) ?? {}; // const waitInfo: WaitInfo = { // workflowId: this.workflow.id, // executionId: this.executionId, // stepId: step.id, // reason: d.reason ?? 'external', // formId: d.formId ?? null, // metadata: d.metadata ?? null, // timeout: d.timeout ?? null, // timeoutMs: d.timeoutMs ?? null, // webhookPath: `/v0/webhooks/${this.workflow.id}/${this.executionId}/${step.id}`, // webhookMethod: step.type === 'wait-for-webhook' ? 'POST' : 'GET' // }; return { status: 'WAITING', wait: waitInfo }; } const newSteps = await controller.getNextSteps(step, this.context); nextSteps.push(...newSteps); } currentSteps = nextSteps; } this.context.log('✅ Workflow completado'); await this.saveContext('COMPLETED'); await this.clearWaitInfo(); return { status: 'COMPLETED' }; } async routeError(step) { const stepAny = step; if (stepAny.error?.next) { const target = this.workflow.steps.find(s => s.id === stepAny.error.next); if (!target) throw new Error(`Error handler step '${stepAny.error.next}' no encontrado`); this.context.log(`➡️ Redirigiendo error de ${step.id} a ${target.id}`); return target; } const handlers = this.workflow.errorHandlers; if (handlers && Object.keys(handlers).length > 0) { const first = Object.values(handlers)[0]; if (!first) return null; const target = this.workflow.steps.find(s => s.id === first.id) ?? first; this.context.log(`➡️ Redirigiendo error de ${step.id} a handler global ${target.id}`); return target; } return null; } async resume(fromStepId) { await this.loadContext(); this.ensureConfigInContext(); await this.clearWaitInfo(); const step = this.workflow.steps.find(s => s.id === fromStepId); if (!step) throw new Error(`Paso ${fromStepId} no encontrado`); if (step.type === 'wait-for-webhook') { this.context.log(`▶️ Reanudando después del paso de espera ${step.id}`); await this.saveContext('RUNNING'); return await this.runFromStep(step); } this.context.currentStepId = step.id; const node = this.nodeRegistry.get(step.type); if (!node) throw new Error(`No node registered for type ${step.type}`); const result = await StepExecutor.execute(node, step, this.context); const isWaiting = !result.success && result.error && (result.error.code === 'WAITING' || result.error.code === 'PAUSED'); if (isWaiting) { this.context.log(`⏸️ Aún en espera en ${step.id}`); await this.saveContext('WAITING'); const waitInfo = this.buildWaitInfo(step, result); await this.saveWaitInfo(waitInfo, 'WAITING'); // const d: any = result.error?.details ?? {}; // const waitInfo: WaitInfo = { // workflowId: this.workflow.id, // executionId: this.executionId, // stepId: step.id, // reason: d.reason ?? 'external', // formId: d.formId ?? null, // metadata: d.metadata ?? null, // timeout: d.timeout ?? null, // timeoutMs: d.timeoutMs ?? null, // webhookPath: `/v0/webhooks/${this.workflow.id}/${this.executionId}/${step.id}`, // webhookMethod: step.type === 'wait-for-webhook' ? 'POST' : 'GET' // }; return { status: 'WAITING', wait: waitInfo }; } if (!result.success) { const routed = await this.routeError(step); if (routed) { await this.saveContext('RUNNING'); return await this.runFromStep(routed); } this.context.log(`❌ Error al reanudar desde ${step.id}`, result.error); await this.saveContext('FAILED'); await this.clearWaitInfo(); return { status: 'FAILED', error: result.error }; } await this.saveContext('RUNNING'); return await this.runFromStep(step); } async runFromStep(current) { const controller = new FlowController(this.workflow); let currentSteps = await controller.getNextSteps(current, this.context); while (currentSteps.length > 0) { const nextSteps = []; for (const step of currentSteps) { this.context.currentStepId = step.id; const node = this.nodeRegistry.get(step.type); if (!node) throw new Error(`No node registered for type ${step.type}`); const result = await StepExecutor.execute(node, step, this.context); const isWaiting = !result.success && result.error && (result.error.code === 'WAITING' || result.error.code === 'PAUSED'); if (isWaiting) { this.context.log(`⏸️ Pausado en paso ${step.id}`); await this.saveContext('WAITING'); const waitInfo = this.buildWaitInfo(step, result); await this.saveWaitInfo(waitInfo, 'WAITING'); // const d: any = result.error?.details ?? {}; // const waitInfo: WaitInfo = { // workflowId: this.workflow.id, // executionId: this.executionId, // stepId: step.id, // reason: d.reason ?? 'external', // formId: d.formId ?? null, // metadata: d.metadata ?? null, // timeout: d.timeout ?? null, // timeoutMs: d.timeoutMs ?? null, // webhookPath: `/v0/webhooks/${this.workflow.id}/${this.executionId}/${step.id}`, // webhookMethod: step.type === 'wait-for-webhook' ? 'POST' : 'GET' // }; return { status: 'WAITING', wait: waitInfo }; } if (!result.success) { const routed = await this.routeError(step); if (routed) { await this.saveContext('RUNNING'); nextSteps.push(routed); continue; } this.context.log(`❌ Paso ${step.id} falló`, result.error); await this.saveContext('FAILED'); await this.clearWaitInfo(); return { status: 'FAILED', error: result.error }; } await this.saveContext('RUNNING'); if (step.type === 'wait-for-webhook') { this.context.log(`⏸️ Pausado en paso ${step.id}`); await this.saveContext('WAITING'); const waitInfo = this.buildWaitInfo(step, result); await this.saveWaitInfo(waitInfo, 'WAITING'); // const d: any = this.context.getVariable?.(`waiting_${step.id}`) ?? {}; // const waitInfo: WaitInfo = { // workflowId: this.workflow.id, // executionId: this.executionId, // stepId: step.id, // reason: d.reason ?? 'external', // formId: d.formId ?? null, // metadata: d.metadata ?? null, // timeout: d.timeout ?? null, // timeoutMs: d.timeoutMs ?? null, // webhookPath: `/v0/webhooks/${this.workflow.id}/${this.executionId}/${step.id}`, // webhookMethod: step.type === 'wait-for-webhook' ? 'POST' : 'GET' // }; return { status: 'WAITING', wait: waitInfo }; } const next = await controller.getNextSteps(step, this.context); nextSteps.push(...next); } currentSteps = nextSteps; } this.context.log('✅ Workflow completado'); await this.saveContext('COMPLETED'); await this.clearWaitInfo(); return { status: 'COMPLETED' }; } async saveContext(status) { const currentStep = this.workflow.steps.find((s) => s.id === this.context.currentStepId); const defaultStatus = currentStep?.type === 'wait-for-webhook' ? 'WAITING' : 'RUNNING'; await this.saveContextFn(this.context, this.executionId, this.workflow.id, status ?? defaultStatus); } async loadContext() { const data = await this.loadContextFn(this.executionId); if (!data) return; const loaded = data; // Garantizar que siempre sean arrays const existingLogs = Array.isArray(this.context.logs) ? this.context.logs : []; const incomingLogs = Array.isArray(loaded.logs) ? loaded.logs : []; const { logs: _omitLogs, ...rest } = loaded; // actualiza el contexto con el resto de campos Object.assign(this.context, rest); // utilidades const toIso = (t) => t instanceof Date ? t.toISOString() : new Date(t).toISOString(); const keyOf = (l) => `${toIso(l.timestamp)}#${l.stepId ?? ''}#${l.message ?? ''}`; // dedupe + merge usando Map const logMap = new Map(); for (const l of existingLogs) logMap.set(keyOf(l), l); for (const l of incomingLogs) if (!logMap.has(keyOf(l))) logMap.set(keyOf(l), l); this.context.logs = Array.from(logMap.values()).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); } /** * Guarda la información de espera cuando el workflow se pausa. * Permite consultar el estado sin re-ejecutar el workflow. */ async saveWaitInfo(waitInfo, status) { if (this.saveWaitInfoFn) { try { await this.saveWaitInfoFn(this.executionId, this.workflow.id, { status: status, wait: waitInfo }); this.context.log(`💾 Información de espera guardada para paso ${waitInfo.stepId} - WorkflowEngine.ts`); } catch (error) { this.context.log(`❌ Error guardando información de espera para paso ${waitInfo.stepId}`, error); } } } /** * Limpia la información de espera cuando el workflow reanuda su ejecución. * Mantiene la consistencia del estado entre WAITING -> RUNNING. */ async clearWaitInfo() { if (this.clearWaitInfoFn) { try { await this.clearWaitInfoFn(this.executionId); ; } catch (error) { // No lanzamos el error para no interrumpir el flujo principal } } } /** * Construye la información de espera basada en el resultado del paso. * Centraliza la lógica de creación de WaitInfo para mantener consistencia. */ buildWaitInfo(step, result) { const d = result?.error?.details ?? {}; const waitInfo = { workflowId: this.workflow.id, executionId: this.executionId, stepId: step.id, reason: d.reason ?? 'external', formId: d.formId ?? null, metadata: d.metadata ?? null, timeout: d.timeout ?? null, timeoutMs: d.timeoutMs ?? null, webhookPath: `/v0/webhooks/${this.workflow.id}/${this.executionId}/${step.id}`, webhookMethod: step.type === 'wait-for-webhook' ? 'POST' : 'GET' }; return waitInfo; } /** * Inicia la ejecución por un trigger webhook validando bearer (si se configuró). * Debe llamarse desde tu handler HTTP pasándole headers y body. */ async startFromWebhook(request) { const trigger = this.workflow.trigger; if (!trigger || trigger.type !== 'webhook') { return { accepted: false, reason: 'NOT_A_WEBHOOK_TRIGGER' }; } // Validar path if (trigger.path && request.path !== trigger.path) { return { accepted: false, reason: 'PATH_MISMATCH' }; } // Validar autenticación si aplica if (trigger.authentication?.type === 'bearer') { const auth = request.headers['authorization'] || request.headers['Authorization']; const header = Array.isArray(auth) ? auth[0] : auth; if (!header || !header.startsWith('Bearer ')) { return { accepted: false, reason: 'MISSING_BEARER' }; } const token = header.slice('Bearer '.length); if (this.validateBearerToken) { const ok = await this.validateBearerToken(token); if (!ok) return { accepted: false, reason: 'INVALID_TOKEN' }; } } // Inyectar input y config al contexto this.context.setVariable('input', request.body ?? {}); const wfAny = this.workflow; this.context.setVariable('config', wfAny.config ?? this.workflow.metadata?.config ?? {}); await this.saveContext('RUNNING'); await this.run(); return { accepted: true }; } } class NodeRegistry { constructor() { this.nodes = new Map(); } register(type, node) { this.nodes.set(type, node); } get(type) { return this.nodes.get(type); } registerMany(nodes) { nodes.forEach(n => this.register(n.type, n)); } listTypes() { return Array.from(this.nodes.keys()); } } export { FlowController, NodeRegistry, StepExecutor, WorkflowEngine };