| |
| |
| |
| |
| |
| |
| import { type LLMGateway } from '@wfo/integrations/llm-providers/index'; |
| import type { ValidationReport, SimulationReport, N8nWorkflow, WorkflowGraph } from '../types/workflow'; |
| import { isValidNodeType } from '../knowledge/nodeRegistry'; |
|
|
| export interface HealingAction { |
| type: 'replace_node' | 'fix_expression' | 'add_parameter' | 'fix_connection' | 'add_retry' | 'remove_node'; |
| targetNodeId?: string; |
| description: string; |
| applied: boolean; |
| before?: unknown; |
| after?: unknown; |
| } |
|
|
| export interface HealingResult { |
| healed: boolean; |
| actionsApplied: HealingAction[]; |
| remainingIssues: string[]; |
| healedWorkflow?: N8nWorkflow; |
| confidence: number; |
| } |
|
|
| export interface FailurePattern { |
| id: string; |
| pattern: string; |
| rootCause: string; |
| fixStrategy: string; |
| occurrences: number; |
| lastSeen: string; |
| } |
|
|
| |
| const failureMemory: Map<string, FailurePattern> = new Map(); |
|
|
| export class SelfHealingSystem { |
| private llm: LLMGateway; |
|
|
| constructor(llm: LLMGateway) { |
| this.llm = llm; |
| } |
|
|
| |
| |
| |
| async heal( |
| workflow: N8nWorkflow, |
| graph: WorkflowGraph, |
| validationReport: ValidationReport, |
| simulationReport?: SimulationReport, |
| ): Promise<HealingResult> { |
| const actions: HealingAction[] = []; |
| let healedWorkflow = structuredClone(workflow); |
|
|
| |
| const ruleBasedActions = this.applyRuleBasedFixes(healedWorkflow, validationReport); |
| actions.push(...ruleBasedActions.actions); |
| healedWorkflow = ruleBasedActions.workflow; |
|
|
| |
| const complexIssues = validationReport.issues.filter((i) => |
| i.severity === 'error' && |
| !actions.some((a) => a.targetNodeId === i.nodeId && a.applied), |
| ); |
|
|
| if (complexIssues.length > 0) { |
| try { |
| const aiHealing = await this.applyAIHealing(healedWorkflow, graph, complexIssues, simulationReport); |
| actions.push(...aiHealing.actions); |
| if (aiHealing.healedWorkflow) { |
| healedWorkflow = aiHealing.healedWorkflow; |
| } |
| } catch (err) { |
| console.warn('[SelfHealing] AI healing phase failed:', err); |
| } |
| } |
|
|
| |
| this.recordFailurePattern(validationReport, simulationReport, actions); |
|
|
| const appliedCount = actions.filter((a) => a.applied).length; |
| const remainingErrors = validationReport.issues.filter((i) => |
| i.severity === 'error' && |
| !actions.some((a) => a.targetNodeId === i.nodeId && a.applied), |
| ); |
|
|
| return { |
| healed: appliedCount > 0 && remainingErrors.length === 0, |
| actionsApplied: actions.filter((a) => a.applied), |
| remainingIssues: remainingErrors.map((i) => i.message), |
| healedWorkflow: appliedCount > 0 ? healedWorkflow : undefined, |
| confidence: Math.round((appliedCount / Math.max(1, actions.length)) * 100), |
| }; |
| } |
|
|
| |
| private applyRuleBasedFixes( |
| workflow: N8nWorkflow, |
| report: ValidationReport, |
| ): { workflow: N8nWorkflow; actions: HealingAction[] } { |
| const actions: HealingAction[] = []; |
|
|
| |
| if ((workflow.active as any) === true) { |
| workflow.active = false; |
| actions.push({ |
| type: 'fix_expression', |
| description: 'Set active: false (safety rule β never auto-activate)', |
| applied: true, |
| before: true, |
| after: false, |
| }); |
| } |
|
|
| |
| workflow.nodes.forEach((node) => { |
| if (!node.id || node.id.trim() === '') { |
| const newId = generateId(); |
| actions.push({ |
| type: 'add_parameter', |
| targetNodeId: node.name, |
| description: `Generated missing ID for node "${node.name}"`, |
| applied: true, |
| before: node.id, |
| after: newId, |
| }); |
| node.id = newId; |
| } |
| }); |
|
|
| |
| workflow.nodes.forEach((node) => { |
| if (!node.typeVersion || node.typeVersion < 1) { |
| actions.push({ |
| type: 'add_parameter', |
| targetNodeId: node.id, |
| description: `Added missing typeVersion: 1 to node "${node.name}"`, |
| applied: true, |
| before: node.typeVersion, |
| after: 1, |
| }); |
| node.typeVersion = 1; |
| } |
| }); |
|
|
| |
| workflow.nodes.forEach((node, i) => { |
| if (!node.position || !Array.isArray(node.position) || node.position.length !== 2) { |
| const newPos: [number, number] = [i * 220, 300]; |
| actions.push({ |
| type: 'fix_expression', |
| targetNodeId: node.id, |
| description: `Set default position for node "${node.name}"`, |
| applied: true, |
| before: node.position, |
| after: newPos, |
| }); |
| node.position = newPos; |
| } |
| }); |
|
|
| |
| workflow.nodes.forEach((node) => { |
| const externalTypes = [ |
| 'n8n-nodes-base.httpRequest', |
| 'n8n-nodes-base.telegram', |
| 'n8n-nodes-base.slack', |
| 'n8n-nodes-base.gmail', |
| 'n8n-nodes-base.openAi', |
| '@n8n/n8n-nodes-langchain.agent', |
| 'n8n-nodes-base.googleSheets', |
| 'n8n-nodes-base.airtable', |
| 'n8n-nodes-base.notion', |
| 'n8n-nodes-base.postgres', |
| 'n8n-nodes-base.hubspot', |
| 'n8n-nodes-base.stripe', |
| 'n8n-nodes-base.github', |
| ]; |
|
|
| if (externalTypes.includes(node.type) && !node.retryOnFail) { |
| actions.push({ |
| type: 'add_retry', |
| targetNodeId: node.id, |
| description: `Added retry policy to external node "${node.name}"`, |
| applied: true, |
| before: { retryOnFail: false }, |
| after: { retryOnFail: true, maxTries: 3, waitBetweenTries: 1000 }, |
| }); |
| node.retryOnFail = true; |
| node.maxTries = node.maxTries ?? 3; |
| node.waitBetweenTries = node.waitBetweenTries ?? 1000; |
| } |
| }); |
|
|
| |
| workflow.nodes.forEach((node) => { |
| if (!node.onError) { |
| node.onError = 'continueErrorOutput'; |
| actions.push({ |
| type: 'add_parameter', |
| targetNodeId: node.id, |
| description: `Set onError: continueErrorOutput for node "${node.name}"`, |
| applied: true, |
| before: undefined, |
| after: 'continueErrorOutput', |
| }); |
| } |
| }); |
|
|
| |
| const unknownNodes = workflow.nodes.filter((n) => n.type && !isValidNodeType(n.type)); |
| unknownNodes.forEach((node) => { |
| actions.push({ |
| type: 'remove_node', |
| targetNodeId: node.id, |
| description: `REJECTED unknown node type "${node.type}" for node "${node.name}" β not in registry`, |
| applied: true, |
| before: node.type, |
| after: null, |
| }); |
| }); |
| if (unknownNodes.length > 0) { |
| const unknownIds = new Set(unknownNodes.map((n) => n.id)); |
| workflow.nodes = workflow.nodes.filter((n) => !unknownIds.has(n.id)); |
| } |
|
|
| |
| if (!workflow.settings) { |
| workflow.settings = {}; |
| actions.push({ |
| type: 'add_parameter', |
| description: 'Added missing workflow settings object', |
| applied: true, |
| before: undefined, |
| after: {}, |
| }); |
| } |
| if (!workflow.settings.executionOrder) { |
| workflow.settings.executionOrder = 'v1'; |
| actions.push({ |
| type: 'add_parameter', |
| description: 'Set settings.executionOrder: v1', |
| applied: true, |
| }); |
| } |
|
|
| return { workflow, actions }; |
| } |
|
|
| |
| private async applyAIHealing( |
| workflow: N8nWorkflow, |
| graph: WorkflowGraph, |
| issues: Array<{ message: string; nodeId?: string; suggestion: string; category: string }>, |
| simulationReport?: SimulationReport, |
| ): Promise<{ actions: HealingAction[]; healedWorkflow?: N8nWorkflow }> { |
| |
| const historicalFixes = this.getRelevantHistoricalFixes(issues.map((i) => i.message)); |
|
|
| const result = await this.llm.completeJSON<{ |
| fixedWorkflow: N8nWorkflow; |
| actionsApplied: Array<{ nodeId?: string; description: string; type: string }>; |
| }>([ |
| { |
| role: 'system', |
| content: `You are an n8n workflow self-healing system. Your job is to fix broken workflows. |
| Fix ONLY the identified issues. Do not change anything else. |
| NEVER set active: true. |
| ALWAYS use real expressions like {{$json?.field ?? ""}}. |
| Return the complete fixed workflow JSON.`, |
| }, |
| { |
| role: 'user', |
| content: `Fix these workflow issues: |
| |
| CURRENT WORKFLOW: |
| ${JSON.stringify(workflow, null, 2)} |
| |
| ISSUES TO FIX: |
| ${issues.map((i, idx) => `${idx + 1}. [${i.category}] ${i.message}\n Fix: ${i.suggestion}`).join('\n')} |
| |
| ${simulationReport ? `SIMULATION FAILURES:\n${JSON.stringify(simulationReport.predictedFailurePoints, null, 2)}` : ''} |
| |
| ${historicalFixes.length > 0 ? `HISTORICAL FIX STRATEGIES:\n${historicalFixes.join('\n')}` : ''} |
| |
| Return JSON: |
| { |
| "fixedWorkflow": { ... complete fixed n8n workflow ... }, |
| "actionsApplied": [{ "nodeId": "...", "description": "what was fixed", "type": "fix_type" }] |
| }`, |
| }, |
| ], { |
| temperature: 0.0, |
| retries: 2, |
| }); |
|
|
| const actions: HealingAction[] = (result.actionsApplied ?? []).map((a) => ({ |
| type: a.type as HealingAction['type'], |
| targetNodeId: a.nodeId, |
| description: a.description, |
| applied: true, |
| })); |
|
|
| return { |
| actions, |
| healedWorkflow: result.fixedWorkflow ? { |
| ...result.fixedWorkflow, |
| active: false, |
| } : undefined, |
| }; |
| } |
|
|
| |
| private recordFailurePattern( |
| validation: ValidationReport, |
| simulation: SimulationReport | undefined, |
| actions: HealingAction[], |
| ): void { |
| validation.issues.forEach((issue) => { |
| const key = `${issue.category}-${issue.id}`; |
| const existing = failureMemory.get(key); |
| const action = actions.find((a) => a.targetNodeId === issue.nodeId); |
|
|
| if (existing) { |
| failureMemory.set(key, { |
| ...existing, |
| occurrences: existing.occurrences + 1, |
| lastSeen: new Date().toISOString(), |
| fixStrategy: action?.description ?? existing.fixStrategy, |
| }); |
| } else { |
| failureMemory.set(key, { |
| id: key, |
| pattern: issue.message, |
| rootCause: issue.category, |
| fixStrategy: action?.description ?? issue.suggestion, |
| occurrences: 1, |
| lastSeen: new Date().toISOString(), |
| }); |
| } |
| }); |
| } |
|
|
| private getRelevantHistoricalFixes(issueMessages: string[]): string[] { |
| const fixes: string[] = []; |
| failureMemory.forEach((pattern) => { |
| const isRelevant = issueMessages.some( |
| (msg) => msg.toLowerCase().includes(pattern.rootCause.toLowerCase()), |
| ); |
| if (isRelevant && pattern.occurrences > 1) { |
| fixes.push(`Pattern: "${pattern.pattern}" β Fix: "${pattern.fixStrategy}" (seen ${pattern.occurrences}x)`); |
| } |
| }); |
| return fixes.slice(0, 5); |
| } |
|
|
| |
| |
| |
| getMemoryStats(): { totalPatterns: number; topFailures: FailurePattern[] } { |
| const patterns = [...failureMemory.values()]; |
| const sorted = patterns.sort((a, b) => b.occurrences - a.occurrences); |
| return { |
| totalPatterns: patterns.length, |
| topFailures: sorted.slice(0, 10), |
| }; |
| } |
| } |
|
|
| function generateId(): string { |
| const hex = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0'); |
| return `${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`; |
| } |
|
|