503 lines
17 KiB
TypeScript
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();
|