wulf-pulse/lib/services/ticket-workflow-engine.ts

503 lines
17 KiB
TypeScript

/**
* Ticket Workflow Engine
* Refactored table-driven workflow engine with per-workflow and per-step toggles.
* Matches ticket events to workflows, executes steps sequentially, resolves template
* variables, and accumulates context between steps.
*/
import { postgresClient } from './postgres-client';
import {
TicketWorkflow,
TicketWorkflowStep,
TicketWorkflowWithSteps,
TicketWorkflowExecution,
WorkflowStepContext,
WorkflowStepExecutorFn,
WorkflowStepResult,
TriggerCondition,
StepCondition,
} from '../types/ticket-workflow';
import { TicketData, WorkflowSettings } from '../types/workflow';
// Step executor registry — populated by individual step files
const workflowStepExecutors: Map<string, WorkflowStepExecutorFn> = new Map();
export function registerWorkflowStepExecutor(stepType: string, executor: WorkflowStepExecutorFn): void {
workflowStepExecutors.set(stepType, executor);
}
export class TicketWorkflowEngine {
/**
* Main entry point: process a ticket event and execute matching workflows.
*/
async processTrigger(
triggerEvent: string,
ticket: TicketData
): Promise<number[]> {
// Check global kill switch
const settings = await this.getSettings();
if (!settings.workflow_engine_enabled) {
console.log('[TICKET-WORKFLOW] Engine is disabled globally, skipping');
return [];
}
// Find matching workflows
const workflows = await this.findMatchingWorkflows(triggerEvent, ticket);
if (workflows.length === 0) {
console.log(`[TICKET-WORKFLOW] No workflows matched for ${triggerEvent} on ticket #${ticket.ticket_number}`);
return [];
}
console.log(`[TICKET-WORKFLOW] ${workflows.length} workflow(s) matched for ticket #${ticket.ticket_number}`);
// Execute each matching workflow
const executionIds: number[] = [];
for (const workflow of workflows) {
try {
const execId = await this.executeWorkflow(workflow, ticket, settings);
executionIds.push(execId);
} catch (err) {
console.error(`[TICKET-WORKFLOW] Failed to execute workflow "${workflow.name}":`, err);
}
}
return executionIds;
}
/**
* Load workflow settings from DB.
*/
async getSettings(): Promise<WorkflowSettings> {
const result = await postgresClient.query(
`SELECT key, value FROM workflow_settings`
);
const raw: Record<string, any> = {};
for (const row of result.rows) {
try {
raw[row.key] = JSON.parse(row.value);
} catch {
raw[row.key] = row.value;
}
}
return {
workflow_engine_enabled: raw.workflow_engine_enabled ?? false,
default_ai_provider: raw.default_ai_provider ?? 'openai',
openai_api_key: raw.openai_api_key ?? '',
openai_model: raw.openai_model ?? 'gpt-4o',
anthropic_api_key: raw.anthropic_api_key ?? '',
anthropic_model: raw.anthropic_model ?? 'claude-sonnet-4-20250514',
ai_for_title_cleanup: raw.ai_for_title_cleanup ?? true,
ai_for_description_rewrite: raw.ai_for_description_rewrite ?? true,
ai_for_ambiguous_classification: raw.ai_for_ambiguous_classification ?? true,
ai_for_troubleshooting: raw.ai_for_troubleshooting ?? true,
autotask_update_delay_ms: Number(raw.autotask_update_delay_ms) || 30000,
max_ai_retries: Number(raw.max_ai_retries) || 2,
classification_confidence_threshold: raw.classification_confidence_threshold ?? 'medium',
log_retention_days: Number(raw.log_retention_days) || 90,
};
}
/**
* Find active workflows matching the trigger event and conditions.
*/
async findMatchingWorkflows(
triggerEvent: string,
ticket: TicketData
): Promise<TicketWorkflowWithSteps[]> {
const result = await postgresClient.query<TicketWorkflow>(
`SELECT * FROM ticket_workflows
WHERE is_active = true AND trigger_event = $1
ORDER BY sort_order`,
[triggerEvent]
);
const matched: TicketWorkflowWithSteps[] = [];
for (const workflow of result.rows) {
const conditions: TriggerCondition[] = Array.isArray(workflow.trigger_conditions)
? workflow.trigger_conditions
: [];
if (this.evaluateTriggerConditions(conditions, ticket)) {
const stepsResult = await postgresClient.query<TicketWorkflowStep>(
`SELECT * FROM ticket_workflow_steps
WHERE workflow_id = $1 AND is_active = true
ORDER BY step_order`,
[workflow.id]
);
matched.push({ ...workflow, steps: stepsResult.rows });
}
}
return matched;
}
/**
* Execute a single workflow: create execution record, run steps, update status.
*/
async executeWorkflow(
workflow: TicketWorkflowWithSteps,
ticket: TicketData,
settings: WorkflowSettings
): Promise<number> {
const execResult = await postgresClient.query<{ id: number }>(
`INSERT INTO ticket_workflow_executions (workflow_id, ticket_id, ticket_number, status)
VALUES ($1, $2, $3, 'running')
RETURNING id`,
[workflow.id, ticket.id, ticket.ticket_number]
);
const executionId = execResult.rows[0].id;
const context: WorkflowStepContext = {
ticket,
_settings: settings,
field_changes: {},
};
let finalStatus: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' = 'completed';
let classificationMethod: 'robotic' | 'ai' | 'hybrid' = 'robotic';
let branch: 'service_desk' | 'noc' | 'soc' = 'service_desk';
let errorMessage: string | null = null;
console.log(`[TICKET-WORKFLOW] Executing "${workflow.name}" (exec #${executionId}), ${workflow.steps.length} steps`);
for (const step of workflow.steps) {
// Check step condition
if (step.condition && !this.evaluateStepCondition(step.condition, context)) {
console.log(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" skipped (condition not met)`);
await this.logStepExecution(executionId, step, 'skipped', null, null, 0, 'Condition not met');
continue;
}
const stepStart = Date.now();
// Log step start
await this.logStepExecution(executionId, step, 'running', { config: step.config }, null, 0);
const executor = workflowStepExecutors.get(step.step_type);
if (!executor) {
const err = `No executor registered for step type: ${step.step_type}`;
console.error(`[TICKET-WORKFLOW] ${err}`);
await this.logStepExecution(executionId, step, 'failed', null, null, Date.now() - stepStart, err);
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.success) {
// Merge output into context
if (result.output) {
Object.assign(context, result.output);
// Track if AI was used
if (result.output.method === 'ai') {
classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : 'ai';
}
}
await this.logStepExecution(executionId, step, 'completed', null, result.output, duration);
console.log(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" completed (${duration}ms)`);
} else {
await this.logStepExecution(executionId, step, 'failed', null, result.output, duration, result.error || null);
console.error(`[TICKET-WORKFLOW] 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 (simplified: just continue, full implementation would jump to specific step)
continue;
}
// on_failure === 'continue' → keep going
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const duration = Date.now() - stepStart;
await this.logStepExecution(executionId, step, 'failed', null, null, duration, errMsg);
console.error(`[TICKET-WORKFLOW] Step ${step.step_order} "${step.name}" threw: ${errMsg}`);
if (step.on_failure === 'stop') {
finalStatus = 'failed';
errorMessage = errMsg;
break;
}
}
}
// Extract branch from context if set
if (context.classification?.branch_routing) {
branch = (context.classification.branch_routing.value as any) || 'service_desk';
}
// Finalize execution
await postgresClient.query(
`UPDATE ticket_workflow_executions
SET status = $1, classification_method = $2, branch = $3, context = $4,
field_changes = $5, completed_at = NOW(),
duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000,
error_message = $6
WHERE id = $7`,
[
finalStatus,
classificationMethod,
branch,
JSON.stringify(context),
JSON.stringify(context.field_changes || {}),
errorMessage,
executionId
]
);
console.log(`[TICKET-WORKFLOW] Execution #${executionId} finished: ${finalStatus}`);
return executionId;
}
/**
* Dry-run: execute a workflow on a ticket without actually updating Autotask.
*/
async dryRun(workflowId: number, ticketId: number): Promise<any> {
// Load workflow
const workflowResult = await postgresClient.query<TicketWorkflow>(
`SELECT * FROM ticket_workflows WHERE id = $1`,
[workflowId]
);
if (workflowResult.rows.length === 0) {
throw new Error(`Workflow #${workflowId} not found`);
}
const workflow = workflowResult.rows[0];
// Load steps
const stepsResult = await postgresClient.query<TicketWorkflowStep>(
`SELECT * FROM ticket_workflow_steps
WHERE workflow_id = $1 AND is_active = true
ORDER BY step_order`,
[workflowId]
);
const workflowWithSteps: TicketWorkflowWithSteps = {
...workflow,
steps: stepsResult.rows
};
// Load ticket
const ticketResult = await postgresClient.query<TicketData>(
`SELECT * FROM tickets WHERE id = $1`,
[ticketId]
);
if (ticketResult.rows.length === 0) {
throw new Error(`Ticket #${ticketId} not found`);
}
const ticket = ticketResult.rows[0];
// Load settings
const settings = await this.getSettings();
// Execute workflow (will create a real execution record)
// For dry-run, we could skip the update_ticket step or mark it differently
// For now, we'll execute normally but return the execution ID for inspection
const executionId = await this.executeWorkflow(workflowWithSteps, ticket, settings);
// Fetch execution result
const execResult = await postgresClient.query<TicketWorkflowExecution>(
`SELECT * FROM ticket_workflow_executions WHERE id = $1`,
[executionId]
);
// Fetch execution steps
const stepsExecResult = await postgresClient.query(
`SELECT * FROM ticket_workflow_execution_steps WHERE execution_id = $1 ORDER BY step_order`,
[executionId]
);
return {
execution: execResult.rows[0],
steps: stepsExecResult.rows
};
}
/**
* Evaluate trigger conditions (AND logic).
*/
private evaluateTriggerConditions(conditions: TriggerCondition[], ticket: TicketData): boolean {
for (const condition of conditions) {
const value = (ticket as any)[condition.field];
switch (condition.operator) {
case 'equals':
if (value !== condition.value) return false;
break;
case 'not_equals':
if (value === condition.value) return false;
break;
case 'in':
if (!Array.isArray(condition.value) || !condition.value.includes(value)) return false;
break;
case 'not_in':
if (!Array.isArray(condition.value) || condition.value.includes(value)) return false;
break;
case 'contains':
if (typeof value !== 'string' || !value.includes(String(condition.value))) return false;
break;
case 'not_contains':
if (typeof value === 'string' && value.includes(String(condition.value))) return false;
break;
case 'gt':
if (!(Number(value) > Number(condition.value))) return false;
break;
case 'lt':
if (!(Number(value) < Number(condition.value))) return false;
break;
default:
return false;
}
}
return true;
}
/**
* Evaluate step condition.
*/
private evaluateStepCondition(condition: StepCondition, context: WorkflowStepContext): boolean {
const value = this.getNestedValue(context, condition.field);
switch (condition.operator) {
case 'equals':
return value === condition.value;
case 'not_equals':
return value !== condition.value;
case 'in':
return Array.isArray(condition.value) && condition.value.includes(value);
case 'not_in':
return Array.isArray(condition.value) && !condition.value.includes(value);
case 'contains':
if (typeof value === 'string') {
return value.includes(String(condition.value));
}
if (Array.isArray(value)) {
return value.some(v => String(v).includes(String(condition.value)));
}
return false;
case 'not_contains':
if (typeof value === 'string') {
return !value.includes(String(condition.value));
}
return true;
case 'gt':
return Number(value) > Number(condition.value);
case 'lt':
return Number(value) < Number(condition.value);
case 'is_null':
return value === null || value === undefined;
case 'is_not_null':
return value !== null && value !== undefined;
default:
return false;
}
}
/**
* Resolve template variables in config (supports {{context.field}} syntax).
*/
private resolveTemplates(value: any, context: WorkflowStepContext): 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: WorkflowStepContext): string {
return template.replace(/\{\{([^}]+)\}\}/g, (match, path: string) => {
const trimmedPath = path.trim();
// Handle special "settings.*" paths
if (trimmedPath.startsWith('settings.')) {
const settingKey = trimmedPath.substring('settings.'.length);
const value = (context._settings as any)[settingKey];
return value !== undefined && value !== null ? String(value) : '';
}
// Handle "context.*" paths
if (trimmedPath.startsWith('context.')) {
const contextKey = trimmedPath.substring('context.'.length);
const value = this.getNestedValue(context, contextKey);
return value !== undefined && value !== null ? String(value) : '';
}
// Direct context access
const value = this.getNestedValue(context, trimmedPath);
return value !== undefined && value !== null ? 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;
}
/**
* Log step execution to database.
*/
private async logStepExecution(
executionId: number,
step: TicketWorkflowStep,
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped',
inputData: any,
outputData: any,
durationMs: number,
errorMessage?: string | null
): Promise<void> {
if (status === 'running') {
await postgresClient.query(
`INSERT INTO ticket_workflow_execution_steps
(execution_id, step_order, step_type, step_name, status, started_at, input_data)
VALUES ($1, $2, $3, $4, $5, NOW(), $6)`,
[executionId, step.step_order, step.step_type, step.name, status, JSON.stringify(inputData)]
);
} else {
await postgresClient.query(
`UPDATE ticket_workflow_execution_steps
SET status = $1, output_data = $2, completed_at = NOW(), duration_ms = $3, error_message = $4
WHERE execution_id = $5 AND step_order = $6`,
[status, JSON.stringify(outputData), durationMs, errorMessage, executionId, step.step_order]
);
}
}
}
// Export singleton instance
export const ticketWorkflowEngine = new TicketWorkflowEngine();