@hemia/workflow-engine
Version:
Motor de flujos de trabajo flexible y extensible desarrollado por Hemia Technologies.
453 lines (447 loc) • 20.6 kB
JavaScript
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 };