UNPKG

n8n-mcp

Version:

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

1,003 lines (1,002 loc) 83.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WorkflowValidator = exports.VALID_CONNECTION_TYPES = void 0; const crypto_1 = __importDefault(require("crypto")); const expression_validator_1 = require("./expression-validator"); const expression_utils_1 = require("../utils/expression-utils"); const expression_format_validator_1 = require("./expression-format-validator"); const node_similarity_service_1 = require("./node-similarity-service"); const node_type_normalizer_1 = require("../utils/node-type-normalizer"); const typeversion_1 = require("../utils/typeversion"); const logger_1 = require("../utils/logger"); const ai_node_validator_1 = require("./ai-node-validator"); const ai_tool_validators_1 = require("./ai-tool-validators"); const node_type_utils_1 = require("../utils/node-type-utils"); const node_classification_1 = require("../utils/node-classification"); const n8n_validation_1 = require("./n8n-validation"); const tool_variant_generator_1 = require("./tool-variant-generator"); const logger = new logger_1.Logger({ prefix: '[WorkflowValidator]' }); const ADD_ERROR_HANDLING_ADVISORY = 'Consider adding error handling to your workflow'; exports.VALID_CONNECTION_TYPES = new Set([ 'main', 'error', ...ai_node_validator_1.AI_CONNECTION_TYPES, 'ai_agent', 'ai_chain', 'ai_retriever', 'ai_reranker', ]); class WorkflowValidator { constructor(nodeRepository, nodeValidator) { this.nodeRepository = nodeRepository; this.nodeValidator = nodeValidator; this.currentWorkflow = null; this.similarityService = new node_similarity_service_1.NodeSimilarityService(nodeRepository); } 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 => !(0, node_classification_1.isNonExecutableNode)(n.type)) : []; 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); } if (workflow.nodes.length > 0 && (0, ai_node_validator_1.hasAINodes)(workflow)) { const aiIssues = (0, ai_node_validator_1.validateAISpecificNodes)(workflow); for (const issue of aiIssues) { if (issue.severity === 'info') { result.suggestions.push(issue.message); continue; } const 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); } } } this.generateSuggestions(workflow, result, profile); 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 = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(singleNode.type); const isWebhook = normalizedType === 'nodes-base.webhook' || normalizedType === 'nodes-base.webhookTrigger'; const isLangchainNode = normalizedType.startsWith('nodes-langchain.'); 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.' }); } } 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(); const nodeIdToIndex = new Map(); 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 (node.id && 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_1.default.randomUUID()}", name: "${node.name}", type: "${node.type}", ...}` }); } else if (node.id) { nodeIds.add(node.id); nodeIdToIndex.set(node.id, i); } } const triggerNodes = workflow.nodes.filter(n => (0, node_type_utils_1.isTriggerNode)(n.type)); 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 || (0, node_classification_1.isNonExecutableNode)(node.type)) 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' }); } } const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(node.type); let nodeInfo = this.nodeRepository.getNode(normalizedType); if (!nodeInfo && tool_variant_generator_1.ToolVariantGenerator.isToolVariantNodeType(normalizedType)) { const baseNodeType = tool_variant_generator_1.ToolVariantGenerator.getBaseNodeType(normalizedType); if (baseNodeType) { const baseNodeInfo = this.nodeRepository.getNode(baseNodeType); if (baseNodeInfo) { result.suggestions.push(`Node type "${node.type}" is inferred as a dynamic AI Tool variant of "${baseNodeType}". ` + `This Tool variant is created by n8n at runtime when connecting "${baseNodeInfo.displayName}" to an AI Agent.`); nodeInfo = { ...baseNodeInfo, nodeType: normalizedType, displayName: `${baseNodeInfo.displayName} Tool`, isToolVariant: true, toolVariantOf: baseNodeType, isInferred: true }; } } } if (!nodeInfo) { const suggestions = await this.similarityService.findSimilarNodes(node.type, 3); const isCommunityType = !this.isCorePackageType(normalizedType) && normalizedType.includes('.'); let message = `Unknown node type: "${node.type}".`; if (isCommunityType) { message += ' This looks like a community node that is not in this server\'s node database — the workflow can still run on an n8n instance where the package is installed.'; } 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 if (!isCommunityType) { message += ' No similar nodes found. Node types must include the package prefix (e.g., "n8n-nodes-base.webhook").'; } const issue = { type: isCommunityType ? 'warning' : 'error', nodeId: node.id, nodeName: node.name, message }; if (suggestions.length > 0) { issue.suggestions = suggestions.map(s => ({ nodeType: s.nodeType, confidence: s.confidence, reason: s.reason })); } if (isCommunityType) { result.warnings.push(issue); } else { result.errors.push(issue); } continue; } if (nodeInfo.isVersioned) { const maxVersion = (0, typeversion_1.parseTypeVersion)(nodeInfo.version); if (maxVersion === null && nodeInfo.version != null) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Cannot validate typeVersion for ${node.type}: stored version "${nodeInfo.version}" is not a valid typeVersion. Min/max checks were skipped — re-sync this node or verify typeVersion against the node descriptor manually.` }); } if (node.typeVersion === undefined || node.typeVersion === null) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Missing required property 'typeVersion'. Add typeVersion: ${maxVersion ?? 1}` }); } else if (typeof node.typeVersion !== 'number' || !Number.isFinite(node.typeVersion) || node.typeVersion < 0) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Invalid typeVersion: ${node.typeVersion}. Must be a finite non-negative number` }); } else if (maxVersion !== null && node.typeVersion < maxVersion) { if (this.isAdvisoryProfile(profile)) { result.suggestions.push(`Outdated typeVersion for node "${node.name}": ${node.typeVersion}. Latest is ${maxVersion}.`); } } else if (maxVersion !== null && node.typeVersion > maxVersion) { if (this.isCorePackageType(normalizedType)) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `typeVersion ${node.typeVersion} exceeds maximum supported version ${maxVersion}` }); } else { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `typeVersion ${node.typeVersion} exceeds maximum supported version ${maxVersion} known to this server. The community package may be newer than this server's data — verify the version exists in your installed package.` }); } } } if (normalizedType.startsWith('nodes-langchain.')) { continue; } if (nodeInfo.isInferred) { continue; } const paramsWithVersion = { '@version': node.typeVersion || 1, ...node.parameters }; const nodeValidation = this.nodeValidator.validateWithMode(node.type, paramsWithVersion, 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) }); }); if (node.type === 'n8n-nodes-base.if' || node.type === 'n8n-nodes-base.switch') { const conditionErrors = (0, n8n_validation_1.validateConditionNodeStructure)(node); for (const err of conditionErrors) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: err }); } } } 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; } for (const [outputKey, outputConnections] of Object.entries(outputs)) { if (!exports.VALID_CONNECTION_TYPES.has(outputKey)) { let suggestion = ''; if (/^\d+$/.test(outputKey)) { suggestion = ` If you meant to use output index ${outputKey}, use main[${outputKey}] instead.`; } result.errors.push({ type: 'error', nodeName: sourceName, message: `Unknown connection output key "${outputKey}" on node "${sourceName}". Valid keys are: ${[...exports.VALID_CONNECTION_TYPES].join(', ')}.${suggestion}`, code: 'UNKNOWN_CONNECTION_KEY' }); result.statistics.invalidConnections++; continue; } if (!outputConnections || !Array.isArray(outputConnections)) continue; if (outputKey === 'ai_tool') { this.validateAIToolSource(sourceNode, result); } if (outputKey === 'main') { this.validateNotAISubNode(sourceNode, result); } this.validateConnectionOutputs(sourceName, outputConnections, nodeMap, nodeIdMap, result, outputKey, profile); } } if (profile !== 'minimal') { this.validateTriggerReachability(workflow, result); } else { this.flagOrphanedNodes(workflow, result); } if (profile !== 'minimal' && this.hasCycle(workflow)) { result.warnings.push({ type: 'warning', message: 'Workflow contains a cycle with no recognized exit. Verify the loop can terminate (e.g. via a conditional branch, an error output, or a node that can return zero items).' }); } } validateConnectionOutputs(sourceName, outputs, nodeMap, nodeIdMap, result, outputType, profile = 'runtime') { const sourceNode = nodeMap.get(sourceName); if (outputType === 'main' && sourceNode) { this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result, profile); this.validateOutputIndexBounds(sourceNode, outputs, result); this.validateConditionalBranchUsage(sourceNode, outputs, result); } 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 (connection.type && !exports.VALID_CONNECTION_TYPES.has(connection.type)) { let suggestion = ''; if (/^\d+$/.test(connection.type)) { suggestion = ` Numeric types are not valid - use "main", "error", or an AI connection type.`; } result.errors.push({ type: 'error', nodeName: sourceName, message: `Invalid connection type "${connection.type}" in connection from "${sourceName}" to "${connection.node}". Expected "main", "error", or an AI connection type (ai_tool, ai_languageModel, etc.).${suggestion}`, code: 'INVALID_CONNECTION_TYPE' }); result.statistics.invalidConnections++; return; } const isSplitInBatches = sourceNode && (sourceNode.type === 'n8n-nodes-base.splitInBatches' || sourceNode.type === 'nodes-base.splitInBatches'); if (isSplitInBatches) { this.validateSplitInBatchesConnection(sourceNode, outputIndex, connection, nodeMap, result); } if (connection.node === sourceName) { 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) { 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); } if (outputType === 'main') { this.validateInputIndexBounds(sourceName, targetNode, connection, result); } } }); }); } validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result, profile = 'runtime') { const hasErrorOutputSetting = sourceNode.onError === 'continueErrorOutput'; const errorOutputIndex = this.getMainOutputCount(sourceNode); if (errorOutputIndex !== null) { const hasErrorConnections = outputs.length > errorOutputIndex && outputs[errorOutputIndex] && outputs[errorOutputIndex].length > 0; if (hasErrorOutputSetting && !hasErrorConnections && profile !== 'minimal') { result.warnings.push({ type: 'warning', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Node has onError: 'continueErrorOutput' but the error output (main[${errorOutputIndex}]) is not connected — failed items are silently dropped. Connect an error handler to main[${errorOutputIndex}] 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[${errorOutputIndex}] but missing onError: 'continueErrorOutput'. Add this property to properly handle errors.` }); } } if (outputs.length >= 1 && outputs[0] && outputs[0].length > 1) { 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(); 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.` }); } } } validateAIToolConnection(sourceName, targetNode, result) { const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type); let targetNodeInfo = this.nodeRepository.getNode(normalizedType); if (!targetNodeInfo && normalizedType !== targetNode.type) { targetNodeInfo = this.nodeRepository.getNode(targetNode.type); } 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.` }); } } validateAIToolSource(sourceNode, result) { const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type); if ((0, ai_tool_validators_1.isAIToolSubNode)(normalizedType)) { return; } const nodeInfo = this.nodeRepository.getNode(normalizedType); if (tool_variant_generator_1.ToolVariantGenerator.isToolVariantNodeType(normalizedType)) { if (nodeInfo?.isToolVariant) { return; } } if (!nodeInfo) { return; } if (nodeInfo.hasToolVariant) { const toolVariantType = tool_variant_generator_1.ToolVariantGenerator.getToolVariantNodeType(normalizedType); const workflowToolVariantType = node_type_normalizer_1.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; } if (nodeInfo.isAITool) { return; } 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' }); } getNodeOutputTypes(nodeType) { const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType); const nodeInfo = this.nodeRepository.getNode(normalizedType); if (!nodeInfo || !nodeInfo.outputs) return null; const outputs = nodeInfo.outputs; if (!Array.isArray(outputs)) return null; for (const output of outputs) { if (typeof output === 'string' && output.startsWith('={{')) { return null; } } return outputs; } validateNotAISubNode(sourceNode, result) { const outputTypes = this.getNodeOutputTypes(sourceNode.type); if (!outputTypes) return; const hasMainOutput = outputTypes.some(t => t === 'main'); if (hasMainOutput) return; const aiTypes = outputTypes.filter(t => t !== 'main'); const expectedType = aiTypes[0] || 'ai_languageModel'; result.errors.push({ type: 'error', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Node "${sourceNode.name}" (${sourceNode.type}) is an AI sub-node that outputs "${expectedType}" connections. ` + `It cannot be used with "main" connections. Connect it to an AI Agent or Chain via "${expectedType}" instead.`, code: 'AI_SUBNODE_MAIN_CONNECTION' }); } getShortNodeType(sourceNode) { const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type); return normalizedType.replace(/^(n8n-)?nodes-base\./, ''); } isCorePackageType(normalizedType) { return normalizedType.startsWith('nodes-base.') || normalizedType.startsWith('nodes-langchain.'); } isAdvisoryProfile(profile) { return profile === 'ai-friendly' || profile === 'strict'; } getMainOutputCount(sourceNode) { const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type); const nodeInfo = this.nodeRepository.getNode(normalizedType); if (!nodeInfo || !nodeInfo.outputs) return null; if (!Array.isArray(nodeInfo.outputs)) return null; const mainOutputCount = nodeInfo.outputs.filter((o) => typeof o === 'string' ? o === 'main' : (o.type === 'main' || !o.type)).length; if (mainOutputCount === 0) return null; const conditionalInfo = this.getConditionalOutputInfo(sourceNode); if (conditionalInfo) { return conditionalInfo.expectedOutputs; } if (this.getShortNodeType(sourceNode) === 'switch') { return null; } return mainOutputCount; } getConditionalOutputInfo(sourceNode) { const shortType = this.getShortNodeType(sourceNode); if (shortType === 'if' || shortType === 'filter') { return { shortType, expectedOutputs: 2 }; } if (shortType === 'switch') { const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules; if (Array.isArray(rules)) { return { shortType, expectedOutputs: rules.length + 1 }; } return null; } return null; } validateOutputIndexBounds(sourceNode, outputs, result) { const naturalOutputCount = this.getMainOutputCount(sourceNode); if (naturalOutputCount === null) return; let mainOutputCount = naturalOutputCount; if (sourceNode.onError === 'continueErrorOutput') { mainOutputCount += 1; } const maxOutputIndex = outputs.length - 1; if (maxOutputIndex >= mainOutputCount) { for (let i = mainOutputCount; i < outputs.length; i++) { if (outputs[i] && outputs[i].length > 0) { result.errors.push({ type: 'error', nodeId: sourceNode.id, nodeName: sourceNode.name, message: `Output index ${i} on node "${sourceNode.name}" exceeds its output count (${mainOutputCount}). ` + `This node has ${mainOutputCount} main output(s) (indices 0-${mainOutputCount - 1}).`, code: 'OUTPUT_INDEX_OUT_OF_BOUNDS' }); result.statistics.invalidConnections++; } } } } validateConditionalBranchUsage(sourceNode, outputs, result) { const conditionalInfo = this.getConditionalOutputInfo(sourceNode); if (!conditionalInfo || conditionalInfo.expectedOutputs < 2) return; const { shortType, expectedOutputs } = conditionalInfo; const main0Count = outputs[0]?.length || 0; if (main0Count < 2) return; const hasHigherIndexConnections = outputs.slice(1).some(conns => conns && conns.length > 0); if (hasHigherIndexConnections) return; let message; if (shortType === 'if' || shortType === 'filter') { const isFilter = shortType === 'filter'; const displayName = isFilter ? 'Filter' : 'IF'; const trueLabel = isFilter ? 'matched' : 'true'; const falseLabel = isFilter ? 'unmatched' : 'false'; message = `${displayName} node "${sourceNode.name}" has ${main0Count} connections on the "${trueLabel}" branch (main[0]) ` + `but no connections on the "${falseLabel}" branch (main[1]). ` + `All ${main0Count} target nodes execute together on the "${trueLabel}" branch, ` + `while the "${falseLabel}" branch has no effect. ` + `Split connections: main[0] for ${trueLabel}, main[1] for ${falseLabel}.`; } else { message = `Switch node "${sourceNode.name}" has ${main0Count} connections on output 0 ` + `but no connections on any other outputs (1-${expectedOutputs - 1}). ` + `All ${main0Count} target nodes execute together on output 0, ` + `while other switch branches have no effect. ` + `Distribute connections across outputs to match switch rules.`; } result.warnings.push({ type: 'warning', nodeId: sourceNode.id, nodeName: sourceNode.name, message, code: 'CONDITIONAL_BRANCH_FANOUT' }); } validateInputIndexBounds(sourceName, targetNode, connection, result) { const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type); const nodeInfo = this.nodeRepository.getNode(normalizedType); if (!nodeInfo) return; const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, ''); if (nodeInfo.isTrigger || (0, node_type_utils_1.isTriggerNode)(targetNode.type)) { if (connection.index >= 0) { result.errors.push({ type: 'error', nodeName: targetNode.name, message: `Input index ${connection.index} on node "${targetNode.name}" exceeds its input count (0). ` + `Connection from "${sourceName}" targets input ${connection.index}, but trigger nodes have no main inputs.`, code: 'INPUT_INDEX_OUT_OF_BOUNDS' }); result.statistics.invalidConnections++; } return; } if (shortType === 'merge' || shortType === 'compareDatasets') { const rawInputs = targetNode.parameters?.numberInputs; if (rawInputs != null && rawInputs !== '') { if (typeof rawInputs === 'string' && rawInputs.trim().startsWith('=')) { return; } const parsed = Number(rawInputs); if (!Number.isFinite(parsed) || parsed <= 0) { return; } if (connection.index >= parsed) { result.errors.push({ type: 'error', nodeName: targetNode.name, message: `Input index ${connection.index} on node "${targetNode.name}" exceeds its input count (${parsed}). ` + `Connection from "${sourceName}" targets input ${connection.index}, but this node has ${parsed} main input(s) (indices 0-${parsed - 1}).`, code: 'INPUT_INDEX_OUT_OF_BOUNDS' }); result.statistics.invalidConnections++; } return; } const defaultInputs = 2; if (connection.index >= defaultInputs) { result.warnings.push({ type: 'warning', nodeName: targetNode.name, message: `Input index ${connection.index} on node "${targetNode.name}" exceeds the default input count (${defaultInputs}) — ` + `numberInputs is not set, so n8n will ignore this connection and drop items from "${sourceName}". ` + `Set numberInputs to at least ${connection.index + 1} to use this input.`, code: 'MERGE_EXTRA_INPUTS_IGNORED' }); } return; } } flagOrphanedNodes(workflow, result) { const connectedNodes = new Set(); for (const [sourceName, outputs] of Object.entries(workflow.connections)) { connectedNodes.add(sourceName); for (const outputConns of Object.values(outputs)) { if (!Array.isArray(outputConns)) continue; for (const conns of outputConns) { if (!conns) continue; for (const conn of conns) { if (conn) connectedNodes.add(conn.node); } } } } for (const node of workflow.nodes) { if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type)) continue; if ((0, node_type_utils_1.isTriggerNode)(node.type)) continue; if (!connectedNodes.has(node.name)) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Node is not connected to any other nodes' }); } } } validateTriggerReachability(workflow, result) { const adjacency = new Map(); for (const [sourceName, outputs] of Object.entries(workflow.connections)) { if (!adjacency.has(sourceName)) adjacency.set(sourceName, new Set()); for (const [connectionType, outputConns] of Object.entries(outputs)) { if (Array.isArray(outputConns)) { for (const conns of outputConns) { if (!conns) continue; for (const conn of conns) { if (conn) { adjacency.get(sourceName).add(conn.node); if (!adjacency.has(conn.node)) adjacency.set(conn.node, new Set()); if (connectionType.startsWith('ai_')) { adjacency.get(conn.node).add(sourceName); } } } } } } } const triggerNodes = []; for (const node of workflow.nodes) { if ((0, node_type_utils_1.isTriggerNode)(node.type) && !node.disabled) { triggerNodes.push(node.name); } } if (triggerNodes.length === 0) { this.flagOrphanedNodes(workflow, result); return; } const reachable = new Set(); const queue = [...triggerNodes]; for (const t of triggerNodes) reachable.add(t); while (queue.length > 0) { const current = queue.shift(); const neighbors = adjacency.get(current); if (neighbors) { for (const neighbor of neighbors) { if (!reachable.has(neighbor)) { reachable.add(neighbor); queue.push(neighbor); } } } } for (const node of workflow.nodes) { if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type)) continue; if ((0, node_type_utils_1.isTriggerNode)(node.type)) continue; if (!reachable.has(node.name)) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Node is not reachable from any trigger node' }); } } } hasCycle(workflow) { const visited = new Set(); const recursionStack = new Set(); const path = []; const nodeTypeMap = new Map(); const nodeMap = new Map(); workflow.nodes.forEach(node => { if (!(0, node_classification_1.isNonExecutableNode)(node.type)) { nodeTypeMap.set(node.name, node.type); nodeMap.set(node.name, node); } }); const loopNodeTypes = [ 'n8n-nodes-base.splitInBatches', 'nodes-base.splitInBatches', 'n8n-nodes-base.itemLists', 'nodes-base.itemLists', 'n8n-nodes-base.loop',