diff --git a/src/mcp/handlers-n8n-manager.ts b/src/mcp/handlers-n8n-manager.ts index 72b8f67..cb22f37 100644 --- a/src/mcp/handlers-n8n-manager.ts +++ b/src/mcp/handlers-n8n-manager.ts @@ -246,8 +246,8 @@ const autofixWorkflowSchema = z.object({ 'expression-format', 'typeversion-correction', 'error-output-config', - 'required-field', - 'enum-value' + 'node-type-correction', + 'webhook-missing-path' ])).optional(), confidenceThreshold: z.enum(['high', 'medium', 'low']).optional().default('medium'), maxFixes: z.number().optional().default(50) diff --git a/src/mcp/tool-docs/workflow_management/n8n-autofix-workflow.ts b/src/mcp/tool-docs/workflow_management/n8n-autofix-workflow.ts new file mode 100644 index 0000000..86ced15 --- /dev/null +++ b/src/mcp/tool-docs/workflow_management/n8n-autofix-workflow.ts @@ -0,0 +1,125 @@ +import { ToolDocumentation } from '../types'; + +export const n8nAutofixWorkflowDoc: ToolDocumentation = { + name: 'n8n_autofix_workflow', + category: 'workflow_management', + essentials: { + description: 'Automatically fix common workflow validation errors - expression formats, typeVersions, error outputs, webhook paths', + keyParameters: ['id', 'applyFixes'], + example: 'n8n_autofix_workflow({id: "wf_abc123", applyFixes: false})', + performance: 'Network-dependent (200-1000ms) - fetches, validates, and optionally updates workflow', + tips: [ + 'Use applyFixes: false to preview changes before applying', + 'Set confidenceThreshold to control fix aggressiveness (high/medium/low)', + 'Supports fixing expression formats, typeVersion issues, error outputs, node type corrections, and webhook paths', + 'High-confidence fixes (≥90%) are safe for auto-application' + ] + }, + full: { + description: `Automatically detects and fixes common workflow validation errors in n8n workflows. This tool: + +- Fetches the workflow from your n8n instance +- Runs comprehensive validation to detect issues +- Generates targeted fixes for common problems +- Optionally applies the fixes back to the workflow + +The auto-fixer can resolve: +1. **Expression Format Issues**: Missing '=' prefix in n8n expressions (e.g., {{ $json.field }} → ={{ $json.field }}) +2. **TypeVersion Corrections**: Downgrades nodes with unsupported typeVersions to maximum supported +3. **Error Output Configuration**: Removes conflicting onError settings when error connections are missing +4. **Node Type Corrections**: Intelligently fixes unknown node types using similarity matching: + - Handles deprecated package prefixes (n8n-nodes-base. → nodes-base.) + - Corrects capitalization mistakes (HttpRequest → httpRequest) + - Suggests correct packages (nodes-base.openai → nodes-langchain.openAi) + - Uses multi-factor scoring: name similarity, category match, package match, pattern match + - Only auto-fixes suggestions with ≥90% confidence + - Leverages NodeSimilarityService with 5-minute caching for performance +5. **Webhook Path Generation**: Automatically generates UUIDs for webhook nodes missing path configuration: + - Generates a unique UUID for webhook path + - Sets both 'path' parameter and 'webhookId' field to the same UUID + - Ensures webhook nodes become functional with valid endpoints + - High confidence fix as UUID generation is deterministic + +The tool uses a confidence-based system to ensure safe fixes: +- **High (≥90%)**: Safe to auto-apply (exact matches, known patterns) +- **Medium (70-89%)**: Generally safe but review recommended +- **Low (<70%)**: Manual review strongly recommended + +Requires N8N_API_URL and N8N_API_KEY environment variables to be configured.`, + parameters: { + id: { + type: 'string', + required: true, + description: 'The workflow ID to fix in your n8n instance' + }, + applyFixes: { + type: 'boolean', + required: false, + description: 'Whether to apply fixes to the workflow (default: false - preview mode). When false, returns proposed fixes without modifying the workflow.' + }, + fixTypes: { + type: 'array', + required: false, + description: 'Types of fixes to apply. Options: ["expression-format", "typeversion-correction", "error-output-config", "node-type-correction", "webhook-missing-path"]. Default: all types.' + }, + confidenceThreshold: { + type: 'string', + required: false, + description: 'Minimum confidence level for fixes: "high" (≥90%), "medium" (≥70%), "low" (any). Default: "medium".' + }, + maxFixes: { + type: 'number', + required: false, + description: 'Maximum number of fixes to apply (default: 50). Useful for limiting scope of changes.' + } + }, + returns: `AutoFixResult object containing: +- operations: Array of diff operations that will be/were applied +- fixes: Detailed list of individual fixes with before/after values +- summary: Human-readable summary of fixes +- stats: Statistics by fix type and confidence level +- applied: Boolean indicating if fixes were applied (when applyFixes: true)`, + examples: [ + 'n8n_autofix_workflow({id: "wf_abc123"}) - Preview all possible fixes', + 'n8n_autofix_workflow({id: "wf_abc123", applyFixes: true}) - Apply all medium+ confidence fixes', + 'n8n_autofix_workflow({id: "wf_abc123", applyFixes: true, confidenceThreshold: "high"}) - Only apply high-confidence fixes', + 'n8n_autofix_workflow({id: "wf_abc123", fixTypes: ["expression-format"]}) - Only fix expression format issues', + 'n8n_autofix_workflow({id: "wf_abc123", fixTypes: ["webhook-missing-path"]}) - Only fix webhook path issues', + 'n8n_autofix_workflow({id: "wf_abc123", applyFixes: true, maxFixes: 10}) - Apply up to 10 fixes' + ], + useCases: [ + 'Fixing workflows imported from older n8n versions', + 'Correcting expression syntax after manual edits', + 'Resolving typeVersion conflicts after n8n upgrades', + 'Cleaning up workflows before production deployment', + 'Batch fixing common issues across multiple workflows', + 'Migrating workflows between n8n instances with different versions', + 'Repairing webhook nodes that lost their path configuration' + ], + performance: 'Depends on workflow size and number of issues. Preview mode: 200-500ms. Apply mode: 500-1000ms for medium workflows. Node similarity matching is cached for 5 minutes for improved performance on repeated validations.', + bestPractices: [ + 'Always preview fixes first (applyFixes: false) before applying', + 'Start with high confidence threshold for production workflows', + 'Review the fix summary to understand what changed', + 'Test workflows after auto-fixing to ensure expected behavior', + 'Use fixTypes parameter to target specific issue categories', + 'Keep maxFixes reasonable to avoid too many changes at once' + ], + pitfalls: [ + 'Some fixes may change workflow behavior - always test after fixing', + 'Low confidence fixes might not be the intended solution', + 'Expression format fixes assume standard n8n syntax requirements', + 'Node type corrections only work for known node types in the database', + 'Cannot fix structural issues like missing nodes or invalid connections', + 'TypeVersion downgrades might remove node features added in newer versions', + 'Generated webhook paths are new UUIDs - existing webhook URLs will change' + ], + relatedTools: [ + 'n8n_validate_workflow', + 'validate_workflow', + 'n8n_update_partial_workflow', + 'validate_workflow_expressions', + 'validate_node_operation' + ] + } +}; \ No newline at end of file diff --git a/src/mcp/tools-n8n-manager.ts b/src/mcp/tools-n8n-manager.ts index 91680d3..058fb7f 100644 --- a/src/mcp/tools-n8n-manager.ts +++ b/src/mcp/tools-n8n-manager.ts @@ -270,6 +270,41 @@ export const n8nManagementTools: ToolDefinition[] = [ required: ['id'] } }, + { + name: 'n8n_autofix_workflow', + description: `Automatically fix common workflow validation errors. Preview fixes or apply them. Fixes expression format, typeVersion, error output config, webhook paths.`, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Workflow ID to fix' + }, + applyFixes: { + type: 'boolean', + description: 'Apply fixes to workflow (default: false - preview mode)' + }, + fixTypes: { + type: 'array', + description: 'Types of fixes to apply (default: all)', + items: { + type: 'string', + enum: ['expression-format', 'typeversion-correction', 'error-output-config', 'node-type-correction', 'webhook-missing-path'] + } + }, + confidenceThreshold: { + type: 'string', + enum: ['high', 'medium', 'low'], + description: 'Minimum confidence level for fixes (default: medium)' + }, + maxFixes: { + type: 'number', + description: 'Maximum number of fixes to apply (default: 50)' + } + }, + required: ['id'] + } + }, // Execution Management Tools { diff --git a/src/services/node-similarity-service.ts b/src/services/node-similarity-service.ts index 0cb1d35..3cde04b 100644 --- a/src/services/node-similarity-service.ts +++ b/src/services/node-similarity-service.ts @@ -19,18 +19,25 @@ export interface SimilarityScore { } export interface CommonMistakePattern { - pattern: RegExp | string; + pattern: string; suggestion: string; confidence: number; reason: string; } export class NodeSimilarityService { + // Constants to avoid magic numbers + private static readonly SCORING_THRESHOLD = 50; // Minimum 50% confidence to suggest + private static readonly TYPO_EDIT_DISTANCE = 2; // Max 2 character differences for typo detection + private static readonly SHORT_SEARCH_LENGTH = 5; // Searches ≤5 chars need special handling + private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes + private static readonly AUTO_FIX_CONFIDENCE = 0.9; // 90% confidence for auto-fix + private repository: NodeRepository; private commonMistakes: Map; private nodeCache: any[] | null = null; private cacheExpiry: number = 0; - private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + private cacheVersion: number = 0; // Track cache version for invalidation constructor(repository: NodeRepository) { this.repository = repository; @@ -39,53 +46,78 @@ export class NodeSimilarityService { /** * Initialize common mistake patterns + * Using safer string-based patterns instead of complex regex to avoid ReDoS */ private initializeCommonMistakes(): Map { const patterns = new Map(); - // Case variations + // Case variations - using exact string matching (case-insensitive) patterns.set('case_variations', [ - { pattern: /^HttpRequest$/i, suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Incorrect capitalization' }, - { pattern: /^HTTPRequest$/i, suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Common capitalization mistake' }, - { pattern: /^Webhook$/i, suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Incorrect capitalization' }, - { pattern: /^WebHook$/i, suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Common capitalization mistake' }, - { pattern: /^Slack$/i, suggestion: 'nodes-base.slack', confidence: 0.9, reason: 'Missing package prefix' }, - { pattern: /^Gmail$/i, suggestion: 'nodes-base.gmail', confidence: 0.9, reason: 'Missing package prefix' }, - { pattern: /^GoogleSheets$/i, suggestion: 'nodes-base.googleSheets', confidence: 0.9, reason: 'Missing package prefix' }, + { pattern: 'httprequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Incorrect capitalization' }, + { pattern: 'webhook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Incorrect capitalization' }, + { pattern: 'slack', suggestion: 'nodes-base.slack', confidence: 0.9, reason: 'Missing package prefix' }, + { pattern: 'gmail', suggestion: 'nodes-base.gmail', confidence: 0.9, reason: 'Missing package prefix' }, + { pattern: 'googlesheets', suggestion: 'nodes-base.googleSheets', confidence: 0.9, reason: 'Missing package prefix' }, + { pattern: 'telegram', suggestion: 'nodes-base.telegram', confidence: 0.9, reason: 'Missing package prefix' }, ]); - // Missing prefixes - patterns.set('missing_prefix', [ - { pattern: /^(httpRequest|webhook|slack|gmail|googleSheets|telegram|discord|notion|airtable|postgres|mysql|mongodb)$/i, - suggestion: '', confidence: 0.9, reason: 'Missing package prefix' }, + // Specific case variations that are common + patterns.set('specific_variations', [ + { pattern: 'HttpRequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Incorrect capitalization' }, + { pattern: 'HTTPRequest', suggestion: 'nodes-base.httpRequest', confidence: 0.95, reason: 'Common capitalization mistake' }, + { pattern: 'Webhook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Incorrect capitalization' }, + { pattern: 'WebHook', suggestion: 'nodes-base.webhook', confidence: 0.95, reason: 'Common capitalization mistake' }, ]); - // Old versions or deprecated names - patterns.set('deprecated', [ - { pattern: /^n8n-nodes-base\./i, suggestion: '', confidence: 0.95, reason: 'Full package name used instead of short form' }, - { pattern: /^@n8n\/n8n-nodes-langchain\./i, suggestion: '', confidence: 0.95, reason: 'Full package name used instead of short form' }, + // Deprecated package prefixes + patterns.set('deprecated_prefixes', [ + { pattern: 'n8n-nodes-base.', suggestion: 'nodes-base.', confidence: 0.95, reason: 'Full package name used instead of short form' }, + { pattern: '@n8n/n8n-nodes-langchain.', suggestion: 'nodes-langchain.', confidence: 0.95, reason: 'Full package name used instead of short form' }, ]); - // Common typos + // Common typos - exact matches patterns.set('typos', [ - { pattern: /^htpRequest$/i, suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, - { pattern: /^httpReqest$/i, suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, - { pattern: /^webook$/i, suggestion: 'nodes-base.webhook', confidence: 0.8, reason: 'Likely typo' }, - { pattern: /^slak$/i, suggestion: 'nodes-base.slack', confidence: 0.8, reason: 'Likely typo' }, - { pattern: /^goggleSheets$/i, suggestion: 'nodes-base.googleSheets', confidence: 0.8, reason: 'Likely typo' }, + { pattern: 'htprequest', suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, + { pattern: 'httpreqest', suggestion: 'nodes-base.httpRequest', confidence: 0.8, reason: 'Likely typo' }, + { pattern: 'webook', suggestion: 'nodes-base.webhook', confidence: 0.8, reason: 'Likely typo' }, + { pattern: 'slak', suggestion: 'nodes-base.slack', confidence: 0.8, reason: 'Likely typo' }, + { pattern: 'googlesheets', suggestion: 'nodes-base.googleSheets', confidence: 0.8, reason: 'Likely typo' }, ]); // AI/LangChain specific patterns.set('ai_nodes', [ - { pattern: /^openai$/i, suggestion: 'nodes-langchain.openAi', confidence: 0.85, reason: 'AI node - incorrect package' }, - { pattern: /^nodes-base\.openai$/i, suggestion: 'nodes-langchain.openAi', confidence: 0.9, reason: 'Wrong package - OpenAI is in LangChain package' }, - { pattern: /^chatOpenAI$/i, suggestion: 'nodes-langchain.lmChatOpenAi', confidence: 0.85, reason: 'LangChain node naming convention' }, - { pattern: /^vectorStore$/i, suggestion: 'nodes-langchain.vectorStoreInMemory', confidence: 0.7, reason: 'Generic vector store reference' }, + { pattern: 'openai', suggestion: 'nodes-langchain.openAi', confidence: 0.85, reason: 'AI node - incorrect package' }, + { pattern: 'nodes-base.openai', suggestion: 'nodes-langchain.openAi', confidence: 0.9, reason: 'Wrong package - OpenAI is in LangChain package' }, + { pattern: 'chatopenai', suggestion: 'nodes-langchain.lmChatOpenAi', confidence: 0.85, reason: 'LangChain node naming convention' }, + { pattern: 'vectorstore', suggestion: 'nodes-langchain.vectorStoreInMemory', confidence: 0.7, reason: 'Generic vector store reference' }, ]); return patterns; } + /** + * Check if a type is a common node name without prefix + */ + private isCommonNodeWithoutPrefix(type: string): string | null { + const commonNodes: Record = { + 'httprequest': 'nodes-base.httpRequest', + 'webhook': 'nodes-base.webhook', + 'slack': 'nodes-base.slack', + 'gmail': 'nodes-base.gmail', + 'googlesheets': 'nodes-base.googleSheets', + 'telegram': 'nodes-base.telegram', + 'discord': 'nodes-base.discord', + 'notion': 'nodes-base.notion', + 'airtable': 'nodes-base.airtable', + 'postgres': 'nodes-base.postgres', + 'mysql': 'nodes-base.mySql', + 'mongodb': 'nodes-base.mongoDb', + }; + + const normalized = type.toLowerCase(); + return commonNodes[normalized] || null; + } + /** * Find similar nodes for an invalid type */ @@ -120,7 +152,7 @@ export class NodeSimilarityService { continue; } - if (score.totalScore >= 50) { + if (score.totalScore >= NodeSimilarityService.SCORING_THRESHOLD) { suggestions.push(this.createSuggestion(node, score)); } @@ -133,38 +165,65 @@ export class NodeSimilarityService { } /** - * Check for common mistake patterns + * Check for common mistake patterns (ReDoS-safe implementation) */ private checkCommonMistakes(invalidType: string): NodeSuggestion | null { const cleanType = invalidType.trim(); + const lowerType = cleanType.toLowerCase(); - // Check each category of patterns + // First check for common nodes without prefix + const commonNodeSuggestion = this.isCommonNodeWithoutPrefix(cleanType); + if (commonNodeSuggestion) { + const node = this.repository.getNode(commonNodeSuggestion); + if (node) { + return { + nodeType: commonNodeSuggestion, + displayName: node.displayName, + confidence: 0.9, + reason: 'Missing package prefix', + category: node.category, + description: node.description + }; + } + } + + // Check deprecated prefixes (string-based, no regex) for (const [category, patterns] of this.commonMistakes) { - for (const pattern of patterns) { - let match = false; - let actualSuggestion = pattern.suggestion; - - if (pattern.pattern instanceof RegExp) { - match = pattern.pattern.test(cleanType); - } else { - match = cleanType === pattern.pattern; - } - - if (match) { - // Handle dynamic suggestions (e.g., missing prefix) - if (category === 'missing_prefix' && !actualSuggestion) { - actualSuggestion = `nodes-base.${cleanType}`; - } else if (category === 'deprecated' && !actualSuggestion) { - // Remove package prefix - actualSuggestion = cleanType.replace(/^n8n-nodes-base\./, 'nodes-base.') - .replace(/^@n8n\/n8n-nodes-langchain\./, 'nodes-langchain.'); + if (category === 'deprecated_prefixes') { + for (const pattern of patterns) { + if (cleanType.startsWith(pattern.pattern)) { + const actualSuggestion = cleanType.replace(pattern.pattern, pattern.suggestion); + const node = this.repository.getNode(actualSuggestion); + if (node) { + return { + nodeType: actualSuggestion, + displayName: node.displayName, + confidence: pattern.confidence, + reason: pattern.reason, + category: node.category, + description: node.description + }; + } } + } + } + } - // Verify the suggestion exists - const node = this.repository.getNode(actualSuggestion); + // Check exact matches for typos and variations + for (const [category, patterns] of this.commonMistakes) { + if (category === 'deprecated_prefixes') continue; // Already handled + + for (const pattern of patterns) { + // Simple string comparison (case-sensitive for specific_variations) + const match = category === 'specific_variations' + ? cleanType === pattern.pattern + : lowerType === pattern.pattern.toLowerCase(); + + if (match && pattern.suggestion) { + const node = this.repository.getNode(pattern.suggestion); if (node) { return { - nodeType: actualSuggestion, + nodeType: pattern.suggestion, displayName: node.displayName, confidence: pattern.confidence, reason: pattern.reason, @@ -188,7 +247,7 @@ export class NodeSimilarityService { const displayNameClean = this.normalizeNodeType(node.displayName); // Special handling for very short search terms (e.g., "http", "sheet") - const isShortSearch = invalidType.length <= 5; + const isShortSearch = invalidType.length <= NodeSimilarityService.SHORT_SEARCH_LENGTH; // Name similarity (40% weight) let nameSimilarity = Math.max( @@ -227,10 +286,10 @@ export class NodeSimilarityService { // Boost score significantly for short searches that are exact substring matches // Short searches need more boost to reach the 50 threshold patternMatch = isShortSearch ? 45 : 25; - } else if (this.getEditDistance(cleanInvalid, cleanValid) <= 2) { + } else if (this.getEditDistance(cleanInvalid, cleanValid) <= NodeSimilarityService.TYPO_EDIT_DISTANCE) { // Small edit distance indicates likely typo patternMatch = 20; - } else if (this.getEditDistance(cleanInvalid, displayNameClean) <= 2) { + } else if (this.getEditDistance(cleanInvalid, displayNameClean) <= NodeSimilarityService.TYPO_EDIT_DISTANCE) { patternMatch = 18; } @@ -301,41 +360,86 @@ export class NodeSimilarityService { } /** - * Calculate Levenshtein distance + * Calculate Levenshtein distance with optimizations + * - Early termination when difference exceeds threshold + * - Space-optimized to use only two rows instead of full matrix + * - Fast path for identical or vastly different strings */ - private getEditDistance(s1: string, s2: string): number { + private getEditDistance(s1: string, s2: string, maxDistance: number = 5): number { + // Fast path: identical strings + if (s1 === s2) return 0; + const m = s1.length; const n = s2.length; - const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); - for (let i = 0; i <= m; i++) dp[i][0] = i; - for (let j = 0; j <= n; j++) dp[0][j] = j; + // Fast path: length difference exceeds threshold + const lengthDiff = Math.abs(m - n); + if (lengthDiff > maxDistance) return maxDistance + 1; + + // Fast path: empty strings + if (m === 0) return n; + if (n === 0) return m; + + // Space optimization: only need previous and current row + let prev = Array(n + 1).fill(0).map((_, i) => i); for (let i = 1; i <= m; i++) { + const curr = [i]; + let minInRow = i; + for (let j = 1; j <= n; j++) { - if (s1[i - 1] === s2[j - 1]) { - dp[i][j] = dp[i - 1][j - 1]; - } else { - dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); - } + const cost = s1[i - 1] === s2[j - 1] ? 0 : 1; + const val = Math.min( + curr[j - 1] + 1, // deletion + prev[j] + 1, // insertion + prev[j - 1] + cost // substitution + ); + curr.push(val); + minInRow = Math.min(minInRow, val); } + + // Early termination: if minimum in this row exceeds threshold + if (minInRow > maxDistance) { + return maxDistance + 1; + } + + prev = curr; } - return dp[m][n]; + return prev[n]; } /** * Get cached nodes or fetch from repository + * Implements proper cache invalidation with version tracking */ private async getCachedNodes(): Promise { const now = Date.now(); if (!this.nodeCache || now > this.cacheExpiry) { try { - this.nodeCache = this.repository.getAllNodes(); - this.cacheExpiry = now + this.CACHE_DURATION; + const newNodes = this.repository.getAllNodes(); + + // Only update cache if we got valid data + if (newNodes && newNodes.length > 0) { + this.nodeCache = newNodes; + this.cacheExpiry = now + NodeSimilarityService.CACHE_DURATION_MS; + this.cacheVersion++; + logger.debug('Node cache refreshed', { + count: newNodes.length, + version: this.cacheVersion + }); + } else if (this.nodeCache) { + // Return stale cache if new fetch returned empty + logger.warn('Node fetch returned empty, using stale cache'); + } } catch (error) { logger.error('Failed to fetch nodes for similarity service', error); + // Return stale cache on error if available + if (this.nodeCache) { + logger.info('Using stale cache due to fetch error'); + return this.nodeCache; + } return []; } } @@ -343,6 +447,24 @@ export class NodeSimilarityService { return this.nodeCache || []; } + /** + * Invalidate the cache (e.g., after database updates) + */ + public invalidateCache(): void { + this.nodeCache = null; + this.cacheExpiry = 0; + this.cacheVersion++; + logger.debug('Node cache invalidated', { version: this.cacheVersion }); + } + + /** + * Clear and refresh cache immediately + */ + public async refreshCache(): Promise { + this.invalidateCache(); + await this.getCachedNodes(); + } + /** * Format suggestions into a user-friendly message */ @@ -377,14 +499,14 @@ export class NodeSimilarityService { * Check if a suggestion is high confidence for auto-fixing */ isAutoFixable(suggestion: NodeSuggestion): boolean { - return suggestion.confidence >= 0.9; + return suggestion.confidence >= NodeSimilarityService.AUTO_FIX_CONFIDENCE; } /** * Clear the node cache (useful after database updates) + * @deprecated Use invalidateCache() instead for proper version tracking */ clearCache(): void { - this.nodeCache = null; - this.cacheExpiry = 0; + this.invalidateCache(); } } \ No newline at end of file diff --git a/src/services/workflow-auto-fixer.ts b/src/services/workflow-auto-fixer.ts index 0796185..27b4fe0 100644 --- a/src/services/workflow-auto-fixer.ts +++ b/src/services/workflow-auto-fixer.ts @@ -5,6 +5,7 @@ * Converts validation results into diff operations that can be applied to fix the workflow. */ +import crypto from 'crypto'; import { WorkflowValidationResult } from './workflow-validator'; import { ExpressionFormatIssue } from './expression-format-validator'; import { NodeSimilarityService } from './node-similarity-service'; @@ -23,9 +24,8 @@ export type FixType = | 'expression-format' | 'typeversion-correction' | 'error-output-config' - | 'required-field' - | 'enum-value' - | 'node-type-correction'; + | 'node-type-correction' + | 'webhook-missing-path'; export interface AutoFixConfig { applyFixes: boolean; @@ -60,6 +60,30 @@ export interface NodeFormatIssue extends ExpressionFormatIssue { nodeId: string; } +/** + * Type guard to check if an issue has node information + */ +export function isNodeFormatIssue(issue: ExpressionFormatIssue): issue is NodeFormatIssue { + return 'nodeName' in issue && 'nodeId' in issue && + typeof (issue as any).nodeName === 'string' && + typeof (issue as any).nodeId === 'string'; +} + +/** + * Error with suggestions for node type issues + */ +export interface NodeTypeError { + type: 'error'; + nodeId?: string; + nodeName?: string; + message: string; + suggestions?: Array<{ + nodeType: string; + confidence: number; + reason: string; + }>; +} + export class WorkflowAutoFixer { private readonly defaultConfig: AutoFixConfig = { applyFixes: false, @@ -114,6 +138,11 @@ export class WorkflowAutoFixer { this.processNodeTypeFixes(validationResult, nodeMap, operations, fixes); } + // Process webhook path fixes (HIGH confidence) + if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('webhook-missing-path')) { + this.processWebhookPathFixes(validationResult, nodeMap, operations, fixes); + } + // Filter by confidence threshold const filteredFixes = this.filterByConfidence(fixes, fullConfig.confidenceThreshold); const filteredOperations = this.filterOperationsByFixes(operations, filteredFixes, fixes); @@ -149,15 +178,17 @@ export class WorkflowAutoFixer { for (const issue of formatIssues) { // Process both errors and warnings for missing-prefix issues if (issue.issueType === 'missing-prefix') { - // Check if the issue has node information - const nodeIssue = issue as any; - const nodeName = nodeIssue.nodeName; - - if (!nodeName) { - // Skip if we can't identify the node + // Use type guard to ensure we have node information + if (!isNodeFormatIssue(issue)) { + logger.warn('Expression format issue missing node information', { + fieldPath: issue.fieldPath, + issueType: issue.issueType + }); continue; } + const nodeName = issue.nodeName; + if (!fixesByNode.has(nodeName)) { fixesByNode.set(nodeName, []); } @@ -304,15 +335,15 @@ export class WorkflowAutoFixer { } for (const error of validationResult.errors) { - // Look for unknown node type errors with suggestions - if (error.message?.includes('Unknown node type:') && (error as any).suggestions) { - const suggestions = (error as any).suggestions; + // Type-safe check for unknown node type errors with suggestions + const nodeError = error as NodeTypeError; + if (error.message?.includes('Unknown node type:') && nodeError.suggestions) { // Only auto-fix if we have a high-confidence suggestion (>= 0.9) - const highConfidenceSuggestion = suggestions.find((s: any) => s.confidence >= 0.9); + const highConfidenceSuggestion = nodeError.suggestions.find(s => s.confidence >= 0.9); - if (highConfidenceSuggestion && error.nodeId) { - const node = nodeMap.get(error.nodeId) || nodeMap.get(error.nodeName || ''); + if (highConfidenceSuggestion && nodeError.nodeId) { + const node = nodeMap.get(nodeError.nodeId) || nodeMap.get(nodeError.nodeName || ''); if (node) { fixes.push({ @@ -339,46 +370,165 @@ export class WorkflowAutoFixer { } } + /** + * Process webhook path fixes for webhook nodes missing path parameter + */ + private processWebhookPathFixes( + validationResult: WorkflowValidationResult, + nodeMap: Map, + operations: WorkflowDiffOperation[], + fixes: FixOperation[] + ): void { + for (const error of validationResult.errors) { + // Check for webhook path required error + if (error.message === 'Webhook path is required') { + const nodeName = error.nodeName || error.nodeId; + if (!nodeName) continue; + + const node = nodeMap.get(nodeName); + if (!node) continue; + + // Only fix webhook nodes + if (!node.type?.includes('webhook')) continue; + + // Generate a unique UUID for both path and webhookId + const webhookId = crypto.randomUUID(); + + // Check if we need to update typeVersion + const currentTypeVersion = node.typeVersion || 1; + const needsVersionUpdate = currentTypeVersion < 2.1; + + fixes.push({ + node: nodeName, + field: 'path', + type: 'webhook-missing-path', + before: undefined, + after: webhookId, + confidence: 'high', + description: needsVersionUpdate + ? `Generated webhook path and ID: ${webhookId} (also updating typeVersion to 2.1)` + : `Generated webhook path and ID: ${webhookId}` + }); + + // Create update operation with both path and webhookId + // The updates object uses dot notation for nested properties + const updates: Record = { + 'parameters.path': webhookId, + 'webhookId': webhookId + }; + + // Only update typeVersion if it's older than 2.1 + if (needsVersionUpdate) { + updates['typeVersion'] = 2.1; + } + + const operation: UpdateNodeOperation = { + type: 'updateNode', + nodeId: nodeName, + updates + }; + operations.push(operation); + } + } + } + /** * Set a nested value in an object using a path array + * Includes validation to prevent silent failures */ private setNestedValue(obj: any, path: string[], value: any): void { - if (path.length === 0) return; + if (!obj || typeof obj !== 'object') { + throw new Error('Cannot set value on non-object'); + } - let current = obj; - for (let i = 0; i < path.length - 1; i++) { - const key = path[i]; + if (path.length === 0) { + throw new Error('Cannot set value with empty path'); + } - // Handle array indices - if (key.includes('[')) { - const [arrayKey, indexStr] = key.split('['); - const index = parseInt(indexStr.replace(']', '')); + try { + let current = obj; + + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + + // Handle array indices + if (key.includes('[')) { + const matches = key.match(/^([^[]+)\[(\d+)\]$/); + if (!matches) { + throw new Error(`Invalid array notation: ${key}`); + } + + const [, arrayKey, indexStr] = matches; + const index = parseInt(indexStr, 10); + + if (isNaN(index) || index < 0) { + throw new Error(`Invalid array index: ${indexStr}`); + } + + if (!current[arrayKey]) { + current[arrayKey] = []; + } + + if (!Array.isArray(current[arrayKey])) { + throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`); + } + + while (current[arrayKey].length <= index) { + current[arrayKey].push({}); + } + + current = current[arrayKey][index]; + } else { + if (current[key] === null || current[key] === undefined) { + current[key] = {}; + } + + if (typeof current[key] !== 'object' || Array.isArray(current[key])) { + throw new Error(`Cannot traverse through ${typeof current[key]} at ${key}`); + } + + current = current[key]; + } + } + + // Set the final value + const lastKey = path[path.length - 1]; + + if (lastKey.includes('[')) { + const matches = lastKey.match(/^([^[]+)\[(\d+)\]$/); + if (!matches) { + throw new Error(`Invalid array notation: ${lastKey}`); + } + + const [, arrayKey, indexStr] = matches; + const index = parseInt(indexStr, 10); + + if (isNaN(index) || index < 0) { + throw new Error(`Invalid array index: ${indexStr}`); + } if (!current[arrayKey]) { current[arrayKey] = []; } - if (!current[arrayKey][index]) { - current[arrayKey][index] = {}; - } - current = current[arrayKey][index]; - } else { - if (!current[key]) { - current[key] = {}; - } - current = current[key]; - } - } - const lastKey = path[path.length - 1]; - if (lastKey.includes('[')) { - const [arrayKey, indexStr] = lastKey.split('['); - const index = parseInt(indexStr.replace(']', '')); - if (!current[arrayKey]) { - current[arrayKey] = []; + if (!Array.isArray(current[arrayKey])) { + throw new Error(`Expected array at ${arrayKey}, got ${typeof current[arrayKey]}`); + } + + while (current[arrayKey].length <= index) { + current[arrayKey].push(null); + } + + current[arrayKey][index] = value; + } else { + current[lastKey] = value; } - current[arrayKey][index] = value; - } else { - current[lastKey] = value; + } catch (error) { + logger.error('Failed to set nested value', { + path: path.join('.'), + error: error instanceof Error ? error.message : String(error) + }); + throw error; } } @@ -427,9 +577,8 @@ export class WorkflowAutoFixer { 'expression-format': 0, 'typeversion-correction': 0, 'error-output-config': 0, - 'required-field': 0, - 'enum-value': 0, - 'node-type-correction': 0 + 'node-type-correction': 0, + 'webhook-missing-path': 0 }, byConfidence: { 'high': 0, @@ -465,11 +614,11 @@ export class WorkflowAutoFixer { if (stats.byType['error-output-config'] > 0) { parts.push(`${stats.byType['error-output-config']} error output ${stats.byType['error-output-config'] === 1 ? 'configuration' : 'configurations'}`); } - if (stats.byType['required-field'] > 0) { - parts.push(`${stats.byType['required-field']} required ${stats.byType['required-field'] === 1 ? 'field' : 'fields'}`); + if (stats.byType['node-type-correction'] > 0) { + parts.push(`${stats.byType['node-type-correction']} node type ${stats.byType['node-type-correction'] === 1 ? 'correction' : 'corrections'}`); } - if (stats.byType['enum-value'] > 0) { - parts.push(`${stats.byType['enum-value']} invalid ${stats.byType['enum-value'] === 1 ? 'value' : 'values'}`); + if (stats.byType['webhook-missing-path'] > 0) { + parts.push(`${stats.byType['webhook-missing-path']} webhook ${stats.byType['webhook-missing-path'] === 1 ? 'path' : 'paths'}`); } if (parts.length === 0) { diff --git a/src/utils/node-type-utils.ts b/src/utils/node-type-utils.ts new file mode 100644 index 0000000..a800d89 --- /dev/null +++ b/src/utils/node-type-utils.ts @@ -0,0 +1,143 @@ +/** + * Utility functions for working with n8n node types + * Provides consistent normalization and transformation of node type strings + */ + +/** + * Normalize a node type to the standard short form + * Handles both old-style (n8n-nodes-base.) and new-style (nodes-base.) prefixes + * + * @example + * normalizeNodeType('n8n-nodes-base.httpRequest') // 'nodes-base.httpRequest' + * normalizeNodeType('@n8n/n8n-nodes-langchain.openAi') // 'nodes-langchain.openAi' + * normalizeNodeType('nodes-base.webhook') // 'nodes-base.webhook' (unchanged) + */ +export function normalizeNodeType(type: string): string { + if (!type) return type; + + return type + .replace(/^n8n-nodes-base\./, 'nodes-base.') + .replace(/^@n8n\/n8n-nodes-langchain\./, 'nodes-langchain.'); +} + +/** + * Convert a short-form node type to the full package name + * + * @example + * denormalizeNodeType('nodes-base.httpRequest', 'base') // 'n8n-nodes-base.httpRequest' + * denormalizeNodeType('nodes-langchain.openAi', 'langchain') // '@n8n/n8n-nodes-langchain.openAi' + */ +export function denormalizeNodeType(type: string, packageType: 'base' | 'langchain'): string { + if (!type) return type; + + if (packageType === 'base') { + return type.replace(/^nodes-base\./, 'n8n-nodes-base.'); + } + + return type.replace(/^nodes-langchain\./, '@n8n/n8n-nodes-langchain.'); +} + +/** + * Extract the node name from a full node type + * + * @example + * extractNodeName('nodes-base.httpRequest') // 'httpRequest' + * extractNodeName('n8n-nodes-base.webhook') // 'webhook' + */ +export function extractNodeName(type: string): string { + if (!type) return ''; + + // First normalize the type + const normalized = normalizeNodeType(type); + + // Extract everything after the last dot + const parts = normalized.split('.'); + return parts[parts.length - 1] || ''; +} + +/** + * Get the package prefix from a node type + * + * @example + * getNodePackage('nodes-base.httpRequest') // 'nodes-base' + * getNodePackage('nodes-langchain.openAi') // 'nodes-langchain' + */ +export function getNodePackage(type: string): string | null { + if (!type || !type.includes('.')) return null; + + // First normalize the type + const normalized = normalizeNodeType(type); + + // Extract everything before the first dot + const parts = normalized.split('.'); + return parts[0] || null; +} + +/** + * Check if a node type is from the base package + */ +export function isBaseNode(type: string): boolean { + const normalized = normalizeNodeType(type); + return normalized.startsWith('nodes-base.'); +} + +/** + * Check if a node type is from the langchain package + */ +export function isLangChainNode(type: string): boolean { + const normalized = normalizeNodeType(type); + return normalized.startsWith('nodes-langchain.'); +} + +/** + * Validate if a string looks like a valid node type + * (has package prefix and node name) + */ +export function isValidNodeTypeFormat(type: string): boolean { + if (!type || typeof type !== 'string') return false; + + // Must contain at least one dot + if (!type.includes('.')) return false; + + const parts = type.split('.'); + + // Must have exactly 2 parts (package and node name) + if (parts.length !== 2) return false; + + // Both parts must be non-empty + return parts[0].length > 0 && parts[1].length > 0; +} + +/** + * Try multiple variations of a node type to find a match + * Returns an array of variations to try in order + * + * @example + * getNodeTypeVariations('httpRequest') + * // ['nodes-base.httpRequest', 'n8n-nodes-base.httpRequest', 'nodes-langchain.httpRequest', ...] + */ +export function getNodeTypeVariations(type: string): string[] { + const variations: string[] = []; + + // If it already has a package prefix, try normalized version first + if (type.includes('.')) { + variations.push(normalizeNodeType(type)); + + // Also try the denormalized versions + const normalized = normalizeNodeType(type); + if (normalized.startsWith('nodes-base.')) { + variations.push(denormalizeNodeType(normalized, 'base')); + } else if (normalized.startsWith('nodes-langchain.')) { + variations.push(denormalizeNodeType(normalized, 'langchain')); + } + } else { + // No package prefix, try common packages + variations.push(`nodes-base.${type}`); + variations.push(`n8n-nodes-base.${type}`); + variations.push(`nodes-langchain.${type}`); + variations.push(`@n8n/n8n-nodes-langchain.${type}`); + } + + // Remove duplicates while preserving order + return [...new Set(variations)]; +} \ No newline at end of file