diff --git a/src/mcp/handlers-n8n-manager.ts b/src/mcp/handlers-n8n-manager.ts index 502a664..72b8f67 100644 --- a/src/mcp/handlers-n8n-manager.ts +++ b/src/mcp/handlers-n8n-manager.ts @@ -24,6 +24,9 @@ import { WorkflowValidator } from '../services/workflow-validator'; import { EnhancedConfigValidator } from '../services/enhanced-config-validator'; import { NodeRepository } from '../database/node-repository'; import { InstanceContext, validateInstanceContext } from '../types/instance-context'; +import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer'; +import { ExpressionFormatValidator } from '../services/expression-format-validator'; +import { handleUpdatePartialWorkflow } from './handlers-workflow-diff'; import { createCacheKey, createInstanceCache, @@ -236,6 +239,20 @@ const validateWorkflowSchema = z.object({ }).optional(), }); +const autofixWorkflowSchema = z.object({ + id: z.string(), + applyFixes: z.boolean().optional().default(false), + fixTypes: z.array(z.enum([ + 'expression-format', + 'typeversion-correction', + 'error-output-config', + 'required-field', + 'enum-value' + ])).optional(), + confidenceThreshold: z.enum(['high', 'medium', 'low']).optional().default('medium'), + maxFixes: z.number().optional().default(50) +}); + const triggerWebhookSchema = z.object({ webhookUrl: z.string().url(), httpMethod: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional(), @@ -736,6 +753,174 @@ export async function handleValidateWorkflow( } } +export async function handleAutofixWorkflow( + args: unknown, + repository: NodeRepository, + context?: InstanceContext +): Promise { + try { + const client = ensureApiConfigured(context); + const input = autofixWorkflowSchema.parse(args); + + // First, fetch the workflow from n8n + const workflowResponse = await handleGetWorkflow({ id: input.id }, context); + + if (!workflowResponse.success) { + return workflowResponse; // Return the error from fetching + } + + const workflow = workflowResponse.data as Workflow; + + // Create validator instance using the provided repository + const validator = new WorkflowValidator(repository, EnhancedConfigValidator); + + // Run validation to identify issues + const validationResult = await validator.validateWorkflow(workflow, { + validateNodes: true, + validateConnections: true, + validateExpressions: true, + profile: 'ai-friendly' + }); + + // Check for expression format issues + const allFormatIssues: any[] = []; + for (const node of workflow.nodes) { + const formatContext = { + nodeType: node.type, + nodeName: node.name, + nodeId: node.id + }; + + const nodeFormatIssues = ExpressionFormatValidator.validateNodeParameters( + node.parameters, + formatContext + ); + + // Add node information to each format issue + const enrichedIssues = nodeFormatIssues.map(issue => ({ + ...issue, + nodeName: node.name, + nodeId: node.id + })); + + allFormatIssues.push(...enrichedIssues); + } + + // Generate fixes using WorkflowAutoFixer + const autoFixer = new WorkflowAutoFixer(repository); + const fixResult = autoFixer.generateFixes( + workflow, + validationResult, + allFormatIssues, + { + applyFixes: input.applyFixes, + fixTypes: input.fixTypes, + confidenceThreshold: input.confidenceThreshold, + maxFixes: input.maxFixes + } + ); + + // If no fixes available + if (fixResult.fixes.length === 0) { + return { + success: true, + data: { + workflowId: workflow.id, + workflowName: workflow.name, + message: 'No automatic fixes available for this workflow', + validationSummary: { + errors: validationResult.errors.length, + warnings: validationResult.warnings.length + } + } + }; + } + + // If preview mode (applyFixes = false) + if (!input.applyFixes) { + return { + success: true, + data: { + workflowId: workflow.id, + workflowName: workflow.name, + preview: true, + fixesAvailable: fixResult.fixes.length, + fixes: fixResult.fixes, + summary: fixResult.summary, + stats: fixResult.stats, + message: `${fixResult.fixes.length} fixes available. Set applyFixes=true to apply them.` + } + }; + } + + // Apply fixes using the diff engine + if (fixResult.operations.length > 0) { + const updateResult = await handleUpdatePartialWorkflow( + { + id: workflow.id, + operations: fixResult.operations + }, + context + ); + + if (!updateResult.success) { + return { + success: false, + error: 'Failed to apply fixes', + details: { + fixes: fixResult.fixes, + updateError: updateResult.error + } + }; + } + + return { + success: true, + data: { + workflowId: workflow.id, + workflowName: workflow.name, + fixesApplied: fixResult.fixes.length, + fixes: fixResult.fixes, + summary: fixResult.summary, + stats: fixResult.stats, + message: `Successfully applied ${fixResult.fixes.length} fixes to workflow "${workflow.name}"` + } + }; + } + + return { + success: true, + data: { + workflowId: workflow.id, + workflowName: workflow.name, + message: 'No fixes needed' + } + }; + + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: 'Invalid input', + details: { errors: error.errors } + }; + } + + if (error instanceof N8nApiError) { + return { + success: false, + error: getUserFriendlyErrorMessage(error), + code: error.code + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } +} + // Execution Management Handlers export async function handleTriggerWebhookWorkflow(args: unknown, context?: InstanceContext): Promise { @@ -964,7 +1149,8 @@ export async function handleListAvailableTools(context?: InstanceContext): Promi { name: 'n8n_update_workflow', description: 'Update existing workflows' }, { name: 'n8n_delete_workflow', description: 'Delete workflows' }, { name: 'n8n_list_workflows', description: 'List workflows with filters' }, - { name: 'n8n_validate_workflow', description: 'Validate workflow from n8n instance' } + { name: 'n8n_validate_workflow', description: 'Validate workflow from n8n instance' }, + { name: 'n8n_autofix_workflow', description: 'Automatically fix common workflow errors' } ] }, { diff --git a/src/scripts/test-autofix-workflow.ts b/src/scripts/test-autofix-workflow.ts new file mode 100644 index 0000000..6538455 --- /dev/null +++ b/src/scripts/test-autofix-workflow.ts @@ -0,0 +1,251 @@ +/** + * Test script for n8n_autofix_workflow functionality + * + * Tests the automatic fixing of common workflow validation errors: + * 1. Expression format errors (missing = prefix) + * 2. TypeVersion corrections + * 3. Error output configuration issues + */ + +import { WorkflowAutoFixer } from '../services/workflow-auto-fixer'; +import { WorkflowValidator } from '../services/workflow-validator'; +import { EnhancedConfigValidator } from '../services/enhanced-config-validator'; +import { ExpressionFormatValidator } from '../services/expression-format-validator'; +import { NodeRepository } from '../database/node-repository'; +import { Logger } from '../utils/logger'; +import { createDatabaseAdapter } from '../database/database-adapter'; +import * as path from 'path'; + +const logger = new Logger({ prefix: '[TestAutofix]' }); + +async function testAutofix() { + // Initialize database and repository + const dbPath = path.join(__dirname, '../../data/nodes.db'); + const dbAdapter = await createDatabaseAdapter(dbPath); + const repository = new NodeRepository(dbAdapter); + + // Test workflow with various issues + const testWorkflow = { + id: 'test_workflow_1', + name: 'Test Workflow for Autofix', + nodes: [ + { + id: 'webhook_1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1.1, + position: [250, 300], + parameters: { + httpMethod: 'GET', + path: 'test-webhook', + responseMode: 'onReceived', + responseData: 'firstEntryJson' + } + }, + { + id: 'http_1', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 5.0, // Invalid - max is 4.2 + position: [450, 300], + parameters: { + method: 'GET', + url: '{{ $json.webhookUrl }}', // Missing = prefix + sendHeaders: true, + headerParameters: { + parameters: [ + { + name: 'Authorization', + value: '{{ $json.token }}' // Missing = prefix + } + ] + } + }, + onError: 'continueErrorOutput' // Has onError but no error connections + }, + { + id: 'set_1', + name: 'Set', + type: 'n8n-nodes-base.set', + typeVersion: 3.5, // Invalid version + position: [650, 300], + parameters: { + mode: 'manual', + duplicateItem: false, + values: { + values: [ + { + name: 'status', + value: '{{ $json.success }}' // Missing = prefix + } + ] + } + } + } + ], + connections: { + 'Webhook': { + main: [ + [ + { + node: 'HTTP Request', + type: 'main', + index: 0 + } + ] + ] + }, + 'HTTP Request': { + main: [ + [ + { + node: 'Set', + type: 'main', + index: 0 + } + ] + // Missing error output connection for onError: 'continueErrorOutput' + ] + } + } + }; + + logger.info('=== Testing Workflow Auto-Fixer ===\n'); + + // Step 1: Validate the workflow to identify issues + logger.info('Step 1: Validating workflow to identify issues...'); + const validator = new WorkflowValidator(repository, EnhancedConfigValidator); + const validationResult = await validator.validateWorkflow(testWorkflow as any, { + validateNodes: true, + validateConnections: true, + validateExpressions: true, + profile: 'ai-friendly' + }); + + logger.info(`Found ${validationResult.errors.length} errors and ${validationResult.warnings.length} warnings`); + + // Step 2: Check for expression format issues + logger.info('\nStep 2: Checking for expression format issues...'); + const allFormatIssues: any[] = []; + for (const node of testWorkflow.nodes) { + const formatContext = { + nodeType: node.type, + nodeName: node.name, + nodeId: node.id + }; + + const nodeFormatIssues = ExpressionFormatValidator.validateNodeParameters( + node.parameters, + formatContext + ); + + // Add node information to each format issue + const enrichedIssues = nodeFormatIssues.map(issue => ({ + ...issue, + nodeName: node.name, + nodeId: node.id + })); + + allFormatIssues.push(...enrichedIssues); + } + + logger.info(`Found ${allFormatIssues.length} expression format issues`); + + // Debug: Show the actual format issues + if (allFormatIssues.length > 0) { + logger.info('\nExpression format issues found:'); + for (const issue of allFormatIssues) { + logger.info(` - ${issue.fieldPath}: ${issue.issueType} (${issue.severity})`); + logger.info(` Current: ${JSON.stringify(issue.currentValue)}`); + logger.info(` Fixed: ${JSON.stringify(issue.correctedValue)}`); + } + } + + // Step 3: Generate fixes in preview mode + logger.info('\nStep 3: Generating fixes (preview mode)...'); + const autoFixer = new WorkflowAutoFixer(); + const previewResult = autoFixer.generateFixes( + testWorkflow as any, + validationResult, + allFormatIssues, + { + applyFixes: false, // Preview mode + confidenceThreshold: 'medium' + } + ); + + logger.info(`\nGenerated ${previewResult.fixes.length} fixes:`); + logger.info(`Summary: ${previewResult.summary}`); + logger.info('\nFixes by type:'); + for (const [type, count] of Object.entries(previewResult.stats.byType)) { + if (count > 0) { + logger.info(` - ${type}: ${count}`); + } + } + + logger.info('\nFixes by confidence:'); + for (const [confidence, count] of Object.entries(previewResult.stats.byConfidence)) { + if (count > 0) { + logger.info(` - ${confidence}: ${count}`); + } + } + + // Step 4: Display individual fixes + logger.info('\nDetailed fixes:'); + for (const fix of previewResult.fixes) { + logger.info(`\n[${fix.confidence.toUpperCase()}] ${fix.node}.${fix.field} (${fix.type})`); + logger.info(` Before: ${JSON.stringify(fix.before)}`); + logger.info(` After: ${JSON.stringify(fix.after)}`); + logger.info(` Description: ${fix.description}`); + } + + // Step 5: Display generated operations + logger.info('\n\nGenerated diff operations:'); + for (const op of previewResult.operations) { + logger.info(`\nOperation: ${op.type}`); + logger.info(` Details: ${JSON.stringify(op, null, 2)}`); + } + + // Step 6: Test with different confidence thresholds + logger.info('\n\n=== Testing Different Confidence Thresholds ==='); + + for (const threshold of ['high', 'medium', 'low'] as const) { + const result = autoFixer.generateFixes( + testWorkflow as any, + validationResult, + allFormatIssues, + { + applyFixes: false, + confidenceThreshold: threshold + } + ); + logger.info(`\nThreshold "${threshold}": ${result.fixes.length} fixes`); + } + + // Step 7: Test with specific fix types + logger.info('\n\n=== Testing Specific Fix Types ==='); + + const fixTypes = ['expression-format', 'typeversion-correction', 'error-output-config'] as const; + for (const fixType of fixTypes) { + const result = autoFixer.generateFixes( + testWorkflow as any, + validationResult, + allFormatIssues, + { + applyFixes: false, + fixTypes: [fixType] + } + ); + logger.info(`\nFix type "${fixType}": ${result.fixes.length} fixes`); + } + + logger.info('\n\nāœ… Autofix test completed successfully!'); + + await dbAdapter.close(); +} + +// Run the test +testAutofix().catch(error => { + logger.error('Test failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/scripts/test-node-suggestions.ts b/src/scripts/test-node-suggestions.ts new file mode 100644 index 0000000..28f872d --- /dev/null +++ b/src/scripts/test-node-suggestions.ts @@ -0,0 +1,205 @@ +#!/usr/bin/env npx tsx +/** + * Test script for enhanced node type suggestions + * Tests the NodeSimilarityService to ensure it provides helpful suggestions + * for unknown or incorrectly typed nodes in workflows. + */ + +import { createDatabaseAdapter } from '../database/database-adapter'; +import { NodeRepository } from '../database/node-repository'; +import { NodeSimilarityService } from '../services/node-similarity-service'; +import { WorkflowValidator } from '../services/workflow-validator'; +import { EnhancedConfigValidator } from '../services/enhanced-config-validator'; +import { WorkflowAutoFixer } from '../services/workflow-auto-fixer'; +import { Logger } from '../utils/logger'; +import path from 'path'; + +const logger = new Logger({ prefix: '[NodeSuggestions Test]' }); +const console = { + log: (msg: string) => logger.info(msg), + error: (msg: string, err?: any) => logger.error(msg, err) +}; + +async function testNodeSimilarity() { + console.log('šŸ” Testing Enhanced Node Type Suggestions\n'); + + // Initialize database and services + const dbPath = path.join(process.cwd(), 'data/nodes.db'); + const db = await createDatabaseAdapter(dbPath); + const repository = new NodeRepository(db); + const similarityService = new NodeSimilarityService(repository); + const validator = new WorkflowValidator(repository, EnhancedConfigValidator); + + // Test cases with various invalid node types + const testCases = [ + // Case variations + { invalid: 'HttpRequest', expected: 'nodes-base.httpRequest' }, + { invalid: 'HTTPRequest', expected: 'nodes-base.httpRequest' }, + { invalid: 'Webhook', expected: 'nodes-base.webhook' }, + { invalid: 'WebHook', expected: 'nodes-base.webhook' }, + + // Missing package prefix + { invalid: 'slack', expected: 'nodes-base.slack' }, + { invalid: 'googleSheets', expected: 'nodes-base.googleSheets' }, + { invalid: 'telegram', expected: 'nodes-base.telegram' }, + + // Common typos + { invalid: 'htpRequest', expected: 'nodes-base.httpRequest' }, + { invalid: 'webook', expected: 'nodes-base.webhook' }, + { invalid: 'slak', expected: 'nodes-base.slack' }, + + // Partial names + { invalid: 'http', expected: 'nodes-base.httpRequest' }, + { invalid: 'sheet', expected: 'nodes-base.googleSheets' }, + + // Wrong package prefix + { invalid: 'nodes-base.openai', expected: 'nodes-langchain.openAi' }, + { invalid: 'n8n-nodes-base.httpRequest', expected: 'nodes-base.httpRequest' }, + + // Complete unknowns + { invalid: 'foobar', expected: null }, + { invalid: 'xyz123', expected: null }, + ]; + + console.log('Testing individual node type suggestions:'); + console.log('=' .repeat(60)); + + for (const testCase of testCases) { + const suggestions = await similarityService.findSimilarNodes(testCase.invalid, 3); + + console.log(`\nāŒ Invalid type: "${testCase.invalid}"`); + + if (suggestions.length > 0) { + console.log('✨ Suggestions:'); + for (const suggestion of suggestions) { + const confidence = Math.round(suggestion.confidence * 100); + const marker = suggestion.nodeType === testCase.expected ? 'āœ…' : ' '; + console.log( + `${marker} ${suggestion.nodeType} (${confidence}% match) - ${suggestion.reason}` + ); + + if (suggestion.confidence >= 0.9) { + console.log(' šŸ’” Can be auto-fixed!'); + } + } + + // Check if expected match was found + if (testCase.expected) { + const found = suggestions.some(s => s.nodeType === testCase.expected); + if (!found) { + console.log(` āš ļø Expected "${testCase.expected}" was not suggested!`); + } + } + } else { + console.log(' No suggestions found'); + if (testCase.expected) { + console.log(` āš ļø Expected "${testCase.expected}" was not suggested!`); + } + } + } + + console.log('\n' + '='.repeat(60)); + console.log('\nšŸ“‹ Testing workflow validation with unknown nodes:'); + console.log('='.repeat(60)); + + // Test with a sample workflow + const testWorkflow = { + id: 'test-workflow', + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Start', + type: 'nodes-base.manualTrigger', + position: [100, 100] as [number, number], + parameters: {}, + typeVersion: 1 + }, + { + id: '2', + name: 'HTTP Request', + type: 'HTTPRequest', // Wrong capitalization + position: [300, 100] as [number, number], + parameters: {}, + typeVersion: 1 + }, + { + id: '3', + name: 'Slack', + type: 'slack', // Missing prefix + position: [500, 100] as [number, number], + parameters: {}, + typeVersion: 1 + }, + { + id: '4', + name: 'Unknown', + type: 'foobar', // Completely unknown + position: [700, 100] as [number, number], + parameters: {}, + typeVersion: 1 + } + ], + connections: { + 'Start': { + main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] + }, + 'HTTP Request': { + main: [[{ node: 'Slack', type: 'main', index: 0 }]] + }, + 'Slack': { + main: [[{ node: 'Unknown', type: 'main', index: 0 }]] + } + }, + settings: {} + }; + + const validationResult = await validator.validateWorkflow(testWorkflow as any, { + validateNodes: true, + validateConnections: false, + validateExpressions: false, + profile: 'runtime' + }); + + console.log('\nValidation Results:'); + for (const error of validationResult.errors) { + if (error.message?.includes('Unknown node type:')) { + console.log(`\nšŸ”“ ${error.nodeName}: ${error.message}`); + } + } + + console.log('\n' + '='.repeat(60)); + console.log('\nšŸ”§ Testing AutoFixer with node type corrections:'); + console.log('='.repeat(60)); + + const autoFixer = new WorkflowAutoFixer(repository); + const fixResult = autoFixer.generateFixes( + testWorkflow as any, + validationResult, + [], + { + applyFixes: false, + fixTypes: ['node-type-correction'], + confidenceThreshold: 'high' + } + ); + + if (fixResult.fixes.length > 0) { + console.log('\nāœ… Auto-fixable issues found:'); + for (const fix of fixResult.fixes) { + console.log(` • ${fix.description}`); + } + console.log(`\nSummary: ${fixResult.summary}`); + } else { + console.log('\nāŒ No auto-fixable node type issues found (only high-confidence fixes are applied)'); + } + + console.log('\n' + '='.repeat(60)); + console.log('\n✨ Test complete!'); +} + +// Run the test +testNodeSimilarity().catch(error => { + console.error('Test failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/services/node-similarity-service.ts b/src/services/node-similarity-service.ts new file mode 100644 index 0000000..29245e1 --- /dev/null +++ b/src/services/node-similarity-service.ts @@ -0,0 +1,374 @@ +import { NodeRepository } from '../database/node-repository'; +import { logger } from '../utils/logger'; + +export interface NodeSuggestion { + nodeType: string; + displayName: string; + confidence: number; + reason: string; + category?: string; + description?: string; +} + +export interface SimilarityScore { + nameSimilarity: number; + categoryMatch: number; + packageMatch: number; + patternMatch: number; + totalScore: number; +} + +export interface CommonMistakePattern { + pattern: RegExp | string; + suggestion: string; + confidence: number; + reason: string; +} + +export class NodeSimilarityService { + private repository: NodeRepository; + private commonMistakes: Map; + private nodeCache: any[] | null = null; + private cacheExpiry: number = 0; + private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + + constructor(repository: NodeRepository) { + this.repository = repository; + this.commonMistakes = this.initializeCommonMistakes(); + } + + /** + * Initialize common mistake patterns + */ + private initializeCommonMistakes(): Map { + const patterns = new Map(); + + // Case variations + 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' }, + ]); + + // 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' }, + ]); + + // Old versions or deprecated names + patterns.set('deprecated', [ + { pattern: /^n8n-nodes-base\./i, suggestion: '', confidence: 0.85, reason: 'Full package name used instead of short form' }, + { pattern: /^@n8n\/n8n-nodes-langchain\./i, suggestion: '', confidence: 0.85, reason: 'Full package name used instead of short form' }, + ]); + + // Common typos + 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' }, + ]); + + // AI/LangChain specific + patterns.set('ai_nodes', [ + { pattern: /^openai$/i, suggestion: 'nodes-langchain.openAi', confidence: 0.85, reason: 'AI node - incorrect 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' }, + ]); + + return patterns; + } + + /** + * Find similar nodes for an invalid type + */ + async findSimilarNodes(invalidType: string, limit: number = 5): Promise { + if (!invalidType || invalidType.trim() === '') { + return []; + } + + const suggestions: NodeSuggestion[] = []; + + // First, check for exact common mistakes + const mistakeSuggestion = this.checkCommonMistakes(invalidType); + if (mistakeSuggestion) { + suggestions.push(mistakeSuggestion); + } + + // Get all nodes (with caching) + const allNodes = await this.getCachedNodes(); + + // Calculate similarity scores for all nodes + const scores = allNodes.map(node => ({ + node, + score: this.calculateSimilarityScore(invalidType, node) + })); + + // Sort by total score and filter high scores + scores.sort((a, b) => b.score.totalScore - a.score.totalScore); + + // Add top suggestions (excluding already added exact matches) + for (const { node, score } of scores) { + if (suggestions.some(s => s.nodeType === node.nodeType)) { + continue; + } + + if (score.totalScore >= 50) { + suggestions.push(this.createSuggestion(node, score)); + } + + if (suggestions.length >= limit) { + break; + } + } + + return suggestions; + } + + /** + * Check for common mistake patterns + */ + private checkCommonMistakes(invalidType: string): NodeSuggestion | null { + const cleanType = invalidType.trim(); + + // Check each category of patterns + 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.'); + } + + // Verify the suggestion exists + 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 + }; + } + } + } + } + + return null; + } + + /** + * Calculate multi-factor similarity score + */ + private calculateSimilarityScore(invalidType: string, node: any): SimilarityScore { + const cleanInvalid = this.normalizeNodeType(invalidType); + const cleanValid = this.normalizeNodeType(node.nodeType); + const displayNameClean = this.normalizeNodeType(node.displayName); + + // Name similarity (40% weight) + const nameSimilarity = Math.max( + this.getStringSimilarity(cleanInvalid, cleanValid), + this.getStringSimilarity(cleanInvalid, displayNameClean) + ) * 40; + + // Category match (20% weight) + let categoryMatch = 0; + if (node.category) { + const categoryClean = this.normalizeNodeType(node.category); + if (cleanInvalid.includes(categoryClean) || categoryClean.includes(cleanInvalid)) { + categoryMatch = 20; + } + } + + // Package match (15% weight) + let packageMatch = 0; + const invalidParts = cleanInvalid.split(/[.-]/); + const validParts = cleanValid.split(/[.-]/); + + if (invalidParts[0] === validParts[0]) { + packageMatch = 15; + } + + // Pattern match (25% weight) + let patternMatch = 0; + + // Check if it's a substring match + if (cleanValid.includes(cleanInvalid) || displayNameClean.includes(cleanInvalid)) { + patternMatch = 25; + } else if (this.getEditDistance(cleanInvalid, cleanValid) <= 2) { + // Small edit distance indicates likely typo + patternMatch = 20; + } else if (this.getEditDistance(cleanInvalid, displayNameClean) <= 2) { + patternMatch = 18; + } + + const totalScore = nameSimilarity + categoryMatch + packageMatch + patternMatch; + + return { + nameSimilarity, + categoryMatch, + packageMatch, + patternMatch, + totalScore + }; + } + + /** + * Create a suggestion object from node and score + */ + private createSuggestion(node: any, score: SimilarityScore): NodeSuggestion { + let reason = 'Similar node'; + + if (score.patternMatch >= 20) { + reason = 'Name similarity'; + } else if (score.categoryMatch >= 15) { + reason = 'Same category'; + } else if (score.packageMatch >= 10) { + reason = 'Same package'; + } + + // Calculate confidence (0-1 scale) + const confidence = Math.min(score.totalScore / 100, 1); + + return { + nodeType: node.nodeType, + displayName: node.displayName, + confidence, + reason, + category: node.category, + description: node.description + }; + } + + /** + * Normalize node type for comparison + */ + private normalizeNodeType(type: string): string { + return type + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + .trim(); + } + + /** + * Calculate string similarity (0-1) + */ + private getStringSimilarity(s1: string, s2: string): number { + if (s1 === s2) return 1; + if (!s1 || !s2) return 0; + + const distance = this.getEditDistance(s1, s2); + const maxLen = Math.max(s1.length, s2.length); + + return 1 - (distance / maxLen); + } + + /** + * Calculate Levenshtein distance + */ + private getEditDistance(s1: string, s2: string): number { + 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; + + for (let i = 1; i <= m; 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]); + } + } + } + + return dp[m][n]; + } + + /** + * Get cached nodes or fetch from repository + */ + 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; + } catch (error) { + logger.error('Failed to fetch nodes for similarity service', error); + return []; + } + } + + return this.nodeCache || []; + } + + /** + * Format suggestions into a user-friendly message + */ + formatSuggestionMessage(suggestions: NodeSuggestion[], invalidType: string): string { + if (suggestions.length === 0) { + return `Unknown node type: "${invalidType}". No similar nodes found.`; + } + + let message = `Unknown node type: "${invalidType}"\n\nDid you mean one of these?\n`; + + for (const suggestion of suggestions) { + const confidence = Math.round(suggestion.confidence * 100); + message += `• ${suggestion.nodeType} (${confidence}% match)`; + + if (suggestion.displayName) { + message += ` - ${suggestion.displayName}`; + } + + message += `\n → ${suggestion.reason}`; + + if (suggestion.confidence >= 0.9) { + message += ' (can be auto-fixed)'; + } + + message += '\n'; + } + + return message; + } + + /** + * Check if a suggestion is high confidence for auto-fixing + */ + isAutoFixable(suggestion: NodeSuggestion): boolean { + return suggestion.confidence >= 0.9; + } + + /** + * Clear the node cache (useful after database updates) + */ + clearCache(): void { + this.nodeCache = null; + this.cacheExpiry = 0; + } +} \ No newline at end of file diff --git a/src/services/workflow-auto-fixer.ts b/src/services/workflow-auto-fixer.ts new file mode 100644 index 0000000..0796185 --- /dev/null +++ b/src/services/workflow-auto-fixer.ts @@ -0,0 +1,481 @@ +/** + * Workflow Auto-Fixer Service + * + * Automatically generates fix operations for common workflow validation errors. + * Converts validation results into diff operations that can be applied to fix the workflow. + */ + +import { WorkflowValidationResult } from './workflow-validator'; +import { ExpressionFormatIssue } from './expression-format-validator'; +import { NodeSimilarityService } from './node-similarity-service'; +import { NodeRepository } from '../database/node-repository'; +import { + WorkflowDiffOperation, + UpdateNodeOperation +} from '../types/workflow-diff'; +import { WorkflowNode, Workflow } from '../types/n8n-api'; +import { Logger } from '../utils/logger'; + +const logger = new Logger({ prefix: '[WorkflowAutoFixer]' }); + +export type FixConfidenceLevel = 'high' | 'medium' | 'low'; +export type FixType = + | 'expression-format' + | 'typeversion-correction' + | 'error-output-config' + | 'required-field' + | 'enum-value' + | 'node-type-correction'; + +export interface AutoFixConfig { + applyFixes: boolean; + fixTypes?: FixType[]; + confidenceThreshold?: FixConfidenceLevel; + maxFixes?: number; +} + +export interface FixOperation { + node: string; + field: string; + type: FixType; + before: any; + after: any; + confidence: FixConfidenceLevel; + description: string; +} + +export interface AutoFixResult { + operations: WorkflowDiffOperation[]; + fixes: FixOperation[]; + summary: string; + stats: { + total: number; + byType: Record; + byConfidence: Record; + }; +} + +export interface NodeFormatIssue extends ExpressionFormatIssue { + nodeName: string; + nodeId: string; +} + +export class WorkflowAutoFixer { + private readonly defaultConfig: AutoFixConfig = { + applyFixes: false, + confidenceThreshold: 'medium', + maxFixes: 50 + }; + private similarityService: NodeSimilarityService | null = null; + + constructor(repository?: NodeRepository) { + if (repository) { + this.similarityService = new NodeSimilarityService(repository); + } + } + + /** + * Generate fix operations from validation results + */ + generateFixes( + workflow: Workflow, + validationResult: WorkflowValidationResult, + formatIssues: ExpressionFormatIssue[] = [], + config: Partial = {} + ): AutoFixResult { + const fullConfig = { ...this.defaultConfig, ...config }; + const operations: WorkflowDiffOperation[] = []; + const fixes: FixOperation[] = []; + + // Create a map for quick node lookup + const nodeMap = new Map(); + workflow.nodes.forEach(node => { + nodeMap.set(node.name, node); + nodeMap.set(node.id, node); + }); + + // Process expression format issues (HIGH confidence) + if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('expression-format')) { + this.processExpressionFormatFixes(formatIssues, nodeMap, operations, fixes); + } + + // Process typeVersion errors (MEDIUM confidence) + if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('typeversion-correction')) { + this.processTypeVersionFixes(validationResult, nodeMap, operations, fixes); + } + + // Process error output configuration issues (MEDIUM confidence) + if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('error-output-config')) { + this.processErrorOutputFixes(validationResult, nodeMap, workflow, operations, fixes); + } + + // Process node type corrections (HIGH confidence only) + if (!fullConfig.fixTypes || fullConfig.fixTypes.includes('node-type-correction')) { + this.processNodeTypeFixes(validationResult, nodeMap, operations, fixes); + } + + // Filter by confidence threshold + const filteredFixes = this.filterByConfidence(fixes, fullConfig.confidenceThreshold); + const filteredOperations = this.filterOperationsByFixes(operations, filteredFixes, fixes); + + // Apply max fixes limit + const limitedFixes = filteredFixes.slice(0, fullConfig.maxFixes); + const limitedOperations = this.filterOperationsByFixes(filteredOperations, limitedFixes, filteredFixes); + + // Generate summary + const stats = this.calculateStats(limitedFixes); + const summary = this.generateSummary(stats); + + return { + operations: limitedOperations, + fixes: limitedFixes, + summary, + stats + }; + } + + /** + * Process expression format fixes (missing = prefix) + */ + private processExpressionFormatFixes( + formatIssues: ExpressionFormatIssue[], + nodeMap: Map, + operations: WorkflowDiffOperation[], + fixes: FixOperation[] + ): void { + // Group fixes by node to create single update operation per node + const fixesByNode = new Map(); + + 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 + continue; + } + + if (!fixesByNode.has(nodeName)) { + fixesByNode.set(nodeName, []); + } + fixesByNode.get(nodeName)!.push(issue); + } + } + + // Create update operations for each node + for (const [nodeName, nodeIssues] of fixesByNode) { + const node = nodeMap.get(nodeName); + if (!node) continue; + + const updatedParameters = JSON.parse(JSON.stringify(node.parameters || {})); + + for (const issue of nodeIssues) { + // Apply the fix to parameters + // The fieldPath doesn't include node name, use as is + const fieldPath = issue.fieldPath.split('.'); + this.setNestedValue(updatedParameters, fieldPath, issue.correctedValue); + + fixes.push({ + node: nodeName, + field: issue.fieldPath, + type: 'expression-format', + before: issue.currentValue, + after: issue.correctedValue, + confidence: 'high', + description: issue.explanation + }); + } + + // Create update operation + const operation: UpdateNodeOperation = { + type: 'updateNode', + nodeId: nodeName, // Can be name or ID + updates: { + parameters: updatedParameters + } + }; + operations.push(operation); + } + } + + /** + * Process typeVersion fixes + */ + private processTypeVersionFixes( + validationResult: WorkflowValidationResult, + nodeMap: Map, + operations: WorkflowDiffOperation[], + fixes: FixOperation[] + ): void { + for (const error of validationResult.errors) { + if (error.message.includes('typeVersion') && error.message.includes('exceeds maximum')) { + // Extract version info from error message + const versionMatch = error.message.match(/typeVersion (\d+(?:\.\d+)?) exceeds maximum supported version (\d+(?:\.\d+)?)/); + if (versionMatch) { + const currentVersion = parseFloat(versionMatch[1]); + const maxVersion = parseFloat(versionMatch[2]); + const nodeName = error.nodeName || error.nodeId; + + if (!nodeName) continue; + + const node = nodeMap.get(nodeName); + if (!node) continue; + + fixes.push({ + node: nodeName, + field: 'typeVersion', + type: 'typeversion-correction', + before: currentVersion, + after: maxVersion, + confidence: 'medium', + description: `Corrected typeVersion from ${currentVersion} to maximum supported ${maxVersion}` + }); + + const operation: UpdateNodeOperation = { + type: 'updateNode', + nodeId: nodeName, + updates: { + typeVersion: maxVersion + } + }; + operations.push(operation); + } + } + } + } + + /** + * Process error output configuration fixes + */ + private processErrorOutputFixes( + validationResult: WorkflowValidationResult, + nodeMap: Map, + workflow: Workflow, + operations: WorkflowDiffOperation[], + fixes: FixOperation[] + ): void { + for (const error of validationResult.errors) { + if (error.message.includes('onError: \'continueErrorOutput\'') && + error.message.includes('no error output connections')) { + const nodeName = error.nodeName || error.nodeId; + if (!nodeName) continue; + + const node = nodeMap.get(nodeName); + if (!node) continue; + + // Remove the conflicting onError setting + fixes.push({ + node: nodeName, + field: 'onError', + type: 'error-output-config', + before: 'continueErrorOutput', + after: undefined, + confidence: 'medium', + description: 'Removed onError setting due to missing error output connections' + }); + + const operation: UpdateNodeOperation = { + type: 'updateNode', + nodeId: nodeName, + updates: { + onError: undefined // This will remove the property + } + }; + operations.push(operation); + } + } + } + + /** + * Process node type corrections for unknown nodes + */ + private processNodeTypeFixes( + validationResult: WorkflowValidationResult, + nodeMap: Map, + operations: WorkflowDiffOperation[], + fixes: FixOperation[] + ): void { + // Only process if we have the similarity service + if (!this.similarityService) { + return; + } + + 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; + + // Only auto-fix if we have a high-confidence suggestion (>= 0.9) + const highConfidenceSuggestion = suggestions.find((s: any) => s.confidence >= 0.9); + + if (highConfidenceSuggestion && error.nodeId) { + const node = nodeMap.get(error.nodeId) || nodeMap.get(error.nodeName || ''); + + if (node) { + fixes.push({ + node: node.name, + field: 'type', + type: 'node-type-correction', + before: node.type, + after: highConfidenceSuggestion.nodeType, + confidence: 'high', + description: `Fix node type: "${node.type}" → "${highConfidenceSuggestion.nodeType}" (${highConfidenceSuggestion.reason})` + }); + + const operation: UpdateNodeOperation = { + type: 'updateNode', + nodeId: node.name, + updates: { + type: highConfidenceSuggestion.nodeType + } + }; + operations.push(operation); + } + } + } + } + } + + /** + * Set a nested value in an object using a path array + */ + private setNestedValue(obj: any, path: string[], value: any): void { + if (path.length === 0) return; + + let current = obj; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + + // Handle array indices + if (key.includes('[')) { + const [arrayKey, indexStr] = key.split('['); + const index = parseInt(indexStr.replace(']', '')); + + 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] = []; + } + current[arrayKey][index] = value; + } else { + current[lastKey] = value; + } + } + + /** + * Filter fixes by confidence level + */ + private filterByConfidence( + fixes: FixOperation[], + threshold?: FixConfidenceLevel + ): FixOperation[] { + if (!threshold) return fixes; + + const levels: FixConfidenceLevel[] = ['high', 'medium', 'low']; + const thresholdIndex = levels.indexOf(threshold); + + return fixes.filter(fix => { + const fixIndex = levels.indexOf(fix.confidence); + return fixIndex <= thresholdIndex; + }); + } + + /** + * Filter operations to match filtered fixes + */ + private filterOperationsByFixes( + operations: WorkflowDiffOperation[], + filteredFixes: FixOperation[], + allFixes: FixOperation[] + ): WorkflowDiffOperation[] { + const fixedNodes = new Set(filteredFixes.map(f => f.node)); + return operations.filter(op => { + if (op.type === 'updateNode') { + return fixedNodes.has(op.nodeId || ''); + } + return true; + }); + } + + /** + * Calculate statistics about fixes + */ + private calculateStats(fixes: FixOperation[]): AutoFixResult['stats'] { + const stats: AutoFixResult['stats'] = { + total: fixes.length, + byType: { + 'expression-format': 0, + 'typeversion-correction': 0, + 'error-output-config': 0, + 'required-field': 0, + 'enum-value': 0, + 'node-type-correction': 0 + }, + byConfidence: { + 'high': 0, + 'medium': 0, + 'low': 0 + } + }; + + for (const fix of fixes) { + stats.byType[fix.type]++; + stats.byConfidence[fix.confidence]++; + } + + return stats; + } + + /** + * Generate a human-readable summary + */ + private generateSummary(stats: AutoFixResult['stats']): string { + if (stats.total === 0) { + return 'No fixes available'; + } + + const parts: string[] = []; + + if (stats.byType['expression-format'] > 0) { + parts.push(`${stats.byType['expression-format']} expression format ${stats.byType['expression-format'] === 1 ? 'error' : 'errors'}`); + } + if (stats.byType['typeversion-correction'] > 0) { + parts.push(`${stats.byType['typeversion-correction']} version ${stats.byType['typeversion-correction'] === 1 ? 'issue' : 'issues'}`); + } + 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['enum-value'] > 0) { + parts.push(`${stats.byType['enum-value']} invalid ${stats.byType['enum-value'] === 1 ? 'value' : 'values'}`); + } + + if (parts.length === 0) { + return `Fixed ${stats.total} ${stats.total === 1 ? 'issue' : 'issues'}`; + } + + return `Fixed ${parts.join(', ')}`; + } +} \ No newline at end of file diff --git a/src/services/workflow-validator.ts b/src/services/workflow-validator.ts index 76e4a9f..47d2b98 100644 --- a/src/services/workflow-validator.ts +++ b/src/services/workflow-validator.ts @@ -7,6 +7,7 @@ import { NodeRepository } from '../database/node-repository'; import { EnhancedConfigValidator } from './enhanced-config-validator'; import { ExpressionValidator } from './expression-validator'; import { ExpressionFormatValidator } from './expression-format-validator'; +import { NodeSimilarityService, NodeSuggestion } from './node-similarity-service'; import { Logger } from '../utils/logger'; const logger = new Logger({ prefix: '[WorkflowValidator]' }); @@ -73,11 +74,14 @@ export interface WorkflowValidationResult { export class WorkflowValidator { private currentWorkflow: WorkflowJson | null = null; + private similarityService: NodeSimilarityService; constructor( private nodeRepository: NodeRepository, private nodeValidator: typeof EnhancedConfigValidator - ) {} + ) { + this.similarityService = new NodeSimilarityService(nodeRepository); + } /** * Check if a node is a Sticky Note or other non-executable node @@ -392,45 +396,45 @@ export class WorkflowValidator { } if (!nodeInfo) { - // Check for common mistakes - let suggestion = ''; - - // Missing package prefix - if (node.type.startsWith('nodes-base.')) { - const withPrefix = node.type.replace('nodes-base.', 'n8n-nodes-base.'); - const exists = this.nodeRepository.getNode(withPrefix) || - this.nodeRepository.getNode(withPrefix.replace('n8n-nodes-base.', 'nodes-base.')); - if (exists) { - suggestion = ` Did you mean "n8n-nodes-base.${node.type.substring(11)}"?`; + // Use NodeSimilarityService to find suggestions + const suggestions = await this.similarityService.findSimilarNodes(node.type, 3); + + let message = `Unknown node type: "${node.type}".`; + + if (suggestions.length > 0) { + message += '\n\nDid you mean one of these?'; + for (const suggestion of suggestions) { + const confidence = Math.round(suggestion.confidence * 100); + message += `\n• ${suggestion.nodeType} (${confidence}% match)`; + if (suggestion.displayName) { + message += ` - ${suggestion.displayName}`; + } + message += `\n → ${suggestion.reason}`; + if (suggestion.confidence >= 0.9) { + message += ' (can be auto-fixed)'; + } } + } else { + message += ' No similar nodes found. Node types must include the package prefix (e.g., "n8n-nodes-base.webhook").'; } - // Check if it's just the node name without package - else if (!node.type.includes('.')) { - // Try common node names - const commonNodes = [ - 'webhook', 'httpRequest', 'set', 'code', 'manualTrigger', - 'scheduleTrigger', 'emailSend', 'slack', 'discord' - ]; - - if (commonNodes.includes(node.type)) { - suggestion = ` Did you mean "n8n-nodes-base.${node.type}"?`; - } - } - - // If no specific suggestion, try to find similar nodes - if (!suggestion) { - const similarNodes = this.findSimilarNodeTypes(node.type); - if (similarNodes.length > 0) { - suggestion = ` Did you mean: ${similarNodes.map(n => `"${n}"`).join(', ')}?`; - } - } - - result.errors.push({ + + const error: any = { type: 'error', nodeId: node.id, nodeName: node.name, - message: `Unknown node type: "${node.type}".${suggestion} Node types must include the package prefix (e.g., "n8n-nodes-base.webhook", not "webhook" or "nodes-base.webhook").` - }); + message + }; + + // Add suggestions as metadata for programmatic access + if (suggestions.length > 0) { + error.suggestions = suggestions.map(s => ({ + nodeType: s.nodeType, + confidence: s.confidence, + reason: s.reason + })); + } + + result.errors.push(error); continue; } @@ -1205,65 +1209,6 @@ export class WorkflowValidator { return maxChain; } - /** - * Find similar node types for suggestions - */ - private findSimilarNodeTypes(invalidType: string): string[] { - // Since we don't have a method to list all nodes, we'll use a predefined list - // of common node types that users might be looking for - const suggestions: string[] = []; - const nodeName = invalidType.includes('.') ? invalidType.split('.').pop()! : invalidType; - - const commonNodeMappings: Record = { - 'webhook': ['nodes-base.webhook'], - 'httpRequest': ['nodes-base.httpRequest'], - 'http': ['nodes-base.httpRequest'], - 'set': ['nodes-base.set'], - 'code': ['nodes-base.code'], - 'manualTrigger': ['nodes-base.manualTrigger'], - 'manual': ['nodes-base.manualTrigger'], - 'scheduleTrigger': ['nodes-base.scheduleTrigger'], - 'schedule': ['nodes-base.scheduleTrigger'], - 'cron': ['nodes-base.scheduleTrigger'], - 'emailSend': ['nodes-base.emailSend'], - 'email': ['nodes-base.emailSend'], - 'slack': ['nodes-base.slack'], - 'discord': ['nodes-base.discord'], - 'postgres': ['nodes-base.postgres'], - 'mysql': ['nodes-base.mySql'], - 'mongodb': ['nodes-base.mongoDb'], - 'redis': ['nodes-base.redis'], - 'if': ['nodes-base.if'], - 'switch': ['nodes-base.switch'], - 'merge': ['nodes-base.merge'], - 'splitInBatches': ['nodes-base.splitInBatches'], - 'loop': ['nodes-base.splitInBatches'], - 'googleSheets': ['nodes-base.googleSheets'], - 'sheets': ['nodes-base.googleSheets'], - 'airtable': ['nodes-base.airtable'], - 'github': ['nodes-base.github'], - 'git': ['nodes-base.github'], - }; - - // Check for exact match - const lowerNodeName = nodeName.toLowerCase(); - if (commonNodeMappings[lowerNodeName]) { - suggestions.push(...commonNodeMappings[lowerNodeName]); - } - - // Check for partial matches - Object.entries(commonNodeMappings).forEach(([key, values]) => { - if (key.includes(lowerNodeName) || lowerNodeName.includes(key)) { - values.forEach(v => { - if (!suggestions.includes(v)) { - suggestions.push(v); - } - }); - } - }); - - return suggestions.slice(0, 3); // Return top 3 suggestions - } /** * Generate suggestions based on validation results