wulf-pulse/lib/services/pipeline-engine.ts

408 lines
15 KiB
TypeScript

/**
* Pipeline Engine
* Matches incoming webhooks to pipelines, executes steps sequentially,
* resolves template variables, and accumulates context between steps.
*/
import { postgresClient } from './postgres-client';
import {
WebhookPipeline,
PipelineStep,
PipelineWithSteps,
PipelineContext,
PipelineStatus,
StepExecutorResult,
TriggerCondition,
} from '../types/pipeline';
// Step executor registry — populated by individual step files
type StepExecutorFn = (
step: PipelineStep,
context: PipelineContext,
executionId: number
) => Promise<StepExecutorResult>;
const stepExecutors: Map<string, StepExecutorFn> = new Map();
export function registerStepExecutor(stepType: string, executor: StepExecutorFn): void {
stepExecutors.set(stepType, executor);
}
export class PipelineEngine {
/**
* Find and execute all matching pipelines for a trigger source + payload.
* Called from webhook routes after raw logging.
*/
async processTrigger(
triggerSource: string,
payload: Record<string, any>
): Promise<number[]> {
const pipelines = await this.findMatchingPipelines(triggerSource, payload);
if (pipelines.length === 0) {
return [];
}
console.log(`[PIPELINE] ${pipelines.length} pipeline(s) matched for ${triggerSource}`);
const executionIds: number[] = [];
for (const pipeline of pipelines) {
try {
const execId = await this.executePipeline(pipeline, triggerSource, payload);
executionIds.push(execId);
} catch (err) {
console.error(`[PIPELINE] Failed to execute pipeline "${pipeline.name}":`, err);
}
}
return executionIds;
}
/**
* Find active pipelines matching the trigger source and conditions.
*/
async findMatchingPipelines(
triggerSource: string,
payload: Record<string, any>
): Promise<PipelineWithSteps[]> {
const result = await postgresClient.query<WebhookPipeline>(
`SELECT * FROM webhook_pipelines
WHERE is_active = true AND trigger_source = $1
ORDER BY sort_order`,
[triggerSource]
);
const matched: PipelineWithSteps[] = [];
for (const pipeline of result.rows) {
const conditions: TriggerCondition[] = Array.isArray(pipeline.trigger_conditions)
? pipeline.trigger_conditions
: [];
if (this.evaluateConditions(conditions, payload)) {
const stepsResult = await postgresClient.query<PipelineStep>(
`SELECT * FROM pipeline_steps
WHERE pipeline_id = $1 AND is_active = true
ORDER BY step_order`,
[pipeline.id]
);
matched.push({ ...pipeline, steps: stepsResult.rows });
}
}
return matched;
}
/**
* Execute a single pipeline: create execution record, run steps, update status.
*/
async executePipeline(
pipeline: PipelineWithSteps,
triggerSource: string,
payload: Record<string, any>
): Promise<number> {
const execResult = await postgresClient.query<{ id: number }>(
`INSERT INTO pipeline_executions (pipeline_id, trigger_source, trigger_payload, status)
VALUES ($1, $2, $3, 'running')
RETURNING id`,
[pipeline.id, triggerSource, JSON.stringify(payload)]
);
const executionId = execResult.rows[0].id;
const context: PipelineContext = { trigger: payload };
let finalStatus: PipelineStatus = 'completed';
let errorMessage: string | null = null;
console.log(`[PIPELINE] Executing "${pipeline.name}" (exec #${executionId}), ${pipeline.steps.length} steps`);
for (const step of pipeline.steps) {
// Update current step
await postgresClient.query(
`UPDATE pipeline_executions SET current_step = $1, context = $2 WHERE id = $3`,
[step.step_order, JSON.stringify(context), executionId]
);
const stepStart = Date.now();
// Log step start
await postgresClient.query(
`INSERT INTO pipeline_execution_steps (execution_id, step_order, step_type, step_name, status, started_at, input_data)
VALUES ($1, $2, $3, $4, 'running', NOW(), $5)`,
[executionId, step.step_order, step.step_type, step.name, JSON.stringify({ config: step.config })]
);
const executor = stepExecutors.get(step.step_type);
if (!executor) {
const err = `No executor registered for step type: ${step.step_type}`;
console.error(`[PIPELINE] ${err}`);
await this.updateStepLog(executionId, step.step_order, 'failed', null, err, Date.now() - stepStart);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = err;
break;
}
continue;
}
try {
// Resolve template variables in step config
const resolvedConfig = this.resolveTemplates(step.config, context);
const resolvedStep = { ...step, config: resolvedConfig };
const result = await executor(resolvedStep, context, executionId);
const duration = Date.now() - stepStart;
if (result.waiting) {
await this.updateStepLog(executionId, step.step_order, 'waiting', result.output, null, duration);
finalStatus = 'waiting';
break;
}
if (result.success) {
// Merge output into context
if (result.output) {
Object.assign(context, result.output);
}
await this.updateStepLog(executionId, step.step_order, 'completed', result.output, null, duration);
console.log(`[PIPELINE] Step ${step.step_order} "${step.name}" completed (${duration}ms)`);
} else {
await this.updateStepLog(executionId, step.step_order, 'failed', result.output, result.error || null, duration);
console.error(`[PIPELINE] Step ${step.step_order} "${step.name}" failed: ${result.error}`);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = `Step ${step.step_order} "${step.name}": ${result.error}`;
break;
} else if (step.on_failure === 'skip_to' && step.skip_to_step) {
// Skip ahead — handled by finding the next step with matching order
// For simplicity, we just continue; the skip_to logic would need step reordering
continue;
}
// on_failure === 'continue' → keep going
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const duration = Date.now() - stepStart;
await this.updateStepLog(executionId, step.step_order, 'failed', null, errMsg, duration);
console.error(`[PIPELINE] Step ${step.step_order} "${step.name}" threw: ${errMsg}`);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = errMsg;
break;
}
}
}
// Finalize execution
await postgresClient.query(
`UPDATE pipeline_executions
SET status = $1, context = $2, completed_at = NOW(),
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
error_message = $3
WHERE id = $4`,
[finalStatus, JSON.stringify(context), errorMessage, executionId]
);
console.log(`[PIPELINE] Execution #${executionId} finished: ${finalStatus}`);
return executionId;
}
/**
* Resume a waiting pipeline (e.g., after approval callback).
*/
async resumeExecution(executionId: number, approvalResult: Record<string, any>): Promise<void> {
const execResult = await postgresClient.query<any>(
`SELECT pe.*, wp.name as pipeline_name FROM pipeline_executions pe
JOIN webhook_pipelines wp ON wp.id = pe.pipeline_id
WHERE pe.id = $1 AND pe.status = 'waiting'`,
[executionId]
);
if (execResult.rows.length === 0) {
throw new Error(`Execution #${executionId} not found or not in waiting state`);
}
const execution = execResult.rows[0];
const context: PipelineContext = execution.context || {};
context.approval_result = approvalResult;
// Get remaining steps after the current waiting step
const stepsResult = await postgresClient.query<PipelineStep>(
`SELECT * FROM pipeline_steps
WHERE pipeline_id = $1 AND step_order > $2 AND is_active = true
ORDER BY step_order`,
[execution.pipeline_id, execution.current_step]
);
// Update execution to running
await postgresClient.query(
`UPDATE pipeline_executions SET status = 'running', context = $1 WHERE id = $2`,
[JSON.stringify(context), executionId]
);
// Mark the waiting step as completed
await this.updateStepLog(executionId, execution.current_step, 'completed', approvalResult, null, 0);
// Continue executing remaining steps
let finalStatus: PipelineStatus = 'completed';
let errorMessage: string | null = null;
for (const step of stepsResult.rows) {
await postgresClient.query(
`UPDATE pipeline_executions SET current_step = $1, context = $2 WHERE id = $3`,
[step.step_order, JSON.stringify(context), executionId]
);
const stepStart = Date.now();
await postgresClient.query(
`INSERT INTO pipeline_execution_steps (execution_id, step_order, step_type, step_name, status, started_at, input_data)
VALUES ($1, $2, $3, $4, 'running', NOW(), $5)`,
[executionId, step.step_order, step.step_type, step.name, JSON.stringify({ config: step.config })]
);
const executor = stepExecutors.get(step.step_type);
if (!executor) {
const err = `No executor for: ${step.step_type}`;
await this.updateStepLog(executionId, step.step_order, 'failed', null, err, Date.now() - stepStart);
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = err; break; }
continue;
}
try {
const resolvedConfig = this.resolveTemplates(step.config, context);
const result = await executor({ ...step, config: resolvedConfig }, context, executionId);
const duration = Date.now() - stepStart;
if (result.waiting) {
await this.updateStepLog(executionId, step.step_order, 'waiting', result.output, null, duration);
finalStatus = 'waiting';
break;
}
if (result.success) {
if (result.output) Object.assign(context, result.output);
await this.updateStepLog(executionId, step.step_order, 'completed', result.output, null, duration);
} else {
await this.updateStepLog(executionId, step.step_order, 'failed', result.output, result.error || null, duration);
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = result.error ?? null; break; }
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
await this.updateStepLog(executionId, step.step_order, 'failed', null, errMsg, Date.now() - stepStart);
if (step.on_failure === 'stop') { finalStatus = 'failed'; errorMessage = errMsg; break; }
}
}
await postgresClient.query(
`UPDATE pipeline_executions
SET status = $1, context = $2, completed_at = NOW(),
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
error_message = $3
WHERE id = $4`,
[finalStatus, JSON.stringify(context), errorMessage, executionId]
);
}
// ============================================================================
// Template Resolution
// ============================================================================
/**
* Recursively resolve {{...}} template variables in any value.
*/
resolveTemplates(value: any, context: PipelineContext): any {
if (typeof value === 'string') {
return this.resolveStringTemplate(value, context);
}
if (Array.isArray(value)) {
return value.map(v => this.resolveTemplates(v, context));
}
if (value && typeof value === 'object') {
const resolved: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
resolved[k] = this.resolveTemplates(v, context);
}
return resolved;
}
return value;
}
private resolveStringTemplate(template: string, context: PipelineContext): string {
return template.replace(/\{\{([^}]+)\}\}/g, (match, path: string) => {
const value = this.getNestedValue(context, path.trim());
if (value === undefined || value === null) return '';
return String(value);
});
}
private getNestedValue(obj: any, path: string): any {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current == null) return undefined;
current = current[part];
}
return current;
}
// ============================================================================
// Condition Evaluation
// ============================================================================
evaluateConditions(conditions: TriggerCondition[], payload: Record<string, any>): boolean {
if (conditions.length === 0) return true;
return conditions.every(cond => this.evaluateCondition(cond, payload));
}
private evaluateCondition(cond: TriggerCondition, payload: Record<string, any>): boolean {
const fieldValue = this.getNestedValue(payload, cond.field);
switch (cond.operator) {
case 'equals':
return String(fieldValue) === String(cond.value);
case 'not_equals':
return String(fieldValue) !== String(cond.value);
case 'contains':
return fieldValue != null && String(fieldValue).toLowerCase().includes(String(cond.value).toLowerCase());
case 'not_contains':
return fieldValue == null || !String(fieldValue).toLowerCase().includes(String(cond.value).toLowerCase());
case 'in':
return Array.isArray(cond.value) && cond.value.some((v: any) => String(v) === String(fieldValue));
case 'not_in':
return !Array.isArray(cond.value) || !cond.value.some((v: any) => String(v) === String(fieldValue));
case 'regex':
try { return fieldValue != null && new RegExp(String(cond.value), 'i').test(String(fieldValue)); }
catch { return false; }
case 'exists':
return fieldValue != null && fieldValue !== '';
case 'not_exists':
return fieldValue == null || fieldValue === '';
default:
return false;
}
}
// ============================================================================
// Helpers
// ============================================================================
private async updateStepLog(
executionId: number,
stepOrder: number,
status: string,
outputData: any,
errorMessage: string | null,
durationMs: number
): Promise<void> {
await postgresClient.query(
`UPDATE pipeline_execution_steps
SET status = $1, output_data = $2, error_message = $3, duration_ms = $4, completed_at = NOW()
WHERE execution_id = $5 AND step_order = $6`,
[status, outputData ? JSON.stringify(outputData) : null, errorMessage, durationMs, executionId, stepOrder]
);
}
}
export const pipelineEngine = new PipelineEngine();