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

954 lines
31 KiB
TypeScript

/**
* 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<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,
};
}
/**
* Main entry point: process a workflow event.
*/
async process(event: WorkflowEvent): Promise<ExecutionResult> {
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<ExecutionResult> {
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<ExecutionStepSummary> {
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<ExecutionStepSummary> {
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<ExecutionStepSummary> {
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<ExecutionStepSummary & { _newTitle?: string }> {
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<ExecutionStepSummary> {
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<ExecutionStepSummary> {
const stepStart = Date.now();
try {
// Build Autotask update payload (camelCase field names)
const updatePayload: Record<string, any> = { id: ticketId };
const fieldMap: Record<string, string> = {
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<string, any> = {};
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<TicketData | null> {
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<WorkflowRuleWithDetails[]> {
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<number, WorkflowCondition[]>();
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<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
return levels[confidence] < levels[threshold];
}
/**
* Create a workflow execution record.
*/
private async createExecution(event: WorkflowEvent): Promise<number> {
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<void> {
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<string, any>,
classificationRuleId?: number | null
): Promise<void> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Export singleton instance
export const workflowEngine = new WorkflowEngine();