UNPKG

n8n-mcp

Version:

Integration between n8n workflow automation and Model Context Protocol (MCP)

1,080 lines (1,079 loc) 56.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.WorkflowValidator = void 0; const expression_validator_1 = require("./expression-validator"); const logger_1 = require("../utils/logger"); const logger = new logger_1.Logger({ prefix: '[WorkflowValidator]' }); class WorkflowValidator { constructor(nodeRepository, nodeValidator) { this.nodeRepository = nodeRepository; this.nodeValidator = nodeValidator; this.currentWorkflow = null; } isStickyNote(node) { const stickyNoteTypes = [ 'n8n-nodes-base.stickyNote', 'nodes-base.stickyNote', '@n8n/n8n-nodes-base.stickyNote' ]; return stickyNoteTypes.includes(node.type); } async validateWorkflow(workflow, options = {}) { this.currentWorkflow = workflow; const { validateNodes = true, validateConnections = true, validateExpressions = true, profile = 'runtime' } = options; const result = { valid: true, errors: [], warnings: [], statistics: { totalNodes: 0, enabledNodes: 0, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0, }, suggestions: [] }; try { if (!workflow) { result.errors.push({ type: 'error', message: 'Invalid workflow structure: workflow is null or undefined' }); result.valid = false; return result; } const executableNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !this.isStickyNote(n)) : []; result.statistics.totalNodes = executableNodes.length; result.statistics.enabledNodes = executableNodes.filter(n => !n.disabled).length; this.validateWorkflowStructure(workflow, result); if (workflow.nodes && Array.isArray(workflow.nodes) && workflow.connections && typeof workflow.connections === 'object') { if (validateNodes && workflow.nodes.length > 0) { await this.validateAllNodes(workflow, result, profile); } if (validateConnections) { this.validateConnections(workflow, result, profile); } if (validateExpressions && workflow.nodes.length > 0) { this.validateExpressions(workflow, result, profile); } if (workflow.nodes.length > 0) { this.checkWorkflowPatterns(workflow, result, profile); } this.generateSuggestions(workflow, result); 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; } validateWorkflowStructure(workflow, result) { 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; } if (workflow.nodes.length === 0) { result.warnings.push({ type: 'warning', message: 'Workflow is empty - no nodes defined' }); return; } if (workflow.nodes.length === 1) { const singleNode = workflow.nodes[0]; const normalizedType = singleNode.type.replace('n8n-nodes-base.', 'nodes-base.'); const isWebhook = normalizedType === 'nodes-base.webhook' || normalizedType === 'nodes-base.webhookTrigger'; if (!isWebhook) { 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 (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.' }); } } 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 }]] } }' }); } } const nodeNames = new Set(); const nodeIds = new Set(); for (const node of workflow.nodes) { 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)) { result.errors.push({ type: 'error', nodeId: node.id, message: `Duplicate node ID: "${node.id}"` }); } nodeIds.add(node.id); } const triggerNodes = workflow.nodes.filter(n => { const normalizedType = n.type.replace('n8n-nodes-base.', 'nodes-base.'); return normalizedType.toLowerCase().includes('trigger') || normalizedType.toLowerCase().includes('webhook') || normalizedType === 'nodes-base.start' || normalizedType === 'nodes-base.manualTrigger' || normalizedType === 'nodes-base.formTrigger'; }); result.statistics.triggerNodes = triggerNodes.length; 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.' }); } } async validateAllNodes(workflow, result, profile) { for (const node of workflow.nodes) { if (node.disabled || this.isStickyNote(node)) continue; try { 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.` }); } 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' }); } } if (node.type.startsWith('nodes-base.')) { const correctType = node.type.replace('nodes-base.', 'n8n-nodes-base.'); result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Invalid node type: "${node.type}". Use "${correctType}" instead. Node types in workflows must use the full package name.` }); continue; } let nodeInfo = this.nodeRepository.getNode(node.type); if (!nodeInfo) { let normalizedType = node.type; if (node.type.startsWith('n8n-nodes-base.')) { normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.'); nodeInfo = this.nodeRepository.getNode(normalizedType); } else if (node.type.startsWith('@n8n/n8n-nodes-langchain.')) { normalizedType = node.type.replace('@n8n/n8n-nodes-langchain.', 'nodes-langchain.'); nodeInfo = this.nodeRepository.getNode(normalizedType); } } if (!nodeInfo) { let suggestion = ''; 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)}"?`; } } else if (!node.type.includes('.')) { 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 (!suggestion) { const similarNodes = this.findSimilarNodeTypes(node.type); if (similarNodes.length > 0) { suggestion = ` Did you mean: ${similarNodes.map(n => `"${n}"`).join(', ')}?`; } } result.errors.push({ 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").` }); continue; } if (nodeInfo.isVersioned) { if (!node.typeVersion) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Missing required property 'typeVersion'. Add typeVersion: ${nodeInfo.version || 1}` }); } else if (typeof node.typeVersion !== 'number' || node.typeVersion < 1) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Invalid typeVersion: ${node.typeVersion}. Must be a positive number` }); } 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}` }); } 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}` }); } } const nodeValidation = this.nodeValidator.validateWithMode(node.type, node.parameters, nodeInfo.properties || [], 'operation', profile); nodeValidation.errors.forEach((error) => { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: typeof error === 'string' ? error : error.message || String(error) }); }); nodeValidation.warnings.forEach((warning) => { 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'}` }); } } } validateConnections(workflow, result, profile = 'runtime') { const nodeMap = new Map(workflow.nodes.map(n => [n.name, n])); const nodeIdMap = new Map(workflow.nodes.map(n => [n.id, n])); for (const [sourceName, outputs] of Object.entries(workflow.connections)) { const sourceNode = nodeMap.get(sourceName); if (!sourceNode) { 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; } if (outputs.main) { this.validateConnectionOutputs(sourceName, outputs.main, nodeMap, nodeIdMap, result, 'main'); } if (outputs.error) { this.validateConnectionOutputs(sourceName, outputs.error, nodeMap, nodeIdMap, result, 'error'); } if (outputs.ai_tool) { this.validateConnectionOutputs(sourceName, outputs.ai_tool, nodeMap, nodeIdMap, result, 'ai_tool'); } } const connectedNodes = new Set(); Object.keys(workflow.connections).forEach(name => connectedNodes.add(name)); 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); }); } }); for (const node of workflow.nodes) { if (node.disabled || this.isStickyNote(node)) continue; const normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.'); const isTrigger = normalizedType.toLowerCase().includes('trigger') || normalizedType.toLowerCase().includes('webhook') || normalizedType === 'nodes-base.start' || normalizedType === 'nodes-base.manualTrigger' || normalizedType === 'nodes-base.formTrigger'; if (!connectedNodes.has(node.name) && !isTrigger) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Node is not connected to any other nodes' }); } } if (profile !== 'minimal' && this.hasCycle(workflow)) { result.errors.push({ type: 'error', message: 'Workflow contains a cycle (infinite loop)' }); } } validateConnectionOutputs(sourceName, outputs, nodeMap, nodeIdMap, result, outputType) { const sourceNode = nodeMap.get(sourceName); outputs.forEach((outputConnections, outputIndex) => { if (!outputConnections) return; outputConnections.forEach(connection => { 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; } if (sourceNode && sourceNode.type === 'n8n-nodes-base.splitInBatches') { this.validateSplitInBatchesConnection(sourceNode, outputIndex, connection, nodeMap, result); } if (connection.node === sourceName) { if (sourceNode && sourceNode.type !== 'n8n-nodes-base.splitInBatches') { 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) { 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++; if (outputType === 'ai_tool') { this.validateAIToolConnection(sourceName, targetNode, result); } } }); }); } validateAIToolConnection(sourceName, targetNode, result) { let targetNodeInfo = this.nodeRepository.getNode(targetNode.type); if (!targetNodeInfo) { let normalizedType = targetNode.type; if (targetNode.type.startsWith('n8n-nodes-base.')) { normalizedType = targetNode.type.replace('n8n-nodes-base.', 'nodes-base.'); targetNodeInfo = this.nodeRepository.getNode(normalizedType); } else if (targetNode.type.startsWith('@n8n/n8n-nodes-langchain.')) { normalizedType = targetNode.type.replace('@n8n/n8n-nodes-langchain.', 'nodes-langchain.'); targetNodeInfo = this.nodeRepository.getNode(normalizedType); } } if (targetNodeInfo && !targetNodeInfo.isAITool && targetNodeInfo.package !== 'n8n-nodes-base') { 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.` }); } } hasCycle(workflow) { const visited = new Set(); const recursionStack = new Set(); const nodeTypeMap = new Map(); workflow.nodes.forEach(node => { if (!this.isStickyNote(node)) { nodeTypeMap.set(node.name, node.type); } }); 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, pathFromLoopNode = false) => { visited.add(nodeName); recursionStack.add(nodeName); const connections = workflow.connections[nodeName]; if (connections) { const allTargets = []; 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)) { const targetNodeType = nodeTypeMap.get(target); const isTargetLoopNode = loopNodeTypes.includes(targetNodeType || ''); if (isTargetLoopNode || pathFromLoopNode || isLoopNode) { continue; } return true; } } } recursionStack.delete(nodeName); return false; }; for (const node of workflow.nodes) { if (!this.isStickyNote(node) && !visited.has(node.name)) { if (hasCycleDFS(node.name)) return true; } } return false; } validateExpressions(workflow, result, profile = 'runtime') { const nodeNames = workflow.nodes.map(n => n.name); for (const node of workflow.nodes) { if (node.disabled || this.isStickyNote(node)) continue; const context = { availableNodes: nodeNames.filter(n => n !== node.name), currentNodeName: node.name, hasInputData: this.nodeHasInput(node.name, workflow), isInLoop: false }; const exprValidation = expression_validator_1.ExpressionValidator.validateNodeExpressions(node.parameters, context); const expressionCount = this.countExpressionsInObject(node.parameters); result.statistics.expressionsValidated += expressionCount; 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}` }); }); } } countExpressionsInObject(obj) { let count = 0; if (typeof obj === 'string') { const matches = obj.match(/\{\{[\s\S]+?\}\}/g); if (matches) { count += matches.length; } } else if (Array.isArray(obj)) { for (const item of obj) { count += this.countExpressionsInObject(item); } } else if (obj && typeof obj === 'object') { for (const value of Object.values(obj)) { count += this.countExpressionsInObject(value); } } return count; } nodeHasInput(nodeName, workflow) { 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; } checkWorkflowPatterns(workflow, result, profile = 'runtime') { const hasErrorHandling = Object.values(workflow.connections).some(outputs => outputs.error && outputs.error.length > 0); if (!hasErrorHandling && workflow.nodes.length > 3 && profile !== 'minimal') { result.warnings.push({ type: 'warning', message: 'Consider adding error handling to your workflow' }); } for (const node of workflow.nodes) { if (!this.isStickyNote(node)) { this.checkNodeErrorHandling(node, workflow, result); } } 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.` }); } this.generateErrorHandlingSuggestions(workflow, result); 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}` }); } } } } const aiAgentNodes = workflow.nodes.filter(n => n.type.toLowerCase().includes('agent') || n.type.includes('langchain.agent')); if (aiAgentNodes.length > 0) { for (const agentNode of aiAgentNodes) { const connections = workflow.connections[agentNode.name]; if (!connections?.ai_tool || connections.ai_tool.flat().filter(c => c).length === 0) { 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.' }); } } 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'); } } } getLongestLinearChain(workflow) { const memo = new Map(); const visiting = new Set(); const getChainLength = (nodeName) => { 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; } findSimilarNodeTypes(invalidType) { const suggestions = []; const nodeName = invalidType.includes('.') ? invalidType.split('.').pop() : invalidType; const commonNodeMappings = { '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'], }; const lowerNodeName = nodeName.toLowerCase(); if (commonNodeMappings[lowerNodeName]) { suggestions.push(...commonNodeMappings[lowerNodeName]); } 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); } generateSuggestions(workflow, result) { if (result.statistics.triggerNodes === 0) { result.suggestions.push('Add a trigger node (e.g., Webhook, Schedule Trigger) to automate workflow execution'); } 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.'); } 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'); } if (workflow.nodes.length > 20) { result.suggestions.push('Consider breaking this workflow into smaller sub-workflows for better maintainability'); } 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'); } 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'); } } checkNodeErrorHandling(node, workflow, result) { if (node.disabled === true) return; 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)); const nodeLevelProps = [ 'onError', 'continueOnFail', 'retryOnFail', 'maxTries', 'waitBetweenTries', 'alwaysOutputData', 'executeOnce', 'disabled', 'notes', 'notesInFlow', 'credentials' ]; const misplacedProps = []; 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` + `}` } }); } 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(', ')}` }); } } 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) { 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.' }); } } 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 (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 { 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) { 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' }); } const hasErrorHandling = node.onError || node.continueOnFail || node.retryOnFail; if (isErrorProne && !hasErrorHandling) { const nodeTypeSimple = normalizedType.split('.').pop() || normalizedType; 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')) { 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.' }); } 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.` }); } } 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.' }); } 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' }); } 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' }); } 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' }); } 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' }); } 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.' }); } 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`); } } } generateErrorHandlingSuggestions(workflow, result) { 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).'); } 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.'); } } validateSplitInBatchesConnection(sourceNode, outputIndex, connection, nodeMap, result) { const targetNode = nodeMap.get(connection.node); if (!targetNode) return; if (outputIndex === 0) { const targetType = targetNode.type.toLowerCase(); const targetName = targetNode.name.toLowerCase(); if (targetType.includes('function') || targetType.includes('code') || targetType.includes('item') || targetName.includes('process') || targetName.includes('transform') || targetName.includes('handle')) { const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap); if (hasLoopBack) { result.errors.push({ type: 'error', nodeId: sourceNode.id, nodeName: sourceNode.name,