/** * 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 = 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 { // 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 { const result = await postgresClient.query( `SELECT key, value FROM workflow_settings` ); const raw: Record = {}; 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 { const result = await postgresClient.query( `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( `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 { 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 { // Load workflow const workflowResult = await postgresClient.query( `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( `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( `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( `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 = {}; 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 { 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();