Spaces:
Sleeping
Sleeping
| /** | |
| * Workflow Validator for n8n workflows | |
| * Validates complete workflow structure, connections, and node configurations | |
| */ | |
| import crypto from 'crypto'; | |
| 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 { NodeTypeNormalizer } from '../utils/node-type-normalizer'; | |
| import { Logger } from '../utils/logger'; | |
| import { validateAISpecificNodes, hasAINodes } from './ai-node-validator'; | |
| import { isAIToolSubNode } from './ai-tool-validators'; | |
| import { isTriggerNode } from '../utils/node-type-utils'; | |
| import { isNonExecutableNode } from '../utils/node-classification'; | |
| import { ToolVariantGenerator } from './tool-variant-generator'; | |
| const logger = new Logger({ prefix: '[WorkflowValidator]' }); | |
| interface WorkflowNode { | |
| id: string; | |
| name: string; | |
| type: string; | |
| position: [number, number]; | |
| parameters: any; | |
| credentials?: any; | |
| disabled?: boolean; | |
| notes?: string; | |
| notesInFlow?: boolean; | |
| typeVersion?: number; | |
| continueOnFail?: boolean; | |
| onError?: 'continueRegularOutput' | 'continueErrorOutput' | 'stopWorkflow'; | |
| retryOnFail?: boolean; | |
| maxTries?: number; | |
| waitBetweenTries?: number; | |
| alwaysOutputData?: boolean; | |
| executeOnce?: boolean; | |
| } | |
| interface WorkflowConnection { | |
| [sourceNode: string]: { | |
| main?: Array<Array<{ node: string; type: string; index: number }>>; | |
| error?: Array<Array<{ node: string; type: string; index: number }>>; | |
| ai_tool?: Array<Array<{ node: string; type: string; index: number }>>; | |
| }; | |
| } | |
| interface WorkflowJson { | |
| name?: string; | |
| nodes: WorkflowNode[]; | |
| connections: WorkflowConnection; | |
| settings?: any; | |
| staticData?: any; | |
| pinData?: any; | |
| meta?: any; | |
| } | |
| export interface ValidationIssue { | |
| type: 'error' | 'warning'; | |
| nodeId?: string; | |
| nodeName?: string; | |
| message: string; | |
| details?: any; | |
| code?: string; | |
| fix?: { | |
| type: string; | |
| currentType?: string; | |
| suggestedType?: string; | |
| description?: string; | |
| }; | |
| } | |
| export interface WorkflowValidationResult { | |
| valid: boolean; | |
| errors: ValidationIssue[]; | |
| warnings: ValidationIssue[]; | |
| statistics: { | |
| totalNodes: number; | |
| enabledNodes: number; | |
| triggerNodes: number; | |
| validConnections: number; | |
| invalidConnections: number; | |
| expressionsValidated: number; | |
| }; | |
| suggestions: string[]; | |
| } | |
| export class WorkflowValidator { | |
| private currentWorkflow: WorkflowJson | null = null; | |
| private similarityService: NodeSimilarityService; | |
| constructor( | |
| private nodeRepository: NodeRepository, | |
| private nodeValidator: typeof EnhancedConfigValidator | |
| ) { | |
| this.similarityService = new NodeSimilarityService(nodeRepository); | |
| } | |
| // Note: isStickyNote logic moved to shared utility: src/utils/node-classification.ts | |
| // Use isNonExecutableNode(node.type) instead | |
| /** | |
| * Validate a complete workflow | |
| */ | |
| async validateWorkflow( | |
| workflow: WorkflowJson, | |
| options: { | |
| validateNodes?: boolean; | |
| validateConnections?: boolean; | |
| validateExpressions?: boolean; | |
| profile?: 'minimal' | 'runtime' | 'ai-friendly' | 'strict'; | |
| } = {} | |
| ): Promise<WorkflowValidationResult> { | |
| // Store current workflow for access in helper methods | |
| this.currentWorkflow = workflow; | |
| const { | |
| validateNodes = true, | |
| validateConnections = true, | |
| validateExpressions = true, | |
| profile = 'runtime' | |
| } = options; | |
| const result: WorkflowValidationResult = { | |
| valid: true, | |
| errors: [], | |
| warnings: [], | |
| statistics: { | |
| totalNodes: 0, | |
| enabledNodes: 0, | |
| triggerNodes: 0, | |
| validConnections: 0, | |
| invalidConnections: 0, | |
| expressionsValidated: 0, | |
| }, | |
| suggestions: [] | |
| }; | |
| try { | |
| // Handle null/undefined workflow | |
| if (!workflow) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: 'Invalid workflow structure: workflow is null or undefined' | |
| }); | |
| result.valid = false; | |
| return result; | |
| } | |
| // Update statistics after null check (exclude sticky notes from counts) | |
| const executableNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !isNonExecutableNode(n.type)) : []; | |
| result.statistics.totalNodes = executableNodes.length; | |
| result.statistics.enabledNodes = executableNodes.filter(n => !n.disabled).length; | |
| // Basic workflow structure validation | |
| this.validateWorkflowStructure(workflow, result); | |
| // Only continue if basic structure is valid | |
| if (workflow.nodes && Array.isArray(workflow.nodes) && workflow.connections && typeof workflow.connections === 'object') { | |
| // Validate each node if requested | |
| if (validateNodes && workflow.nodes.length > 0) { | |
| await this.validateAllNodes(workflow, result, profile); | |
| } | |
| // Validate connections if requested | |
| if (validateConnections) { | |
| this.validateConnections(workflow, result, profile); | |
| } | |
| // Validate expressions if requested | |
| if (validateExpressions && workflow.nodes.length > 0) { | |
| this.validateExpressions(workflow, result, profile); | |
| } | |
| // Check workflow patterns and best practices | |
| if (workflow.nodes.length > 0) { | |
| this.checkWorkflowPatterns(workflow, result, profile); | |
| } | |
| // Validate AI-specific nodes (AI Agent, Chat Trigger, AI tools) | |
| if (workflow.nodes.length > 0 && hasAINodes(workflow)) { | |
| const aiIssues = validateAISpecificNodes(workflow); | |
| // Convert AI validation issues to workflow validation format | |
| for (const issue of aiIssues) { | |
| const validationIssue: ValidationIssue = { | |
| type: issue.severity === 'error' ? 'error' : 'warning', | |
| nodeId: issue.nodeId, | |
| nodeName: issue.nodeName, | |
| message: issue.message, | |
| details: issue.code ? { code: issue.code } : undefined | |
| }; | |
| if (issue.severity === 'error') { | |
| result.errors.push(validationIssue); | |
| } else { | |
| result.warnings.push(validationIssue); | |
| } | |
| } | |
| } | |
| // Add suggestions based on findings | |
| this.generateSuggestions(workflow, result); | |
| // Add AI-specific recovery suggestions if there are errors | |
| if (result.errors.length > 0) { | |
| this.addErrorRecoverySuggestions(result); | |
| } | |
| } | |
| } catch (error) { | |
| logger.error('Error validating workflow:', error); | |
| result.errors.push({ | |
| type: 'error', | |
| message: `Workflow validation failed: ${error instanceof Error ? error.message : 'Unknown error'}` | |
| }); | |
| } | |
| result.valid = result.errors.length === 0; | |
| return result; | |
| } | |
| /** | |
| * Validate basic workflow structure | |
| */ | |
| private validateWorkflowStructure( | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult | |
| ): void { | |
| // Check for required fields | |
| if (!workflow.nodes) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: workflow.nodes === null ? 'nodes must be an array' : 'Workflow must have a nodes array' | |
| }); | |
| return; | |
| } | |
| if (!Array.isArray(workflow.nodes)) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: 'nodes must be an array' | |
| }); | |
| return; | |
| } | |
| if (!workflow.connections) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: workflow.connections === null ? 'connections must be an object' : 'Workflow must have a connections object' | |
| }); | |
| return; | |
| } | |
| if (typeof workflow.connections !== 'object' || Array.isArray(workflow.connections)) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: 'connections must be an object' | |
| }); | |
| return; | |
| } | |
| // Check for empty workflow - this should be a warning, not an error | |
| if (workflow.nodes.length === 0) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| message: 'Workflow is empty - no nodes defined' | |
| }); | |
| return; | |
| } | |
| // Check for minimum viable workflow | |
| if (workflow.nodes.length === 1) { | |
| const singleNode = workflow.nodes[0]; | |
| const normalizedType = NodeTypeNormalizer.normalizeToFullForm(singleNode.type); | |
| const isWebhook = normalizedType === 'nodes-base.webhook' || | |
| normalizedType === 'nodes-base.webhookTrigger'; | |
| const isLangchainNode = normalizedType.startsWith('nodes-langchain.'); | |
| // Langchain nodes can be validated standalone for AI tool purposes | |
| if (!isWebhook && !isLangchainNode) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: 'Single-node workflows are only valid for webhook endpoints. Add at least one more connected node to create a functional workflow.' | |
| }); | |
| } else if (isWebhook && Object.keys(workflow.connections).length === 0) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| message: 'Webhook node has no connections. Consider adding nodes to process the webhook data.' | |
| }); | |
| } | |
| } | |
| // Check for empty connections in multi-node workflows | |
| if (workflow.nodes.length > 1) { | |
| const hasEnabledNodes = workflow.nodes.some(n => !n.disabled); | |
| const hasConnections = Object.keys(workflow.connections).length > 0; | |
| if (hasEnabledNodes && !hasConnections) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: 'Multi-node workflow has no connections. Nodes must be connected to create a workflow. Use connections: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }' | |
| }); | |
| } | |
| } | |
| // Check for duplicate node names | |
| const nodeNames = new Set<string>(); | |
| const nodeIds = new Set<string>(); | |
| const nodeIdToIndex = new Map<string, number>(); // Track which node index has which ID | |
| for (let i = 0; i < workflow.nodes.length; i++) { | |
| const node = workflow.nodes[i]; | |
| if (nodeNames.has(node.name)) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Duplicate node name: "${node.name}"` | |
| }); | |
| } | |
| nodeNames.add(node.name); | |
| if (nodeIds.has(node.id)) { | |
| const firstNodeIndex = nodeIdToIndex.get(node.id); | |
| const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined; | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}"). Each node must have a unique ID. Generate a new UUID using crypto.randomUUID() - Example: {id: "${crypto.randomUUID()}", name: "${node.name}", type: "${node.type}", ...}` | |
| }); | |
| } else { | |
| nodeIds.add(node.id); | |
| nodeIdToIndex.set(node.id, i); | |
| } | |
| } | |
| // Count trigger nodes using shared trigger detection | |
| const triggerNodes = workflow.nodes.filter(n => isTriggerNode(n.type)); | |
| result.statistics.triggerNodes = triggerNodes.length; | |
| // Check for at least one trigger node | |
| if (triggerNodes.length === 0 && workflow.nodes.filter(n => !n.disabled).length > 0) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| message: 'Workflow has no trigger nodes. It can only be executed manually.' | |
| }); | |
| } | |
| } | |
| /** | |
| * Validate all nodes in the workflow | |
| */ | |
| private async validateAllNodes( | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult, | |
| profile: string | |
| ): Promise<void> { | |
| for (const node of workflow.nodes) { | |
| if (node.disabled || isNonExecutableNode(node.type)) continue; | |
| try { | |
| // Validate node name length | |
| if (node.name && node.name.length > 255) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Node name is very long (${node.name.length} characters). Consider using a shorter name for better readability.` | |
| }); | |
| } | |
| // Validate node position | |
| if (!Array.isArray(node.position) || node.position.length !== 2) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'Node position must be an array with exactly 2 numbers [x, y]' | |
| }); | |
| } else { | |
| const [x, y] = node.position; | |
| if (typeof x !== 'number' || typeof y !== 'number' || | |
| !isFinite(x) || !isFinite(y)) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'Node position values must be finite numbers' | |
| }); | |
| } | |
| } | |
| // Normalize node type for database lookup (DO NOT mutate the original workflow) | |
| // The normalizer converts to short form (nodes-base.*) for database queries, | |
| // but n8n API requires full form (n8n-nodes-base.*). Never modify the input workflow. | |
| const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type); | |
| // Get node definition using normalized type (needed for typeVersion validation) | |
| const nodeInfo = this.nodeRepository.getNode(normalizedType); | |
| if (!nodeInfo) { | |
| // 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").'; | |
| } | |
| const error: any = { | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| 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; | |
| } | |
| // Validate typeVersion for ALL versioned nodes (including langchain nodes) | |
| // CRITICAL: This MUST run BEFORE the langchain skip below! | |
| // Otherwise, langchain nodes with invalid typeVersion (e.g., 99999) would pass validation | |
| // but fail at runtime in n8n. This was the bug fixed in v2.17.4. | |
| if (nodeInfo.isVersioned) { | |
| // Check if typeVersion is missing | |
| if (!node.typeVersion) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Missing required property 'typeVersion'. Add typeVersion: ${nodeInfo.version || 1}` | |
| }); | |
| } | |
| // Check if typeVersion is invalid (must be non-negative number, version 0 is valid) | |
| else if (typeof node.typeVersion !== 'number' || node.typeVersion < 0) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Invalid typeVersion: ${node.typeVersion}. Must be a non-negative number` | |
| }); | |
| } | |
| // Check if typeVersion is outdated (less than latest) | |
| else if (nodeInfo.version && node.typeVersion < nodeInfo.version) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Outdated typeVersion: ${node.typeVersion}. Latest is ${nodeInfo.version}` | |
| }); | |
| } | |
| // Check if typeVersion exceeds maximum supported | |
| else if (nodeInfo.version && node.typeVersion > nodeInfo.version) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `typeVersion ${node.typeVersion} exceeds maximum supported version ${nodeInfo.version}` | |
| }); | |
| } | |
| } | |
| // Skip PARAMETER validation for langchain nodes (but NOT typeVersion validation above!) | |
| // Langchain nodes have dedicated AI-specific validators in validateAISpecificNodes() | |
| // which handle their unique parameter structures (AI connections, tool ports, etc.) | |
| if (normalizedType.startsWith('nodes-langchain.')) { | |
| continue; | |
| } | |
| // Validate node configuration | |
| // Add @version to parameters for displayOptions evaluation (supports _cnd operators) | |
| const paramsWithVersion = { | |
| '@version': node.typeVersion || 1, | |
| ...node.parameters | |
| }; | |
| const nodeValidation = this.nodeValidator.validateWithMode( | |
| node.type, | |
| paramsWithVersion, | |
| nodeInfo.properties || [], | |
| 'operation', | |
| profile as any | |
| ); | |
| // Add node-specific errors and warnings | |
| nodeValidation.errors.forEach((error: any) => { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: typeof error === 'string' ? error : error.message || String(error) | |
| }); | |
| }); | |
| nodeValidation.warnings.forEach((warning: any) => { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: typeof warning === 'string' ? warning : warning.message || String(warning) | |
| }); | |
| }); | |
| } catch (error) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Failed to validate node: ${error instanceof Error ? error.message : 'Unknown error'}` | |
| }); | |
| } | |
| } | |
| } | |
| /** | |
| * Validate workflow connections | |
| */ | |
| private validateConnections( | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult, | |
| profile: string = 'runtime' | |
| ): void { | |
| const nodeMap = new Map(workflow.nodes.map(n => [n.name, n])); | |
| const nodeIdMap = new Map(workflow.nodes.map(n => [n.id, n])); | |
| // Check all connections | |
| for (const [sourceName, outputs] of Object.entries(workflow.connections)) { | |
| const sourceNode = nodeMap.get(sourceName); | |
| if (!sourceNode) { | |
| // Check if this is an ID being used instead of a name | |
| const nodeById = nodeIdMap.get(sourceName); | |
| if (nodeById) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: nodeById.id, | |
| nodeName: nodeById.name, | |
| message: `Connection uses node ID '${sourceName}' instead of node name '${nodeById.name}'. In n8n, connections must use node names, not IDs.` | |
| }); | |
| } else { | |
| result.errors.push({ | |
| type: 'error', | |
| message: `Connection from non-existent node: "${sourceName}"` | |
| }); | |
| } | |
| result.statistics.invalidConnections++; | |
| continue; | |
| } | |
| // Check main outputs | |
| if (outputs.main) { | |
| this.validateConnectionOutputs( | |
| sourceName, | |
| outputs.main, | |
| nodeMap, | |
| nodeIdMap, | |
| result, | |
| 'main' | |
| ); | |
| } | |
| // Check error outputs | |
| if (outputs.error) { | |
| this.validateConnectionOutputs( | |
| sourceName, | |
| outputs.error, | |
| nodeMap, | |
| nodeIdMap, | |
| result, | |
| 'error' | |
| ); | |
| } | |
| // Check AI tool outputs | |
| if (outputs.ai_tool) { | |
| // Validate that the source node can actually output ai_tool | |
| this.validateAIToolSource(sourceNode, result); | |
| this.validateConnectionOutputs( | |
| sourceName, | |
| outputs.ai_tool, | |
| nodeMap, | |
| nodeIdMap, | |
| result, | |
| 'ai_tool' | |
| ); | |
| } | |
| } | |
| // Check for orphaned nodes (not connected and not triggers) | |
| const connectedNodes = new Set<string>(); | |
| // Add all source nodes | |
| Object.keys(workflow.connections).forEach(name => connectedNodes.add(name)); | |
| // Add all target nodes | |
| Object.values(workflow.connections).forEach(outputs => { | |
| if (outputs.main) { | |
| outputs.main.flat().forEach(conn => { | |
| if (conn) connectedNodes.add(conn.node); | |
| }); | |
| } | |
| if (outputs.error) { | |
| outputs.error.flat().forEach(conn => { | |
| if (conn) connectedNodes.add(conn.node); | |
| }); | |
| } | |
| if (outputs.ai_tool) { | |
| outputs.ai_tool.flat().forEach(conn => { | |
| if (conn) connectedNodes.add(conn.node); | |
| }); | |
| } | |
| }); | |
| // Check for orphaned nodes (exclude sticky notes) | |
| for (const node of workflow.nodes) { | |
| if (node.disabled || isNonExecutableNode(node.type)) continue; | |
| // Use shared trigger detection function for consistency | |
| const isNodeTrigger = isTriggerNode(node.type); | |
| if (!connectedNodes.has(node.name) && !isNodeTrigger) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'Node is not connected to any other nodes' | |
| }); | |
| } | |
| } | |
| // Check for cycles (skip in minimal profile to reduce false positives) | |
| if (profile !== 'minimal' && this.hasCycle(workflow)) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: 'Workflow contains a cycle (infinite loop)' | |
| }); | |
| } | |
| } | |
| /** | |
| * Validate connection outputs | |
| */ | |
| private validateConnectionOutputs( | |
| sourceName: string, | |
| outputs: Array<Array<{ node: string; type: string; index: number }>>, | |
| nodeMap: Map<string, WorkflowNode>, | |
| nodeIdMap: Map<string, WorkflowNode>, | |
| result: WorkflowValidationResult, | |
| outputType: 'main' | 'error' | 'ai_tool' | |
| ): void { | |
| // Get source node for special validation | |
| const sourceNode = nodeMap.get(sourceName); | |
| // Special validation for main outputs with error handling | |
| if (outputType === 'main' && sourceNode) { | |
| this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result); | |
| } | |
| outputs.forEach((outputConnections, outputIndex) => { | |
| if (!outputConnections) return; | |
| outputConnections.forEach(connection => { | |
| // Check for negative index | |
| if (connection.index < 0) { | |
| result.errors.push({ | |
| type: 'error', | |
| message: `Invalid connection index ${connection.index} from "${sourceName}". Connection indices must be non-negative.` | |
| }); | |
| result.statistics.invalidConnections++; | |
| return; | |
| } | |
| // Special validation for SplitInBatches node | |
| // Check both full form (n8n-nodes-base.*) and short form (nodes-base.*) | |
| const isSplitInBatches = sourceNode && ( | |
| sourceNode.type === 'n8n-nodes-base.splitInBatches' || | |
| sourceNode.type === 'nodes-base.splitInBatches' | |
| ); | |
| if (isSplitInBatches) { | |
| this.validateSplitInBatchesConnection( | |
| sourceNode, | |
| outputIndex, | |
| connection, | |
| nodeMap, | |
| result | |
| ); | |
| } | |
| // Check for self-referencing connections | |
| if (connection.node === sourceName) { | |
| // This is only a warning for non-loop nodes (not SplitInBatches) | |
| if (sourceNode && !isSplitInBatches) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.` | |
| }); | |
| } | |
| } | |
| const targetNode = nodeMap.get(connection.node); | |
| if (!targetNode) { | |
| // Check if this is an ID being used instead of a name | |
| const nodeById = nodeIdMap.get(connection.node); | |
| if (nodeById) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: nodeById.id, | |
| nodeName: nodeById.name, | |
| message: `Connection target uses node ID '${connection.node}' instead of node name '${nodeById.name}' (from ${sourceName}). In n8n, connections must use node names, not IDs.` | |
| }); | |
| } else { | |
| result.errors.push({ | |
| type: 'error', | |
| message: `Connection to non-existent node: "${connection.node}" from "${sourceName}"` | |
| }); | |
| } | |
| result.statistics.invalidConnections++; | |
| } else if (targetNode.disabled) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| message: `Connection to disabled node: "${connection.node}" from "${sourceName}"` | |
| }); | |
| } else { | |
| result.statistics.validConnections++; | |
| // Additional validation for AI tool connections | |
| if (outputType === 'ai_tool') { | |
| this.validateAIToolConnection(sourceName, targetNode, result); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Validate error output configuration | |
| */ | |
| private validateErrorOutputConfiguration( | |
| sourceName: string, | |
| sourceNode: WorkflowNode, | |
| outputs: Array<Array<{ node: string; type: string; index: number }>>, | |
| nodeMap: Map<string, WorkflowNode>, | |
| result: WorkflowValidationResult | |
| ): void { | |
| // Check if node has onError: 'continueErrorOutput' | |
| const hasErrorOutputSetting = sourceNode.onError === 'continueErrorOutput'; | |
| const hasErrorConnections = outputs.length > 1 && outputs[1] && outputs[1].length > 0; | |
| // Validate mismatch between onError setting and connections | |
| if (hasErrorOutputSetting && !hasErrorConnections) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `Node has onError: 'continueErrorOutput' but no error output connections in main[1]. Add error handler connections to main[1] or change onError to 'continueRegularOutput' or 'stopWorkflow'.` | |
| }); | |
| } | |
| if (!hasErrorOutputSetting && hasErrorConnections) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `Node has error output connections in main[1] but missing onError: 'continueErrorOutput'. Add this property to properly handle errors.` | |
| }); | |
| } | |
| // Check for common mistake: multiple nodes in main[0] when error handling is intended | |
| if (outputs.length >= 1 && outputs[0] && outputs[0].length > 1) { | |
| // Check if any of the nodes in main[0] look like error handlers | |
| const potentialErrorHandlers = outputs[0].filter(conn => { | |
| const targetNode = nodeMap.get(conn.node); | |
| if (!targetNode) return false; | |
| const nodeName = targetNode.name.toLowerCase(); | |
| const nodeType = targetNode.type.toLowerCase(); | |
| // Common patterns for error handler nodes | |
| return nodeName.includes('error') || | |
| nodeName.includes('fail') || | |
| nodeName.includes('catch') || | |
| nodeName.includes('exception') || | |
| nodeType.includes('respondtowebhook') || | |
| nodeType.includes('emailsend'); | |
| }); | |
| if (potentialErrorHandlers.length > 0) { | |
| const errorHandlerNames = potentialErrorHandlers.map(conn => `"${conn.node}"`).join(', '); | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `Incorrect error output configuration. Nodes ${errorHandlerNames} appear to be error handlers but are in main[0] (success output) along with other nodes.\n\n` + | |
| `INCORRECT (current):\n` + | |
| `"${sourceName}": {\n` + | |
| ` "main": [\n` + | |
| ` [ // main[0] has multiple nodes mixed together\n` + | |
| outputs[0].map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' + | |
| ` ]\n` + | |
| ` ]\n` + | |
| `}\n\n` + | |
| `CORRECT (should be):\n` + | |
| `"${sourceName}": {\n` + | |
| ` "main": [\n` + | |
| ` [ // main[0] = success output\n` + | |
| outputs[0].filter(conn => !potentialErrorHandlers.includes(conn)).map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' + | |
| ` ],\n` + | |
| ` [ // main[1] = error output\n` + | |
| potentialErrorHandlers.map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' + | |
| ` ]\n` + | |
| ` ]\n` + | |
| `}\n\n` + | |
| `Also add: "onError": "continueErrorOutput" to the "${sourceName}" node.` | |
| }); | |
| } | |
| } | |
| } | |
| /** | |
| * Validate AI tool connections | |
| */ | |
| private validateAIToolConnection( | |
| sourceName: string, | |
| targetNode: WorkflowNode, | |
| result: WorkflowValidationResult | |
| ): void { | |
| // For AI tool connections, we just need to check if this is being used as a tool | |
| // The source should be an AI Agent connecting to this target node as a tool | |
| // Get target node info to check if it can be used as a tool | |
| const normalizedType = NodeTypeNormalizer.normalizeToFullForm(targetNode.type); | |
| let targetNodeInfo = this.nodeRepository.getNode(normalizedType); | |
| // Try original type if normalization didn't help (fallback for edge cases) | |
| if (!targetNodeInfo && normalizedType !== targetNode.type) { | |
| targetNodeInfo = this.nodeRepository.getNode(targetNode.type); | |
| } | |
| if (targetNodeInfo && !targetNodeInfo.isAITool && targetNodeInfo.package !== 'n8n-nodes-base') { | |
| // It's a community node being used as a tool | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: targetNode.id, | |
| nodeName: targetNode.name, | |
| message: `Community node "${targetNode.name}" is being used as an AI tool. Ensure N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true is set.` | |
| }); | |
| } | |
| } | |
| /** | |
| * Validate that a node can actually output ai_tool connections. | |
| * | |
| * Valid ai_tool sources are: | |
| * 1. Langchain tool nodes (in AI_TOOL_VALIDATORS) | |
| * 2. Tool variant nodes (e.g., nodes-base.supabaseTool) | |
| * | |
| * If a base node (e.g., nodes-base.supabase) is used with ai_tool connection | |
| * but it has a Tool variant available, this is an error. | |
| */ | |
| private validateAIToolSource( | |
| sourceNode: WorkflowNode, | |
| result: WorkflowValidationResult | |
| ): void { | |
| const normalizedType = NodeTypeNormalizer.normalizeToFullForm(sourceNode.type); | |
| // Check if it's a known langchain tool node | |
| if (isAIToolSubNode(normalizedType)) { | |
| return; // Valid - it's a langchain tool | |
| } | |
| // Get node info from repository (single lookup, reused below) | |
| const nodeInfo = this.nodeRepository.getNode(normalizedType); | |
| // Check if it's a Tool variant (ends with Tool and is in database as isToolVariant) | |
| if (ToolVariantGenerator.isToolVariantNodeType(normalizedType)) { | |
| // It looks like a Tool variant, verify it exists in database | |
| if (nodeInfo?.isToolVariant) { | |
| return; // Valid - it's a Tool variant | |
| } | |
| } | |
| if (!nodeInfo) { | |
| // Node not found in database - might be a community node or unknown | |
| // Don't error here, let other validation handle unknown nodes | |
| return; | |
| } | |
| // Check if this is a base node that has a Tool variant available | |
| if (nodeInfo.hasToolVariant) { | |
| const toolVariantType = ToolVariantGenerator.getToolVariantNodeType(normalizedType); | |
| const workflowToolVariantType = NodeTypeNormalizer.toWorkflowFormat(toolVariantType); | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `Node "${sourceNode.name}" uses "${sourceNode.type}" which cannot output ai_tool connections. ` + | |
| `Use the Tool variant "${workflowToolVariantType}" instead for AI Agent integration.`, | |
| code: 'WRONG_NODE_TYPE_FOR_AI_TOOL', | |
| fix: { | |
| type: 'tool-variant-correction', | |
| currentType: sourceNode.type, | |
| suggestedType: workflowToolVariantType, | |
| description: `Change node type from "${sourceNode.type}" to "${workflowToolVariantType}"` | |
| } | |
| }); | |
| return; | |
| } | |
| // Check if it's an AI-capable node (isAITool flag) but not a Tool variant | |
| if (nodeInfo.isAITool) { | |
| // This node is AI-capable, which is fine for ai_tool connections | |
| return; | |
| } | |
| // Node is not valid for ai_tool connections | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `Node "${sourceNode.name}" of type "${sourceNode.type}" cannot output ai_tool connections. ` + | |
| `Only AI tool nodes (e.g., Calculator, HTTP Request Tool) or Tool variants (e.g., *Tool suffix nodes) can be connected to AI Agents as tools.`, | |
| code: 'INVALID_AI_TOOL_SOURCE' | |
| }); | |
| } | |
| /** | |
| * Check if workflow has cycles | |
| * Allow legitimate loops for SplitInBatches and similar loop nodes | |
| */ | |
| private hasCycle(workflow: WorkflowJson): boolean { | |
| const visited = new Set<string>(); | |
| const recursionStack = new Set<string>(); | |
| const nodeTypeMap = new Map<string, string>(); | |
| // Build node type map (exclude sticky notes) | |
| workflow.nodes.forEach(node => { | |
| if (!isNonExecutableNode(node.type)) { | |
| nodeTypeMap.set(node.name, node.type); | |
| } | |
| }); | |
| // Known legitimate loop node types | |
| const loopNodeTypes = [ | |
| 'n8n-nodes-base.splitInBatches', | |
| 'nodes-base.splitInBatches', | |
| 'n8n-nodes-base.itemLists', | |
| 'nodes-base.itemLists', | |
| 'n8n-nodes-base.loop', | |
| 'nodes-base.loop' | |
| ]; | |
| const hasCycleDFS = (nodeName: string, pathFromLoopNode: boolean = false): boolean => { | |
| visited.add(nodeName); | |
| recursionStack.add(nodeName); | |
| const connections = workflow.connections[nodeName]; | |
| if (connections) { | |
| const allTargets: string[] = []; | |
| if (connections.main) { | |
| connections.main.flat().forEach(conn => { | |
| if (conn) allTargets.push(conn.node); | |
| }); | |
| } | |
| if (connections.error) { | |
| connections.error.flat().forEach(conn => { | |
| if (conn) allTargets.push(conn.node); | |
| }); | |
| } | |
| if (connections.ai_tool) { | |
| connections.ai_tool.flat().forEach(conn => { | |
| if (conn) allTargets.push(conn.node); | |
| }); | |
| } | |
| const currentNodeType = nodeTypeMap.get(nodeName); | |
| const isLoopNode = loopNodeTypes.includes(currentNodeType || ''); | |
| for (const target of allTargets) { | |
| if (!visited.has(target)) { | |
| if (hasCycleDFS(target, pathFromLoopNode || isLoopNode)) return true; | |
| } else if (recursionStack.has(target)) { | |
| // Allow cycles that involve legitimate loop nodes | |
| const targetNodeType = nodeTypeMap.get(target); | |
| const isTargetLoopNode = loopNodeTypes.includes(targetNodeType || ''); | |
| // If this cycle involves a loop node, it's legitimate | |
| if (isTargetLoopNode || pathFromLoopNode || isLoopNode) { | |
| continue; // Allow this cycle | |
| } | |
| return true; // Reject other cycles | |
| } | |
| } | |
| } | |
| recursionStack.delete(nodeName); | |
| return false; | |
| }; | |
| // Check from all executable nodes (exclude sticky notes) | |
| for (const node of workflow.nodes) { | |
| if (!isNonExecutableNode(node.type) && !visited.has(node.name)) { | |
| if (hasCycleDFS(node.name)) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Validate expressions in the workflow | |
| */ | |
| private validateExpressions( | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult, | |
| profile: string = 'runtime' | |
| ): void { | |
| const nodeNames = workflow.nodes.map(n => n.name); | |
| for (const node of workflow.nodes) { | |
| if (node.disabled || isNonExecutableNode(node.type)) continue; | |
| // Skip expression validation for langchain nodes | |
| // They have AI-specific validators and different expression rules | |
| const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type); | |
| if (normalizedType.startsWith('nodes-langchain.')) { | |
| continue; | |
| } | |
| // Create expression context | |
| const context = { | |
| availableNodes: nodeNames.filter(n => n !== node.name), | |
| currentNodeName: node.name, | |
| hasInputData: this.nodeHasInput(node.name, workflow), | |
| isInLoop: false // Could be enhanced to detect loop nodes | |
| }; | |
| // Validate expressions in parameters | |
| const exprValidation = ExpressionValidator.validateNodeExpressions( | |
| node.parameters, | |
| context | |
| ); | |
| // Count actual expressions found, not just unique variables | |
| const expressionCount = this.countExpressionsInObject(node.parameters); | |
| result.statistics.expressionsValidated += expressionCount; | |
| // Add expression errors and warnings | |
| exprValidation.errors.forEach(error => { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Expression error: ${error}` | |
| }); | |
| }); | |
| exprValidation.warnings.forEach(warning => { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Expression warning: ${warning}` | |
| }); | |
| }); | |
| // Validate expression format (check for missing = prefix and resource locator format) | |
| const formatContext = { | |
| nodeType: node.type, | |
| nodeName: node.name, | |
| nodeId: node.id | |
| }; | |
| const formatIssues = ExpressionFormatValidator.validateNodeParameters( | |
| node.parameters, | |
| formatContext | |
| ); | |
| // Add format errors and warnings | |
| formatIssues.forEach(issue => { | |
| const formattedMessage = ExpressionFormatValidator.formatErrorMessage(issue, formatContext); | |
| if (issue.severity === 'error') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: formattedMessage | |
| }); | |
| } else { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: formattedMessage | |
| }); | |
| } | |
| }); | |
| } | |
| } | |
| /** | |
| * Count expressions in an object recursively | |
| */ | |
| private countExpressionsInObject(obj: any): number { | |
| let count = 0; | |
| if (typeof obj === 'string') { | |
| // Count expressions in string | |
| const matches = obj.match(/\{\{[\s\S]+?\}\}/g); | |
| if (matches) { | |
| count += matches.length; | |
| } | |
| } else if (Array.isArray(obj)) { | |
| // Recursively count in arrays | |
| for (const item of obj) { | |
| count += this.countExpressionsInObject(item); | |
| } | |
| } else if (obj && typeof obj === 'object') { | |
| // Recursively count in objects | |
| for (const value of Object.values(obj)) { | |
| count += this.countExpressionsInObject(value); | |
| } | |
| } | |
| return count; | |
| } | |
| /** | |
| * Check if a node has input connections | |
| */ | |
| private nodeHasInput(nodeName: string, workflow: WorkflowJson): boolean { | |
| for (const [sourceName, outputs] of Object.entries(workflow.connections)) { | |
| if (outputs.main) { | |
| for (const outputConnections of outputs.main) { | |
| if (outputConnections?.some(conn => conn.node === nodeName)) { | |
| return true; | |
| } | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Check workflow patterns and best practices | |
| */ | |
| private checkWorkflowPatterns( | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult, | |
| profile: string = 'runtime' | |
| ): void { | |
| // Check for error handling (n8n uses main[1] for error outputs, not outputs.error) | |
| const hasErrorHandling = Object.values(workflow.connections).some( | |
| outputs => outputs.main && outputs.main.length > 1 && outputs.main[1] && outputs.main[1].length > 0 | |
| ); | |
| // Only suggest error handling in stricter profiles | |
| if (!hasErrorHandling && workflow.nodes.length > 3 && profile !== 'minimal') { | |
| result.warnings.push({ | |
| type: 'warning', | |
| message: 'Consider adding error handling to your workflow' | |
| }); | |
| } | |
| // Check node-level error handling properties for ALL executable nodes | |
| for (const node of workflow.nodes) { | |
| if (!isNonExecutableNode(node.type)) { | |
| this.checkNodeErrorHandling(node, workflow, result); | |
| } | |
| } | |
| // Check for very long linear workflows | |
| const linearChainLength = this.getLongestLinearChain(workflow); | |
| if (linearChainLength > 10) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| message: `Long linear chain detected (${linearChainLength} nodes). Consider breaking into sub-workflows.` | |
| }); | |
| } | |
| // Generate error handling suggestions based on all nodes | |
| this.generateErrorHandlingSuggestions(workflow, result); | |
| // Check for missing credentials | |
| for (const node of workflow.nodes) { | |
| if (node.credentials && Object.keys(node.credentials).length > 0) { | |
| for (const [credType, credConfig] of Object.entries(node.credentials)) { | |
| if (!credConfig || (typeof credConfig === 'object' && !('id' in credConfig))) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Missing credentials configuration for ${credType}` | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // Check for AI Agent workflows | |
| const aiAgentNodes = workflow.nodes.filter(n => | |
| n.type.toLowerCase().includes('agent') || | |
| n.type.includes('langchain.agent') | |
| ); | |
| if (aiAgentNodes.length > 0) { | |
| // Check if AI agents have tools connected | |
| // Tools connect TO the agent, so we need to find connections where the target is the agent | |
| for (const agentNode of aiAgentNodes) { | |
| // Search all connections to find ones targeting this agent via ai_tool | |
| const hasToolConnected = Object.values(workflow.connections).some(sourceOutputs => { | |
| const aiToolConnections = sourceOutputs.ai_tool; | |
| if (!aiToolConnections) return false; | |
| return aiToolConnections.flat().some(conn => conn && conn.node === agentNode.name); | |
| }); | |
| if (!hasToolConnected) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: agentNode.id, | |
| nodeName: agentNode.name, | |
| message: 'AI Agent has no tools connected. Consider adding tools to enhance agent capabilities.' | |
| }); | |
| } | |
| } | |
| // Check for community nodes used as tools | |
| const hasAIToolConnections = Object.values(workflow.connections).some( | |
| outputs => outputs.ai_tool && outputs.ai_tool.length > 0 | |
| ); | |
| if (hasAIToolConnections) { | |
| result.suggestions.push( | |
| 'For community nodes used as AI tools, ensure N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true is set' | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * Get the longest linear chain in the workflow | |
| */ | |
| private getLongestLinearChain(workflow: WorkflowJson): number { | |
| const memo = new Map<string, number>(); | |
| const visiting = new Set<string>(); | |
| const getChainLength = (nodeName: string): number => { | |
| // If we're already visiting this node, we have a cycle | |
| if (visiting.has(nodeName)) return 0; | |
| if (memo.has(nodeName)) return memo.get(nodeName)!; | |
| visiting.add(nodeName); | |
| let maxLength = 0; | |
| const connections = workflow.connections[nodeName]; | |
| if (connections?.main) { | |
| for (const outputConnections of connections.main) { | |
| if (outputConnections) { | |
| for (const conn of outputConnections) { | |
| const length = getChainLength(conn.node); | |
| maxLength = Math.max(maxLength, length); | |
| } | |
| } | |
| } | |
| } | |
| visiting.delete(nodeName); | |
| const result = maxLength + 1; | |
| memo.set(nodeName, result); | |
| return result; | |
| }; | |
| let maxChain = 0; | |
| for (const node of workflow.nodes) { | |
| if (!this.nodeHasInput(node.name, workflow)) { | |
| maxChain = Math.max(maxChain, getChainLength(node.name)); | |
| } | |
| } | |
| return maxChain; | |
| } | |
| /** | |
| * Generate suggestions based on validation results | |
| */ | |
| private generateSuggestions( | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult | |
| ): void { | |
| // Suggest adding trigger if missing | |
| if (result.statistics.triggerNodes === 0) { | |
| result.suggestions.push( | |
| 'Add a trigger node (e.g., Webhook, Schedule Trigger) to automate workflow execution' | |
| ); | |
| } | |
| // Suggest proper connection structure for workflows with connection errors | |
| const hasConnectionErrors = result.errors.some(e => | |
| typeof e.message === 'string' && ( | |
| e.message.includes('connection') || | |
| e.message.includes('Connection') || | |
| e.message.includes('Multi-node workflow has no connections') | |
| ) | |
| ); | |
| if (hasConnectionErrors) { | |
| result.suggestions.push( | |
| 'Example connection structure: connections: { "Manual Trigger": { "main": [[{ "node": "Set", "type": "main", "index": 0 }]] } }' | |
| ); | |
| result.suggestions.push( | |
| 'Remember: Use node NAMES (not IDs) in connections. The name is what you see in the UI, not the node type.' | |
| ); | |
| } | |
| // Suggest error handling | |
| if (!Object.values(workflow.connections).some(o => o.error)) { | |
| result.suggestions.push( | |
| 'Add error handling using the error output of nodes or an Error Trigger node' | |
| ); | |
| } | |
| // Suggest optimization for large workflows | |
| if (workflow.nodes.length > 20) { | |
| result.suggestions.push( | |
| 'Consider breaking this workflow into smaller sub-workflows for better maintainability' | |
| ); | |
| } | |
| // Suggest using Code node for complex logic | |
| const complexExpressionNodes = workflow.nodes.filter(node => { | |
| const jsonString = JSON.stringify(node.parameters); | |
| const expressionCount = (jsonString.match(/\{\{/g) || []).length; | |
| return expressionCount > 5; | |
| }); | |
| if (complexExpressionNodes.length > 0) { | |
| result.suggestions.push( | |
| 'Consider using a Code node for complex data transformations instead of multiple expressions' | |
| ); | |
| } | |
| // Suggest minimum workflow structure | |
| if (workflow.nodes.length === 1 && Object.keys(workflow.connections).length === 0) { | |
| result.suggestions.push( | |
| 'A minimal workflow needs: 1) A trigger node (e.g., Manual Trigger), 2) An action node (e.g., Set, HTTP Request), 3) A connection between them' | |
| ); | |
| } | |
| } | |
| /** | |
| * Check node-level error handling configuration for a single node | |
| * | |
| * Validates error handling properties (onError, continueOnFail, retryOnFail) | |
| * and provides warnings for error-prone nodes (HTTP, webhooks, databases) | |
| * that lack proper error handling. Delegates webhook-specific validation | |
| * to checkWebhookErrorHandling() for clearer logic. | |
| * | |
| * @param node - The workflow node to validate | |
| * @param workflow - The complete workflow for context | |
| * @param result - Validation result to add errors/warnings to | |
| */ | |
| private checkNodeErrorHandling( | |
| node: WorkflowNode, | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult | |
| ): void { | |
| // Only skip if disabled is explicitly true (not just truthy) | |
| if (node.disabled === true) return; | |
| // Define node types that typically interact with external services (lowercase for comparison) | |
| const errorProneNodeTypes = [ | |
| 'httprequest', | |
| 'webhook', | |
| 'emailsend', | |
| 'slack', | |
| 'discord', | |
| 'telegram', | |
| 'postgres', | |
| 'mysql', | |
| 'mongodb', | |
| 'redis', | |
| 'github', | |
| 'gitlab', | |
| 'jira', | |
| 'salesforce', | |
| 'hubspot', | |
| 'airtable', | |
| 'googlesheets', | |
| 'googledrive', | |
| 'dropbox', | |
| 's3', | |
| 'ftp', | |
| 'ssh', | |
| 'mqtt', | |
| 'kafka', | |
| 'rabbitmq', | |
| 'graphql', | |
| 'openai', | |
| 'anthropic' | |
| ]; | |
| const normalizedType = node.type.toLowerCase(); | |
| const isErrorProne = errorProneNodeTypes.some(type => normalizedType.includes(type)); | |
| // CRITICAL: Check for node-level properties in wrong location (inside parameters) | |
| const nodeLevelProps = [ | |
| // Error handling properties | |
| 'onError', 'continueOnFail', 'retryOnFail', 'maxTries', 'waitBetweenTries', 'alwaysOutputData', | |
| // Other node-level properties | |
| 'executeOnce', 'disabled', 'notes', 'notesInFlow', 'credentials' | |
| ]; | |
| const misplacedProps: string[] = []; | |
| if (node.parameters) { | |
| for (const prop of nodeLevelProps) { | |
| if (node.parameters[prop] !== undefined) { | |
| misplacedProps.push(prop); | |
| } | |
| } | |
| } | |
| if (misplacedProps.length > 0) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Node-level properties ${misplacedProps.join(', ')} are in the wrong location. They must be at the node level, not inside parameters.`, | |
| details: { | |
| fix: `Move these properties from node.parameters to the node level. Example:\n` + | |
| `{\n` + | |
| ` "name": "${node.name}",\n` + | |
| ` "type": "${node.type}",\n` + | |
| ` "parameters": { /* operation-specific params */ },\n` + | |
| ` "onError": "continueErrorOutput", // ✅ Correct location\n` + | |
| ` "retryOnFail": true, // ✅ Correct location\n` + | |
| ` "executeOnce": true, // ✅ Correct location\n` + | |
| ` "disabled": false, // ✅ Correct location\n` + | |
| ` "credentials": { /* ... */ } // ✅ Correct location\n` + | |
| `}` | |
| } | |
| }); | |
| } | |
| // Validate error handling properties | |
| // Check for onError property (the modern approach) | |
| if (node.onError !== undefined) { | |
| const validOnErrorValues = ['continueRegularOutput', 'continueErrorOutput', 'stopWorkflow']; | |
| if (!validOnErrorValues.includes(node.onError)) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Invalid onError value: "${node.onError}". Must be one of: ${validOnErrorValues.join(', ')}` | |
| }); | |
| } | |
| } | |
| // Check for deprecated continueOnFail | |
| if (node.continueOnFail !== undefined) { | |
| if (typeof node.continueOnFail !== 'boolean') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'continueOnFail must be a boolean value' | |
| }); | |
| } else if (node.continueOnFail === true) { | |
| // Warn about using deprecated property | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'Using deprecated "continueOnFail: true". Use "onError: \'continueRegularOutput\'" instead for better control and UI compatibility.' | |
| }); | |
| } | |
| } | |
| // Check for conflicting error handling properties | |
| if (node.continueOnFail !== undefined && node.onError !== undefined) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'Cannot use both "continueOnFail" and "onError" properties. Use only "onError" for modern workflows.' | |
| }); | |
| } | |
| if (node.retryOnFail !== undefined) { | |
| if (typeof node.retryOnFail !== 'boolean') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'retryOnFail must be a boolean value' | |
| }); | |
| } | |
| // If retry is enabled, check retry configuration | |
| if (node.retryOnFail === true) { | |
| if (node.maxTries !== undefined) { | |
| if (typeof node.maxTries !== 'number' || node.maxTries < 1) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'maxTries must be a positive number when retryOnFail is enabled' | |
| }); | |
| } else if (node.maxTries > 10) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `maxTries is set to ${node.maxTries}. Consider if this many retries is necessary.` | |
| }); | |
| } | |
| } else { | |
| // maxTries defaults to 3 if not specified | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'retryOnFail is enabled but maxTries is not specified. Default is 3 attempts.' | |
| }); | |
| } | |
| if (node.waitBetweenTries !== undefined) { | |
| if (typeof node.waitBetweenTries !== 'number' || node.waitBetweenTries < 0) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'waitBetweenTries must be a non-negative number (milliseconds)' | |
| }); | |
| } else if (node.waitBetweenTries > 300000) { // 5 minutes | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `waitBetweenTries is set to ${node.waitBetweenTries}ms (${(node.waitBetweenTries/1000).toFixed(1)}s). This seems excessive.` | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| if (node.alwaysOutputData !== undefined && typeof node.alwaysOutputData !== 'boolean') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'alwaysOutputData must be a boolean value' | |
| }); | |
| } | |
| // Warnings for error-prone nodes without error handling | |
| const hasErrorHandling = node.onError || node.continueOnFail || node.retryOnFail; | |
| if (isErrorProne && !hasErrorHandling) { | |
| const nodeTypeSimple = normalizedType.split('.').pop() || normalizedType; | |
| // Special handling for specific node types | |
| if (normalizedType.includes('httprequest')) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'HTTP Request node without error handling. Consider adding "onError: \'continueRegularOutput\'" for non-critical requests or "retryOnFail: true" for transient failures.' | |
| }); | |
| } else if (normalizedType.includes('webhook')) { | |
| // Delegate to specialized webhook validation helper | |
| this.checkWebhookErrorHandling(node, normalizedType, result); | |
| } else if (errorProneNodeTypes.some(db => normalizedType.includes(db) && ['postgres', 'mysql', 'mongodb'].includes(db))) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Database operation without error handling. Consider adding "retryOnFail: true" for connection issues or "onError: \'continueRegularOutput\'" for non-critical queries.` | |
| }); | |
| } else { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `${nodeTypeSimple} node without error handling. Consider using "onError" property for better error management.` | |
| }); | |
| } | |
| } | |
| // Check for problematic combinations | |
| if (node.continueOnFail && node.retryOnFail) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'Both continueOnFail and retryOnFail are enabled. The node will retry first, then continue on failure.' | |
| }); | |
| } | |
| // Validate additional node-level properties | |
| // Check executeOnce | |
| if (node.executeOnce !== undefined && typeof node.executeOnce !== 'boolean') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'executeOnce must be a boolean value' | |
| }); | |
| } | |
| // Check disabled | |
| if (node.disabled !== undefined && typeof node.disabled !== 'boolean') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'disabled must be a boolean value' | |
| }); | |
| } | |
| // Check notesInFlow | |
| if (node.notesInFlow !== undefined && typeof node.notesInFlow !== 'boolean') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'notesInFlow must be a boolean value' | |
| }); | |
| } | |
| // Check notes | |
| if (node.notes !== undefined && typeof node.notes !== 'string') { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'notes must be a string value' | |
| }); | |
| } | |
| // Provide guidance for executeOnce | |
| if (node.executeOnce === true) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'executeOnce is enabled. This node will execute only once regardless of input items.' | |
| }); | |
| } | |
| // Suggest alwaysOutputData for debugging | |
| if ((node.continueOnFail || node.retryOnFail) && !node.alwaysOutputData) { | |
| if (normalizedType.includes('httprequest') || normalizedType.includes('webhook')) { | |
| result.suggestions.push( | |
| `Consider enabling alwaysOutputData on "${node.name}" to capture error responses for debugging` | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * Check webhook-specific error handling requirements | |
| * | |
| * Webhooks have special error handling requirements: | |
| * - respondToWebhook nodes (response nodes) don't need error handling | |
| * - Webhook nodes with responseNode mode REQUIRE onError to ensure responses | |
| * - Regular webhook nodes should have error handling to prevent blocking | |
| * | |
| * @param node - The webhook node to check | |
| * @param normalizedType - Normalized node type for comparison | |
| * @param result - Validation result to add errors/warnings to | |
| */ | |
| private checkWebhookErrorHandling( | |
| node: WorkflowNode, | |
| normalizedType: string, | |
| result: WorkflowValidationResult | |
| ): void { | |
| // respondToWebhook nodes are response nodes (endpoints), not triggers | |
| // They're the END of execution, not controllers of flow - skip error handling check | |
| if (normalizedType.includes('respondtowebhook')) { | |
| return; | |
| } | |
| // Check for responseNode mode specifically | |
| // responseNode mode requires onError to ensure response is sent even on error | |
| if (node.parameters?.responseMode === 'responseNode') { | |
| if (!node.onError && !node.continueOnFail) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'responseNode mode requires onError: "continueRegularOutput"' | |
| }); | |
| } | |
| return; | |
| } | |
| // Regular webhook nodes without responseNode mode | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: 'Webhook node without error handling. Consider adding "onError: \'continueRegularOutput\'" to prevent workflow failures from blocking webhook responses.' | |
| }); | |
| } | |
| /** | |
| * Generate error handling suggestions based on all nodes | |
| */ | |
| private generateErrorHandlingSuggestions( | |
| workflow: WorkflowJson, | |
| result: WorkflowValidationResult | |
| ): void { | |
| // Add general suggestions based on findings | |
| const nodesWithoutErrorHandling = workflow.nodes.filter(n => | |
| !n.disabled && !n.onError && !n.continueOnFail && !n.retryOnFail | |
| ).length; | |
| if (nodesWithoutErrorHandling > 5 && workflow.nodes.length > 5) { | |
| result.suggestions.push( | |
| 'Most nodes lack error handling. Use "onError" property for modern error handling: "continueRegularOutput" (continue on error), "continueErrorOutput" (use error output), or "stopWorkflow" (stop execution).' | |
| ); | |
| } | |
| // Check for nodes using deprecated continueOnFail | |
| const nodesWithDeprecatedErrorHandling = workflow.nodes.filter(n => | |
| !n.disabled && n.continueOnFail === true | |
| ).length; | |
| if (nodesWithDeprecatedErrorHandling > 0) { | |
| result.suggestions.push( | |
| 'Replace "continueOnFail: true" with "onError: \'continueRegularOutput\'" for better UI compatibility and control.' | |
| ); | |
| } | |
| } | |
| /** | |
| * Validate SplitInBatches node connections for common mistakes | |
| */ | |
| private validateSplitInBatchesConnection( | |
| sourceNode: WorkflowNode, | |
| outputIndex: number, | |
| connection: { node: string; type: string; index: number }, | |
| nodeMap: Map<string, WorkflowNode>, | |
| result: WorkflowValidationResult | |
| ): void { | |
| const targetNode = nodeMap.get(connection.node); | |
| if (!targetNode) return; | |
| // Check if connections appear to be reversed | |
| // Output 0 = "done", Output 1 = "loop" | |
| if (outputIndex === 0) { | |
| // This is the "done" output (index 0) | |
| // Check if target looks like it should be in the loop | |
| const targetType = targetNode.type.toLowerCase(); | |
| const targetName = targetNode.name.toLowerCase(); | |
| // Common patterns that suggest this node should be inside the loop | |
| if (targetType.includes('function') || | |
| targetType.includes('code') || | |
| targetType.includes('item') || | |
| targetName.includes('process') || | |
| targetName.includes('transform') || | |
| targetName.includes('handle')) { | |
| // Check if this node connects back to the SplitInBatches | |
| const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap); | |
| if (hasLoopBack) { | |
| result.errors.push({ | |
| type: 'error', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `SplitInBatches outputs appear reversed! Node "${targetNode.name}" is connected to output 0 ("done") but connects back to the loop. It should be connected to output 1 ("loop") instead. Remember: Output 0 = "done" (post-loop), Output 1 = "loop" (inside loop).` | |
| }); | |
| } else { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `Node "${targetNode.name}" is connected to the "done" output (index 0) but appears to be a processing node. Consider connecting it to the "loop" output (index 1) if it should process items inside the loop.` | |
| }); | |
| } | |
| } | |
| } else if (outputIndex === 1) { | |
| // This is the "loop" output (index 1) | |
| // Check if target looks like it should be after the loop | |
| const targetType = targetNode.type.toLowerCase(); | |
| const targetName = targetNode.name.toLowerCase(); | |
| // Common patterns that suggest this node should be after the loop | |
| if (targetType.includes('aggregate') || | |
| targetType.includes('merge') || | |
| targetType.includes('email') || | |
| targetType.includes('slack') || | |
| targetName.includes('final') || | |
| targetName.includes('complete') || | |
| targetName.includes('summary') || | |
| targetName.includes('report')) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `Node "${targetNode.name}" is connected to the "loop" output (index 1) but appears to be a post-processing node. Consider connecting it to the "done" output (index 0) if it should run after all iterations complete.` | |
| }); | |
| } | |
| // Check if loop output doesn't eventually connect back | |
| const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap); | |
| if (!hasLoopBack) { | |
| result.warnings.push({ | |
| type: 'warning', | |
| nodeId: sourceNode.id, | |
| nodeName: sourceNode.name, | |
| message: `The "loop" output connects to "${targetNode.name}" but doesn't connect back to the SplitInBatches node. The last node in the loop should connect back to complete the iteration.` | |
| }); | |
| } | |
| } | |
| } | |
| /** | |
| * Check if a node eventually connects back to a target node | |
| */ | |
| private checkForLoopBack( | |
| startNode: string, | |
| targetNode: string, | |
| nodeMap: Map<string, WorkflowNode>, | |
| visited: Set<string> = new Set(), | |
| maxDepth: number = 50 | |
| ): boolean { | |
| if (maxDepth <= 0) return false; // Prevent stack overflow | |
| if (visited.has(startNode)) return false; | |
| visited.add(startNode); | |
| const node = nodeMap.get(startNode); | |
| if (!node) return false; | |
| // Access connections from the workflow structure, not the node | |
| // We need to access this.currentWorkflow.connections[startNode] | |
| const connections = (this as any).currentWorkflow?.connections[startNode]; | |
| if (!connections) return false; | |
| for (const [outputType, outputs] of Object.entries(connections)) { | |
| if (!Array.isArray(outputs)) continue; | |
| for (const outputConnections of outputs) { | |
| if (!Array.isArray(outputConnections)) continue; | |
| for (const conn of outputConnections) { | |
| if (conn.node === targetNode) { | |
| return true; | |
| } | |
| // Recursively check connected nodes | |
| if (this.checkForLoopBack(conn.node, targetNode, nodeMap, visited, maxDepth - 1)) { | |
| return true; | |
| } | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Add AI-specific error recovery suggestions | |
| */ | |
| private addErrorRecoverySuggestions(result: WorkflowValidationResult): void { | |
| // Categorize errors and provide specific recovery actions | |
| const errorTypes = { | |
| nodeType: result.errors.filter(e => e.message.includes('node type') || e.message.includes('Node type')), | |
| connection: result.errors.filter(e => e.message.includes('connection') || e.message.includes('Connection')), | |
| structure: result.errors.filter(e => e.message.includes('structure') || e.message.includes('nodes must be')), | |
| configuration: result.errors.filter(e => e.message.includes('property') || e.message.includes('field')), | |
| typeVersion: result.errors.filter(e => e.message.includes('typeVersion')) | |
| }; | |
| // Add recovery suggestions based on error types | |
| if (errorTypes.nodeType.length > 0) { | |
| result.suggestions.unshift( | |
| '🔧 RECOVERY: Invalid node types detected. Use these patterns:', | |
| ' • For core nodes: "n8n-nodes-base.nodeName" (e.g., "n8n-nodes-base.webhook")', | |
| ' • For AI nodes: "@n8n/n8n-nodes-langchain.nodeName"', | |
| ' • Never use just the node name without package prefix' | |
| ); | |
| } | |
| if (errorTypes.connection.length > 0) { | |
| result.suggestions.unshift( | |
| '🔧 RECOVERY: Connection errors detected. Fix with:', | |
| ' • Use node NAMES in connections, not IDs or types', | |
| ' • Structure: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }', | |
| ' • Ensure all referenced nodes exist in the workflow' | |
| ); | |
| } | |
| if (errorTypes.structure.length > 0) { | |
| result.suggestions.unshift( | |
| '🔧 RECOVERY: Workflow structure errors. Fix with:', | |
| ' • Ensure "nodes" is an array: "nodes": [...]', | |
| ' • Ensure "connections" is an object: "connections": {...}', | |
| ' • Add at least one node to create a valid workflow' | |
| ); | |
| } | |
| if (errorTypes.configuration.length > 0) { | |
| result.suggestions.unshift( | |
| '🔧 RECOVERY: Node configuration errors. Fix with:', | |
| ' • Check required fields using validate_node_minimal first', | |
| ' • Use get_node_essentials to see what fields are needed', | |
| ' • Ensure operation-specific fields match the node\'s requirements' | |
| ); | |
| } | |
| if (errorTypes.typeVersion.length > 0) { | |
| result.suggestions.unshift( | |
| '🔧 RECOVERY: TypeVersion errors. Fix with:', | |
| ' • Add "typeVersion": 1 (or latest version) to each node', | |
| ' • Use get_node_info to check the correct version for each node type' | |
| ); | |
| } | |
| // Add general recovery workflow | |
| if (result.errors.length > 3) { | |
| result.suggestions.push( | |
| '📋 SUGGESTED WORKFLOW: Too many errors detected. Try this approach:', | |
| ' 1. Fix structural issues first (nodes array, connections object)', | |
| ' 2. Validate node types and fix invalid ones', | |
| ' 3. Add required typeVersion to all nodes', | |
| ' 4. Test connections step by step', | |
| ' 5. Use validate_node_minimal on individual nodes to verify configuration' | |
| ); | |
| } | |
| } | |
| } |