/** * Workflow Engine * Orchestrates the full ticket triage pipeline: * 1. Check if enabled * 2. Run exclusion filter rules * 3. Run robotic classification * 4. Validate classification * 5. If needed → AI enhancement * 6. Re-validate after AI * 7. Delay (configurable) * 8. Write back to Autotask * 9. Create notes if applicable * 10. Log full execution */ import { postgresClient } from './postgres-client'; import { AutotaskClient } from './autotask-client'; import { roboticClassifier } from './robotic-classifier'; import { triageValidator } from './triage-validator'; import { aiTriageService } from './ai-triage-service'; import { WorkflowEvent, ExecutionResult, WorkflowSettings, TicketData, ClassificationResult, FieldChanges, ExecutionStepSummary, WorkflowRuleWithDetails, WorkflowCondition, StepName, ExecutionStatus, StepMethod, ConfidenceLevel, Branch, ClassificationMethod, } from '../types/workflow'; export class WorkflowEngine { private _autotaskClient: AutotaskClient | null = null; private getAutotaskClient(): AutotaskClient { if (!this._autotaskClient) { this._autotaskClient = new AutotaskClient({ apiUrl: process.env.AUTOTASK_API_URL || '', username: process.env.AUTOTASK_USERNAME || '', password: process.env.AUTOTASK_SECRET || '', apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '', }); } return this._autotaskClient; } /** * 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, }; } /** * Main entry point: process a workflow event. */ async process(event: WorkflowEvent): Promise { const startTime = Date.now(); const steps: ExecutionStepSummary[] = []; const fieldChanges: FieldChanges = {}; let executionId: number = 0; let classificationMethod: ClassificationMethod = 'robotic'; let branch: Branch = 'service_desk'; try { // 1. Check if engine is enabled const settings = await this.getSettings(); if (!settings.workflow_engine_enabled) { console.log('[WORKFLOW] Engine is disabled, skipping'); return this.buildSkippedResult(0, 'Engine disabled'); } // Create execution record executionId = await this.createExecution(event); // 2. Load ticket data const ticket = event.ticket_data || await this.loadTicketData(event.entity_id); if (!ticket) { await this.updateExecution(executionId, 'failed', null, null, 'Ticket not found'); return this.buildSkippedResult(executionId, 'Ticket not found'); } // 3. Run exclusion filter rules const filterStep = await this.runFilterRules(ticket, event.trigger_event, executionId); steps.push(filterStep); if (filterStep.status === 'completed' && filterStep.method === 'skipped') { await this.updateExecution(executionId, 'skipped', null, null); return { execution_id: executionId, status: 'skipped', classification_method: 'robotic', branch: 'service_desk', field_changes: {}, steps, duration_ms: Date.now() - startTime, }; } // 4. Run robotic classification const classifyStart = Date.now(); const classification = await roboticClassifier.classify(ticket); branch = (classification.branch?.value as Branch) || 'service_desk'; // Log classification steps const classificationSteps = this.buildClassificationSteps(classification, executionId, classifyStart); steps.push(...classificationSteps); // 5. Validate classification const validationStep = await this.runValidation(classification, executionId); steps.push(validationStep); // 6. AI Enhancement (if needed) let finalClassification = classification; const confidenceThreshold = settings.classification_confidence_threshold; if ( classification.needs_ai && settings.ai_for_ambiguous_classification && this.shouldUseAi(classification.overall_confidence, confidenceThreshold) ) { classificationMethod = 'hybrid'; // AI for ambiguous classification const failedFields = classification.ai_reasons .filter(r => r.includes('No ') && r.includes('matched')) .map(r => { if (r.includes('ticket type')) return 'ticket_type'; if (r.includes('issue type')) return 'issue_type'; if (r.includes('priority')) return 'priority'; return ''; }) .filter(Boolean); if (failedFields.length > 0) { const aiClassifyStep = await this.runAiClassification( ticket, failedFields, finalClassification, settings, executionId ); steps.push(aiClassifyStep); } } // AI for title cleanup if ( settings.ai_for_title_cleanup && classification.ai_reasons.some(r => r.includes('Title')) ) { classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod; const titleStep = await this.runAiTitleCleanup(ticket, settings, executionId); steps.push(titleStep); if (titleStep.status === 'completed' && (titleStep as any)._newTitle) { fieldChanges['title'] = { before: ticket.title, after: (titleStep as any)._newTitle }; } } // 7. Build field changes for write-back this.buildFieldChanges(ticket, finalClassification, fieldChanges); // 8. Delay before Autotask write-back if (Object.keys(fieldChanges).length > 0 && settings.autotask_update_delay_ms > 0) { await this.delay(settings.autotask_update_delay_ms); } // 9. Write back to Autotask if (Object.keys(fieldChanges).length > 0) { const updateStep = await this.writeBackToAutotask( ticket.id, fieldChanges, executionId ); steps.push(updateStep); } // 10. Create troubleshooting note for incidents const ticketTypeValue = finalClassification.ticket_type?.value ?? ticket.ticket_type; if ( Number(ticketTypeValue) === 2 && settings.ai_for_troubleshooting ) { classificationMethod = classificationMethod === 'robotic' ? 'hybrid' : classificationMethod; const noteStep = await this.runAiTroubleshooting(ticket, settings, executionId); steps.push(noteStep); } // 11. Update execution record const duration = Date.now() - startTime; await this.updateExecution(executionId, 'completed', classificationMethod, branch); console.log( `[WORKFLOW] Completed processing ticket #${ticket.ticket_number} ` + `(${classificationMethod}, ${branch}) in ${duration}ms` ); return { execution_id: executionId, status: 'completed', classification_method: classificationMethod, branch, field_changes: fieldChanges, steps, duration_ms: duration, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error('[WORKFLOW] Pipeline error:', errorMessage); if (executionId) { await this.updateExecution(executionId, 'failed', classificationMethod, branch, errorMessage); } return { execution_id: executionId, status: 'failed', classification_method: classificationMethod, branch, field_changes: fieldChanges, steps, duration_ms: Date.now() - startTime, error: errorMessage, }; } } /** * Dry-run: process a ticket without writing back to Autotask. */ async dryRun(ticketId: number): Promise { const settings = await this.getSettings(); const ticket = await this.loadTicketData(ticketId); if (!ticket) { return this.buildSkippedResult(0, 'Ticket not found'); } const startTime = Date.now(); const steps: ExecutionStepSummary[] = []; // Run filter rules const filterStep = await this.runFilterRules(ticket, 'ticket.created', 0); steps.push(filterStep); // Run classification const classification = await roboticClassifier.classify(ticket); const branch = (classification.branch?.value as Branch) || 'service_desk'; const classificationSteps = this.buildClassificationSteps(classification, 0, Date.now()); steps.push(...classificationSteps); // Run validation const validation = await triageValidator.validate(classification); steps.push({ step_name: 'validation', status: validation.is_valid ? 'completed' : 'failed', method: 'robotic', duration_ms: 0, }); // Build proposed field changes const fieldChanges: FieldChanges = {}; this.buildFieldChanges(ticket, classification, fieldChanges); return { execution_id: 0, status: filterStep.method === 'skipped' ? 'skipped' : 'completed', classification_method: 'robotic', branch, field_changes: fieldChanges, steps, duration_ms: Date.now() - startTime, }; } // ============================================================================ // Pipeline Steps // ============================================================================ /** * Run workflow filter rules (exclusion/inclusion). */ private async runFilterRules( ticket: TicketData, triggerEvent: string, executionId: number ): Promise { const stepStart = Date.now(); try { const rules = await this.loadWorkflowRules(triggerEvent); for (const rule of rules) { if (this.evaluateConditions(rule.conditions, ticket)) { // Check if any action is 'skip' const skipAction = rule.actions.find(a => a.action_type === 'skip'); if (skipAction) { const reason = skipAction.config?.reason || rule.name; console.log(`[WORKFLOW] Ticket #${ticket.ticket_number} skipped: ${reason}`); if (executionId > 0) { await this.logStep(executionId, 'filter', 1, 'completed', 'skipped', { rule_name: rule.name, reason, }); } return { step_name: 'filter', status: 'completed', method: 'skipped', matched_rule: rule.name, duration_ms: Date.now() - stepStart, }; } if (rule.stop_processing) break; } } if (executionId > 0) { await this.logStep(executionId, 'filter', 1, 'completed', 'robotic', { result: 'passed_all_filters', }); } return { step_name: 'filter', status: 'completed', method: 'robotic', duration_ms: Date.now() - stepStart, }; } catch (error) { console.error('[WORKFLOW] Filter rules error:', error); return { step_name: 'filter', status: 'failed', method: 'robotic', duration_ms: Date.now() - stepStart, }; } } /** * Build step summaries from classification result. */ private buildClassificationSteps( classification: ClassificationResult, executionId: number, startTime: number ): ExecutionStepSummary[] { const steps: ExecutionStepSummary[] = []; const entries: [StepName, any][] = [ ['branch_routing', classification.branch], ['ticket_type', classification.ticket_type], ['issue_classification', classification.issue_classification], ['priority', classification.priority], ['queue_routing', classification.queue], ]; let order = 2; for (const [stepName, result] of entries) { steps.push({ step_name: stepName, status: result ? 'completed' : 'completed', method: result?.method || 'robotic', confidence: result?.confidence, matched_rule: result?.matched_rule_name || (result ? undefined : 'No match'), duration_ms: 0, }); if (executionId > 0 && result) { this.logStep(executionId, stepName, order, 'completed', result.method, { field: result.field, value: result.value, confidence: result.confidence, matched_rule: result.matched_rule_name, }, result.matched_rule_id).catch(err => console.error(`[WORKFLOW] Failed to log step ${stepName}:`, err) ); } order++; } return steps; } /** * Run validation step. */ private async runValidation( classification: ClassificationResult, executionId: number ): Promise { const stepStart = Date.now(); const validation = await triageValidator.validate(classification); if (executionId > 0) { await this.logStep(executionId, 'validation', 7, validation.is_valid ? 'completed' : 'failed', 'robotic', { is_valid: validation.is_valid, errors: validation.errors, }); } return { step_name: 'validation', status: validation.is_valid ? 'completed' : 'failed', method: 'robotic', duration_ms: Date.now() - stepStart, }; } /** * Run AI classification for ambiguous fields. */ private async runAiClassification( ticket: TicketData, failedFields: string[], classification: ClassificationResult, settings: WorkflowSettings, executionId: number ): Promise { const stepStart = Date.now(); try { const aiResult = await aiTriageService.classifyAmbiguous(ticket, failedFields, settings); // Merge AI results into classification if (aiResult.classification) { if (aiResult.classification.issue_type != null && !classification.issue_classification) { classification.issue_classification = { field: 'issue_type', value: aiResult.classification.issue_type, field_2: 'sub_issue_type', value_2: aiResult.classification.sub_issue_type, confidence: 'medium', matched_rule_id: null, matched_rule_name: 'AI classification', method: 'ai', }; } if (aiResult.classification.ticket_type != null && !classification.ticket_type) { classification.ticket_type = { field: 'ticket_type', value: aiResult.classification.ticket_type, confidence: 'medium', matched_rule_id: null, matched_rule_name: 'AI classification', method: 'ai', }; } if (aiResult.classification.priority != null && !classification.priority) { classification.priority = { field: 'priority', value: aiResult.classification.priority, confidence: 'medium', matched_rule_id: null, matched_rule_name: 'AI classification', method: 'ai', }; } } if (executionId > 0) { await this.logStep(executionId, 'ai_classification', 8, 'completed', 'ai', { failed_fields: failedFields, ai_result: aiResult.classification, }); } return { step_name: 'ai_classification', status: 'completed', method: 'ai', duration_ms: Date.now() - stepStart, }; } catch (error) { console.error('[WORKFLOW] AI classification error:', error); if (executionId > 0) { await this.logStep(executionId, 'ai_classification', 8, 'failed', 'ai', { error: error instanceof Error ? error.message : String(error), }); } return { step_name: 'ai_classification', status: 'failed', method: 'ai', duration_ms: Date.now() - stepStart, }; } } /** * Run AI title cleanup. */ private async runAiTitleCleanup( ticket: TicketData, settings: WorkflowSettings, executionId: number ): Promise { const stepStart = Date.now(); try { const newTitle = await aiTriageService.cleanupTitle(ticket, settings); if (executionId > 0) { await this.logStep(executionId, 'ai_title', 9, 'completed', 'ai', { original: ticket.title, cleaned: newTitle, }); } return { step_name: 'ai_title', status: 'completed', method: 'ai', duration_ms: Date.now() - stepStart, _newTitle: newTitle, }; } catch (error) { console.error('[WORKFLOW] AI title cleanup error:', error); return { step_name: 'ai_title', status: 'failed', method: 'ai', duration_ms: Date.now() - stepStart, }; } } /** * Run AI troubleshooting steps generation and create a ticket note. */ private async runAiTroubleshooting( ticket: TicketData, settings: WorkflowSettings, executionId: number ): Promise { const stepStart = Date.now(); try { const steps = await aiTriageService.generateTroubleshootingSteps(ticket, settings); if (!steps) { return { step_name: 'ai_troubleshooting', status: 'completed', method: 'skipped', duration_ms: Date.now() - stepStart, }; } // Create ticket note in Autotask const client = this.getAutotaskClient(); await client.createEntity('TicketNotes', { ticketID: ticket.id, title: 'Troubleshooting Steps (Auto-Generated)', description: steps, noteType: 1, // Internal publish: 1, }); if (executionId > 0) { await this.logStep(executionId, 'create_note', 12, 'completed', 'ai', { note_type: 'troubleshooting_steps', content_length: steps.length, }); } return { step_name: 'create_note', status: 'completed', method: 'ai', duration_ms: Date.now() - stepStart, }; } catch (error) { console.error('[WORKFLOW] Troubleshooting note error:', error); return { step_name: 'create_note', status: 'failed', method: 'ai', duration_ms: Date.now() - stepStart, }; } } /** * Write classified fields back to Autotask. */ private async writeBackToAutotask( ticketId: number, fieldChanges: FieldChanges, executionId: number ): Promise { const stepStart = Date.now(); try { // Build Autotask update payload (camelCase field names) const updatePayload: Record = { id: ticketId }; const fieldMap: Record = { ticket_type: 'ticketType', issue_type: 'issueType', sub_issue_type: 'subIssueType', priority: 'priority', queue_id: 'queueID', title: 'title', }; for (const [field, change] of Object.entries(fieldChanges)) { const autotaskField = fieldMap[field] || field; updatePayload[autotaskField] = change.after; } const client = this.getAutotaskClient(); await client.updateTicket(ticketId, updatePayload); // Also update local DB const dbUpdates: Record = {}; for (const [field, change] of Object.entries(fieldChanges)) { dbUpdates[field] = change.after; } if (Object.keys(dbUpdates).length > 0) { const setClauses = Object.keys(dbUpdates).map((k, i) => `${k} = $${i + 2}`); await postgresClient.query( `UPDATE tickets SET ${setClauses.join(', ')}, updated_at = NOW() WHERE id = $1`, [ticketId, ...Object.values(dbUpdates)] ); } if (executionId > 0) { await this.logStep(executionId, 'autotask_update', 11, 'completed', 'robotic', { fields_updated: Object.keys(fieldChanges), }); } console.log(`[WORKFLOW] Updated ticket ${ticketId} in Autotask: ${Object.keys(fieldChanges).join(', ')}`); return { step_name: 'autotask_update', status: 'completed', method: 'robotic', duration_ms: Date.now() - stepStart, }; } catch (error) { console.error('[WORKFLOW] Autotask update error:', error); if (executionId > 0) { await this.logStep(executionId, 'autotask_update', 11, 'failed', 'robotic', { error: error instanceof Error ? error.message : String(error), }); } return { step_name: 'autotask_update', status: 'failed', method: 'robotic', duration_ms: Date.now() - stepStart, }; } } // ============================================================================ // Helpers // ============================================================================ /** * Load ticket data from DB. */ private async loadTicketData(ticketId: number): Promise { const result = await postgresClient.query( `SELECT id, ticket_number, title, description, ticket_category, ticket_type, priority, queue_id, issue_type, sub_issue_type, company_id, contact_id, assigned_resource_id, status, source FROM tickets WHERE id = $1`, [ticketId] ); if (result.rows.length === 0) return null; const row = result.rows[0]; return { ...row, // Try to extract device/policy info from title/description for queue routing device_name: this.extractDeviceName(row.title, row.description), policy_name: this.extractPolicyName(row.description), creator_resource_id: null, // Not stored on tickets table — comes from webhook person_id: null, }; } /** * Extract device name patterns from text (e.g., DT-1234, LT-5678). */ private extractDeviceName(title: string | null, description: string | null): string | null { const text = [title, description].filter(Boolean).join(' '); const match = text.match(/\b(DT|LT|SP|SRV|VM)-?\d{2,}/i); return match ? match[0] : null; } /** * Extract policy name from description (e.g., "Policy: Windows Workstation"). */ private extractPolicyName(description: string | null): string | null { if (!description) return null; const match = description.match(/(?:policy|monitoring policy)[:\s]+([^\n]+)/i); return match ? match[1].trim() : null; } /** * Load workflow filter rules with conditions and actions. */ private async loadWorkflowRules(triggerEvent: string): Promise { const rulesResult = await postgresClient.query( `SELECT * FROM workflow_rules WHERE is_active = true AND trigger_event = $1 ORDER BY sort_order`, [triggerEvent] ); const rules: WorkflowRuleWithDetails[] = []; for (const rule of rulesResult.rows) { const [conditionsResult, actionsResult] = await Promise.all([ postgresClient.query(`SELECT * FROM workflow_conditions WHERE rule_id = $1`, [rule.id]), postgresClient.query(`SELECT * FROM workflow_actions WHERE rule_id = $1 ORDER BY sort_order`, [rule.id]), ]); rules.push({ ...rule, conditions: conditionsResult.rows, actions: actionsResult.rows, }); } return rules; } /** * Evaluate workflow conditions against ticket data. * AND within condition_group, OR between groups. */ private evaluateConditions(conditions: WorkflowCondition[], ticket: TicketData): boolean { if (conditions.length === 0) return true; // Group conditions by condition_group const groups = new Map(); for (const cond of conditions) { const group = groups.get(cond.condition_group) || []; group.push(cond); groups.set(cond.condition_group, group); } // OR between groups: at least one group must pass for (const [, groupConditions] of groups) { const groupPasses = groupConditions.every(cond => this.evaluateCondition(cond, ticket)); if (groupPasses) return true; } return false; } /** * Evaluate a single workflow condition. */ private evaluateCondition(condition: WorkflowCondition, ticket: TicketData): boolean { const fieldValue = this.getTicketFieldValue(condition.field, ticket); switch (condition.operator) { case 'equals': return this.isEqual(fieldValue, condition.value); case 'not_equals': return !this.isEqual(fieldValue, condition.value); case 'in': return this.isIn(fieldValue, condition.value); case 'not_in': return !this.isIn(fieldValue, condition.value); case 'contains': return fieldValue != null && String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase()); case 'not_contains': return fieldValue == null || !String(fieldValue).toLowerCase().includes(String(condition.value).toLowerCase()); case 'regex': try { return fieldValue != null && new RegExp(String(condition.value), 'i').test(String(fieldValue)); } catch { return false; } case 'gt': return fieldValue != null && Number(fieldValue) > Number(condition.value); case 'lt': return fieldValue != null && Number(fieldValue) < Number(condition.value); case 'is_null': return fieldValue == null; case 'is_not_null': return fieldValue != null; default: return false; } } private getTicketFieldValue(field: string, ticket: TicketData): any { return (ticket as any)[field] ?? null; } private isEqual(a: any, b: any): boolean { if (a == null && b == null) return true; if (a == null || b == null) return false; return String(a) === String(b); } private isIn(value: any, list: any): boolean { if (value == null) return false; const arr = Array.isArray(list) ? list : [list]; return arr.some(v => String(v) === String(value)); } /** * Build field changes from classification result. */ private buildFieldChanges( ticket: TicketData, classification: ClassificationResult, fieldChanges: FieldChanges ): void { if (classification.ticket_type?.value != null && classification.ticket_type.value !== ticket.ticket_type) { fieldChanges['ticket_type'] = { before: ticket.ticket_type, after: Number(classification.ticket_type.value) }; } if (classification.issue_classification?.value != null && Number(classification.issue_classification.value) !== ticket.issue_type) { fieldChanges['issue_type'] = { before: ticket.issue_type, after: Number(classification.issue_classification.value) }; } if (classification.issue_classification?.value_2 != null && Number(classification.issue_classification.value_2) !== ticket.sub_issue_type) { fieldChanges['sub_issue_type'] = { before: ticket.sub_issue_type, after: Number(classification.issue_classification.value_2) }; } if (classification.priority?.value != null && Number(classification.priority.value) !== ticket.priority) { fieldChanges['priority'] = { before: ticket.priority, after: Number(classification.priority.value) }; } if (classification.queue?.value != null && Number(classification.queue.value) !== ticket.queue_id) { fieldChanges['queue_id'] = { before: ticket.queue_id, after: Number(classification.queue.value) }; } } /** * Determine if AI should be used based on confidence. */ private shouldUseAi(confidence: ConfidenceLevel, threshold: ConfidenceLevel): boolean { const levels: Record = { high: 3, medium: 2, low: 1 }; return levels[confidence] < levels[threshold]; } /** * Create a workflow execution record. */ private async createExecution(event: WorkflowEvent): Promise { const result = await postgresClient.query( `INSERT INTO workflow_executions (trigger_event, entity_type, entity_id, ticket_number, status) VALUES ($1, $2, $3, $4, 'running') RETURNING id`, [event.trigger_event, event.entity_type, event.entity_id, event.ticket_number || null] ); return result.rows[0].id; } /** * Update execution status. */ private async updateExecution( executionId: number, status: ExecutionStatus, method: ClassificationMethod | null, branch: Branch | null, errorMessage?: string ): Promise { await postgresClient.query( `UPDATE workflow_executions SET status = $1, classification_method = $2, branch = $3, completed_at = NOW(), duration_ms = EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000, error_message = $4 WHERE id = $5`, [status, method, branch, errorMessage || null, executionId] ); } /** * Log an execution step. */ private async logStep( executionId: number, stepName: string, stepOrder: number, status: ExecutionStatus, method: StepMethod, outputData?: Record, classificationRuleId?: number | null ): Promise { await postgresClient.query( `INSERT INTO workflow_execution_steps (execution_id, step_name, step_order, status, method, output_data, classification_rule_id, completed_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, [executionId, stepName, stepOrder, status, method, outputData ? JSON.stringify(outputData) : null, classificationRuleId || null] ); } /** * Build a skipped execution result. */ private buildSkippedResult(executionId: number, reason: string): ExecutionResult { return { execution_id: executionId, status: 'skipped', classification_method: 'robotic', branch: 'service_desk', field_changes: {}, steps: [], duration_ms: 0, error: reason, }; } /** * Delay for a specified number of milliseconds. */ private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } } // Export singleton instance export const workflowEngine = new WorkflowEngine();