UNPKG

flowengine-n8n-workflow-builder

Version:

Build n8n workflows from text using AI. Connect to Claude, Cursor, or any LLM to generate and validate n8n workflows with expert knowledge and intelligent auto-fixing. Built by FlowEngine. Now with real node parameter schemas from n8n packages!

1,148 lines 72.2 kB
/** * n8n Workflow Validator - Advanced Auto-Fixing * * Upgraded to match FlowEngine's comprehensive validation with: * - Malformed JSON detection * - Category-aware node conversion * - Descriptive name generation * - Placeholder credentials (20+ services) * - Intelligent auto-connection * - Hanging node detection * - Duplicate connection removal * - Empty parameter placeholders * - CRITICAL AI AGENT FIXES (11 fixes from FlowEngine): * 1. Remove invalid AI tool connections * 2. Smart regular-to-tool node conversion * 3. Remove hardcoded model parameters * 4. Fix backwards tool connections * 5. Fix node positioning for AI agents * 6. Ensure descriptive names * 7. Normalize AI tool indexes * 8. Replace deprecated nodes (NEW) * 9. Remove over-linking (NEW) * 10. Rebuild orphaned connections (NEW) * VALIDATION: Validate AI agent requirements (NEW) */ import { z } from 'zod'; import { v4 as uuidv4 } from 'uuid'; const NodeSchema = z.object({ id: z.string().optional(), name: z.string(), type: z.string().regex(/^n8n-nodes-base\.|^@n8n\/|^n8n-nodes-/), typeVersion: z.number().positive().optional(), position: z.array(z.number()).length(2), parameters: z.record(z.any()), }); const ConnectionSchema = z.record(z.record(z.array(z.array(z.object({ node: z.string(), type: z.string(), index: z.number() }))))); const WorkflowSchema = z.object({ id: z.string().optional(), name: z.string().optional(), nodes: z.array(NodeSchema).min(1), connections: ConnectionSchema.optional(), active: z.boolean().optional(), settings: z.record(z.any()).optional(), }); /** * Deprecated nodes mapping (self-contained) */ const DEPRECATED_NODES = { '@n8n/n8n-nodes-langchain.openAi': '@n8n/n8n-nodes-langchain.lmChatOpenAi', '@n8n/n8n-nodes-langchain.chatOpenAi': '@n8n/n8n-nodes-langchain.lmChatOpenAi', }; /** * Required AI agent model types */ const REQUIRED_MODEL_TYPES = [ '@n8n/n8n-nodes-langchain.lmChatOpenAi', '@n8n/n8n-nodes-langchain.lmChatAnthropic', '@n8n/n8n-nodes-langchain.lmChatGoogleGemini', '@n8n/n8n-nodes-langchain.lmChatGroq', '@n8n/n8n-nodes-langchain.lmChatOllama' ]; /** * Generate descriptive node names based on node type * Matches FlowEngine's generateDescriptiveName function */ function generateDescriptiveName(node, index) { const nodeType = node.type || ''; // Map common node types to descriptive names if (nodeType.includes('manualTrigger')) return 'Manual Trigger'; if (nodeType.includes('webhook')) return 'Webhook Trigger'; if (nodeType.includes('schedule')) return 'Schedule Trigger'; if (nodeType.includes('googleSheets')) return 'Google Sheets'; if (nodeType.includes('gmail')) return 'Gmail'; if (nodeType.includes('slack')) return 'Slack'; if (nodeType.includes('httpRequest')) return 'HTTP Request'; if (nodeType.includes('set')) return 'Set Data'; if (nodeType.includes('code')) return 'Code Execute'; if (nodeType.includes('if')) return 'Condition Check'; if (nodeType.includes('function')) return 'Function'; if (nodeType.includes('merge')) return 'Merge Data'; if (nodeType.includes('split')) return 'Split Data'; if (nodeType.includes('filter')) return 'Filter Data'; if (nodeType.includes('transform')) return 'Transform Data'; if (nodeType.includes('email')) return 'Send Email'; if (nodeType.includes('file')) return 'File Operation'; if (nodeType.includes('database')) return 'Database Query'; if (nodeType.includes('lmChatOpenAi')) return 'OpenAI Chat Model'; if (nodeType.includes('lmChatAnthropic')) return 'Anthropic Chat Model'; if (nodeType.includes('agent')) return 'AI Agent'; if (nodeType.includes('memory')) return 'Chat Memory'; if (nodeType.includes('tool')) return 'AI Tool'; // Extract meaningful part from node type const typeParts = nodeType.split('.'); const baseType = typeParts[typeParts.length - 1] || 'Node'; // Convert camelCase to Title Case return baseType.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()).trim() || `Process Step ${index + 1}`; } /** * Get suggested node name from node type */ function getSuggestedNodeName(nodeType) { if (!nodeType) return 'Node'; // Map common node types to descriptive names if (nodeType.includes('manualTrigger')) return 'Manual Trigger'; if (nodeType.includes('webhook')) return 'Webhook Trigger'; if (nodeType.includes('schedule')) return 'Schedule Trigger'; if (nodeType.includes('googleSheets')) return 'Google Sheets'; if (nodeType.includes('gmail')) return 'Gmail'; if (nodeType.includes('slack')) return 'Slack'; if (nodeType.includes('httpRequest')) return 'HTTP Request'; if (nodeType.includes('set')) return 'Set Data'; if (nodeType.includes('code')) return 'Code Execute'; if (nodeType.includes('if')) return 'Condition Check'; if (nodeType.includes('function')) return 'Function'; if (nodeType.includes('merge')) return 'Merge Data'; if (nodeType.includes('split')) return 'Split Data'; if (nodeType.includes('filter')) return 'Filter Data'; if (nodeType.includes('transform')) return 'Transform Data'; if (nodeType.includes('email')) return 'Send Email'; if (nodeType.includes('file')) return 'File Operation'; if (nodeType.includes('database')) return 'Database Query'; if (nodeType.includes('lmChatOpenAi')) return 'OpenAI Chat Model'; if (nodeType.includes('lmChatAnthropic')) return 'Anthropic Chat Model'; if (nodeType.includes('agent')) return 'AI Agent'; if (nodeType.includes('memory')) return 'Chat Memory'; if (nodeType.includes('tool')) return 'AI Tool'; // Extract meaningful part from node type const typeParts = nodeType.split('.'); const baseType = typeParts[typeParts.length - 1] || 'Node'; // Convert camelCase to Title Case return baseType.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()).trim(); } /** * Check if node type is a trigger/starting node */ function isTriggerNode(nodeType) { const type = nodeType.toLowerCase(); return type.includes('trigger') || type.includes('webhook') || type.includes('manual') || type.includes('schedule') || type.includes('cron'); } /** * Check if node type is an AI tool */ function isToolNode(nodeType) { return nodeType.includes('tool') || nodeType.includes('Tool'); } /** * Check if node type is a LangChain tool (allowed to use ai_tool connections) */ function isLangChainTool(nodeType) { return nodeType.startsWith('@n8n/n8n-nodes-langchain.tool'); } /** * Check if node type is a service tool (allowed to use ai_tool connections) */ function isServiceTool(nodeType) { return nodeType.startsWith('n8n-nodes-base.') && nodeType.toLowerCase().includes('tool'); } /** * Check if node type is a language model */ function isLanguageModelNode(nodeType) { return nodeType.includes('lmChat') || nodeType.includes('ChatModel'); } /** * Check if node type is a chat model */ function isChatModelNode(nodeType) { return nodeType.includes('lmChat'); } /** * Check if node type is memory */ function isMemoryNode(nodeType) { return nodeType.includes('memory') || nodeType.includes('Memory'); } /** * Check if node type is an AI agent */ function isAgentNode(nodeType) { return nodeType.includes('agent') || nodeType.includes('Agent'); } /** * Check if node type is an AI agent (strict check) */ function isAIAgentNode(nodeType) { return nodeType === '@n8n/n8n-nodes-langchain.agent'; } /** * Check if workflow has any AI agents */ function hasAIAgents(workflow) { if (!workflow.nodes || !Array.isArray(workflow.nodes)) { return false; } return workflow.nodes.some((node) => isAgentNode(node.type || '')); } /** * Check if node is connected only to AI infrastructure */ function isConnectedOnlyToAIInfrastructure(nodeName, connections) { if (!connections) return true; // If no connections, consider it disconnected let hasConnections = false; let allConnectionsAreAI = true; // Check all connections from this node for (const [sourceName, conns] of Object.entries(connections)) { if (sourceName === nodeName) { if (typeof conns === 'object' && conns !== null) { for (const outputs of Object.values(conns)) { if (Array.isArray(outputs)) { for (const connArray of outputs) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn && typeof conn === 'object' && 'node' in conn) { hasConnections = true; // AI infrastructure connections are ai_tool, ai_languageModel, ai_memory, etc. if (conn.type !== 'ai_tool' && conn.type !== 'ai_languageModel' && conn.type !== 'ai_memory' && conn.type !== 'ai_outputParser') { allConnectionsAreAI = false; } } } } } } } } } } // Check all connections to this node for (const [sourceName, conns] of Object.entries(connections)) { if (typeof conns === 'object' && conns !== null) { for (const outputs of Object.values(conns)) { if (Array.isArray(outputs)) { for (const connArray of outputs) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn && typeof conn === 'object' && 'node' in conn && conn.node === nodeName) { hasConnections = true; if (conn.type !== 'ai_tool' && conn.type !== 'ai_languageModel' && conn.type !== 'ai_memory' && conn.type !== 'ai_outputParser') { allConnectionsAreAI = false; } } } } } } } } } return !hasConnections || allConnectionsAreAI; } /** * Check if source node is connected to target node */ function isConnectedTo(connections, sourceNode, targetNode) { if (!connections || !connections[sourceNode]) { return false; } const sourceConns = connections[sourceNode]; if (typeof sourceConns !== 'object' || sourceConns === null) { return false; } for (const outputs of Object.values(sourceConns)) { if (Array.isArray(outputs)) { for (const connArray of outputs) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn && typeof conn === 'object' && conn.node === targetNode) { return true; } } } } } } return false; } /** * Get tool variant of a regular node type if it exists */ function getToolVariant(nodeType) { // Map of regular nodes to their tool variants const toolMappings = { 'n8n-nodes-base.gmail': 'n8n-nodes-base.gmailTool', 'n8n-nodes-base.googleSheets': 'n8n-nodes-base.googleSheetsTool', 'n8n-nodes-base.slack': 'n8n-nodes-base.slackTool', 'n8n-nodes-base.notion': 'n8n-nodes-base.notionTool', 'n8n-nodes-base.airtable': 'n8n-nodes-base.airtableTool', 'n8n-nodes-base.github': 'n8n-nodes-base.githubTool', 'n8n-nodes-base.googleDrive': 'n8n-nodes-base.googleDriveTool', 'n8n-nodes-base.hubspot': 'n8n-nodes-base.hubspotTool', 'n8n-nodes-base.salesforce': 'n8n-nodes-base.salesforceTool', 'n8n-nodes-base.jira': 'n8n-nodes-base.jiraTool', 'n8n-nodes-base.trello': 'n8n-nodes-base.trelloTool', 'n8n-nodes-base.asana': 'n8n-nodes-base.asanaTool', 'n8n-nodes-base.linear': 'n8n-nodes-base.linearTool', 'n8n-nodes-base.discord': 'n8n-nodes-base.discordTool', 'n8n-nodes-base.telegram': 'n8n-nodes-base.telegramTool', 'n8n-nodes-base.httpRequest': 'n8n-nodes-base.httpRequestTool', }; return toolMappings[nodeType] || null; } /** * CRITICAL FIX #1: Remove Invalid AI Tool Connections * Only LangChain tools and service tools can use ai_tool connections. * Regular nodes should use main connections. */ function removeInvalidAIToolConnections(workflow) { console.log('[REMOVE-INVALID-AI-TOOL-CONNECTIONS] Starting validation...'); if (!workflow.connections) { console.log('[REMOVE-INVALID-AI-TOOL-CONNECTIONS] No connections to validate'); return { removed: 0 }; } let removed = 0; // Build a map of node names to node types const nodeTypeMap = new Map(); if (workflow.nodes && Array.isArray(workflow.nodes)) { for (const node of workflow.nodes) { if (node.name && node.type) { nodeTypeMap.set(node.name, node.type); } } } // Check all connections for (const [sourceName, conns] of Object.entries(workflow.connections)) { const sourceType = nodeTypeMap.get(sourceName); if (!sourceType) continue; // Check if this node is allowed to have ai_tool connections const isAllowedToolNode = isLangChainTool(sourceType) || isServiceTool(sourceType); if (!isAllowedToolNode && typeof conns === 'object' && conns !== null) { // This is a regular node - remove ai_tool connections if ('ai_tool' in conns) { console.log(`[REMOVE-INVALID-AI-TOOL-CONNECTIONS] Removing ai_tool connection from regular node "${sourceName}" (type: ${sourceType})`); delete conns.ai_tool; removed++; // Convert to main connection if there were ai_tool connections // This ensures the node isn't left disconnected } } } console.log(`[REMOVE-INVALID-AI-TOOL-CONNECTIONS] Removed ${removed} invalid ai_tool connections`); return { removed }; } /** * CRITICAL FIX #2: Smart Regular-to-Tool Node Conversion * Converts regular service nodes to Tool variants when appropriate. * Conversion rules: * 1. Tool variant must exist * 2. Workflow must have AI agents * 3. Node must be disconnected OR only connected to AI infrastructure * 4. Nodes in main workflow flow are kept as regular nodes */ function convertRegularNodesToTools(workflow) { console.log('[CONVERT-REGULAR-TO-TOOLS] Starting conversion check...'); if (!workflow.nodes || !Array.isArray(workflow.nodes)) { console.log('[CONVERT-REGULAR-TO-TOOLS] No nodes to convert'); return { converted: 0, fixes: [] }; } const fixes = []; let converted = 0; // Check if workflow has AI agents if (!hasAIAgents(workflow)) { console.log('[CONVERT-REGULAR-TO-TOOLS] No AI agents found - skipping conversion'); return { converted: 0, fixes: [] }; } console.log('[CONVERT-REGULAR-TO-TOOLS] AI agents detected, checking for convertible nodes...'); for (const node of workflow.nodes) { if (!node.type || !node.name) continue; // Skip if already a tool node if (isToolNode(node.type)) { continue; } // Check if tool variant exists const toolVariant = getToolVariant(node.type); if (!toolVariant) { continue; } // Check if node is disconnected or only connected to AI infrastructure const shouldConvert = isConnectedOnlyToAIInfrastructure(node.name, workflow.connections); if (shouldConvert) { const oldType = node.type; node.type = toolVariant; converted++; const fix = `Converted "${node.name}" from ${oldType} to ${toolVariant} (AI tool variant)`; fixes.push(fix); console.log(`[CONVERT-REGULAR-TO-TOOLS] ${fix}`); } } console.log(`[CONVERT-REGULAR-TO-TOOLS] Converted ${converted} nodes to tool variants`); return { converted, fixes }; } /** * CRITICAL FIX #3: Remove Hardcoded Model Parameters * LLM nodes should NEVER have hardcoded model names. * Only allowed: parameters: { options: {} } or parameters: { options: { temperature: 0.7, maxTokens: 4096 } } */ function removeHardcodedModelParameters(workflow) { console.log('[REMOVE-HARDCODED-MODELS] Starting model parameter validation...'); if (!workflow.nodes || !Array.isArray(workflow.nodes)) { console.log('[REMOVE-HARDCODED-MODELS] No nodes to validate'); return { removed: 0, fixes: [] }; } const fixes = []; let removed = 0; // List of hardcoded model names to remove const hardcodedModelPatterns = [ 'gpt-4', 'gpt-3.5', 'gpt-4-turbo', 'gpt-4o', 'claude-3', 'claude-2', 'claude-instant', 'claude-3-5-sonnet', 'claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku', 'gemini', 'palm', 'llama', 'mistral', 'mixtral', ]; for (const node of workflow.nodes) { if (!node.type || !node.name) continue; // Check if this is an LLM node if (!isLanguageModelNode(node.type)) { continue; } // Check parameters for hardcoded model names if (node.parameters && typeof node.parameters === 'object') { let hadHardcodedModel = false; // Check for 'model' parameter if ('model' in node.parameters) { const modelValue = node.parameters.model; if (typeof modelValue === 'string') { // Check if it matches any hardcoded pattern for (const pattern of hardcodedModelPatterns) { if (modelValue.toLowerCase().includes(pattern.toLowerCase())) { console.log(`[REMOVE-HARDCODED-MODELS] Removing hardcoded model "${modelValue}" from "${node.name}"`); delete node.parameters.model; hadHardcodedModel = true; removed++; break; } } } } // Also check nested model configurations if ('modelName' in node.parameters) { const modelValue = node.parameters.modelName; if (typeof modelValue === 'string') { for (const pattern of hardcodedModelPatterns) { if (modelValue.toLowerCase().includes(pattern.toLowerCase())) { console.log(`[REMOVE-HARDCODED-MODELS] Removing hardcoded modelName "${modelValue}" from "${node.name}"`); delete node.parameters.modelName; hadHardcodedModel = true; removed++; break; } } } } if (hadHardcodedModel) { const fix = `Removed hardcoded model parameter from LLM node "${node.name}" (type: ${node.type})`; fixes.push(fix); // Ensure we have a valid parameters object with at least options if (!node.parameters.options) { node.parameters.options = {}; } } } } console.log(`[REMOVE-HARDCODED-MODELS] Removed ${removed} hardcoded model parameters`); return { removed, fixes }; } /** * CRITICAL FIX #4: Fix Backwards Tool Connections * CORRECT n8n pattern: Tools connect TO agent (tool is SOURCE, agent is TARGET) * WRONG pattern: Agent connects TO tool (agent is SOURCE, tool is TARGET) * This function reverses any backwards connections. */ function fixBackwardsToolConnections(workflow) { console.log('[FIX-BACKWARDS-TOOL-CONNECTIONS] Starting connection validation...'); if (!workflow.connections || !workflow.nodes) { console.log('[FIX-BACKWARDS-TOOL-CONNECTIONS] No connections to validate'); return { fixed: 0, fixes: [] }; } const fixes = []; let fixed = 0; // Build a map of node names to node types const nodeTypeMap = new Map(); for (const node of workflow.nodes) { if (node.name && node.type) { nodeTypeMap.set(node.name, node.type); } } const backwardsConnections = []; // Find backwards connections (agent -> tool with ai_tool type) for (const [sourceName, conns] of Object.entries(workflow.connections)) { const sourceType = nodeTypeMap.get(sourceName); if (!sourceType || !isAgentNode(sourceType)) continue; if (typeof conns === 'object' && conns !== null) { for (const [connType, outputs] of Object.entries(conns)) { if (connType === 'ai_tool' && Array.isArray(outputs)) { for (const connArray of outputs) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn && typeof conn === 'object' && 'node' in conn) { const targetType = nodeTypeMap.get(conn.node); if (targetType && isToolNode(targetType)) { // Found backwards connection: agent -> tool backwardsConnections.push({ agentName: sourceName, toolName: conn.node, connectionType: conn.type, index: conn.index || 0, }); console.log(`[FIX-BACKWARDS-TOOL-CONNECTIONS] Found backwards connection: agent "${sourceName}" -> tool "${conn.node}"`); } } } } } } } } } // Fix each backwards connection by reversing it for (const backwards of backwardsConnections) { const { agentName, toolName, index } = backwards; // Remove the backwards connection (agent -> tool) if (workflow.connections[agentName] && workflow.connections[agentName].ai_tool) { workflow.connections[agentName].ai_tool = workflow.connections[agentName].ai_tool.map((connArray) => { if (!Array.isArray(connArray)) return connArray; return connArray.filter((conn) => conn.node !== toolName); }).filter((arr) => arr.length > 0); // Clean up empty ai_tool array if (workflow.connections[agentName].ai_tool.length === 0) { delete workflow.connections[agentName].ai_tool; } } // Create the correct connection (tool -> agent) if (!workflow.connections[toolName]) { workflow.connections[toolName] = {}; } if (!workflow.connections[toolName].ai_tool) { workflow.connections[toolName].ai_tool = [[]]; } // Add the connection if it doesn't already exist const existingConnection = workflow.connections[toolName].ai_tool[0].find((conn) => conn.node === agentName); if (!existingConnection) { workflow.connections[toolName].ai_tool[0].push({ node: agentName, type: 'ai_tool', index: index, }); fixed++; const fix = `Reversed backwards connection: tool "${toolName}" now correctly connects TO agent "${agentName}"`; fixes.push(fix); console.log(`[FIX-BACKWARDS-TOOL-CONNECTIONS] ${fix}`); } } console.log(`[FIX-BACKWARDS-TOOL-CONNECTIONS] Fixed ${fixed} backwards connections`); return { fixed, fixes }; } /** * CRITICAL FIX #5: Fix Node Positioning * UPDATED to use FlowEngine's generator positioning logic (horizontal spread below agent) * Based on FlowEngine/src/lib/workflowGenerator.ts lines 1660-1684 */ function fixNodePositioning(workflow) { const fixes = []; let changed = false; const aiAgents = workflow.nodes.filter((n) => isAIAgentNode(n.type)); // For each AI agent, position support nodes BELOW and spread horizontally for (const agent of aiAgents) { const agentX = agent.position[0]; const agentY = agent.position[1]; // Find connected nodes const languageModels = workflow.nodes.filter((n) => isChatModelNode(n.type) && isConnectedTo(workflow.connections, n.name, agent.name)); const memoryNodes = workflow.nodes.filter((n) => isMemoryNode(n.type) && isConnectedTo(workflow.connections, n.name, agent.name)); const toolNodes = workflow.nodes.filter((n) => isToolNode(n.type) && isConnectedTo(workflow.connections, n.name, agent.name)); // Position language model BELOW and LEFT of agent // FlowEngine formula: [agentX - 200, agentY + 300] languageModels.forEach((model, index) => { const newPos = [agentX - 200, agentY + 300 + (index * 150)]; if (model.position[0] !== newPos[0] || model.position[1] !== newPos[1]) { model.position = newPos; fixes.push(`📍 Repositioned "${model.name}" below-left of AI Agent`); changed = true; } }); // Position memory BELOW and RIGHT of agent // FlowEngine formula: [agentX + 200, agentY + 300] memoryNodes.forEach((memory, index) => { const newPos = [agentX + 200, agentY + 300 + (index * 150)]; if (memory.position[0] !== newPos[0] || memory.position[1] !== newPos[1]) { memory.position = newPos; fixes.push(`📍 Repositioned "${memory.name}" below-right of AI Agent`); changed = true; } }); // Position tools BELOW and CENTER of agent, spread horizontally // FlowEngine formula: [agentX + (index - floor(length/2)) * 200, agentY + 300] toolNodes.forEach((tool, index) => { const newPos = [ agentX + (index - Math.floor(toolNodes.length / 2)) * 200, agentY + 300 ]; if (tool.position[0] !== newPos[0] || tool.position[1] !== newPos[1]) { tool.position = newPos; fixes.push(`📍 Repositioned "${tool.name}" below-center of AI Agent`); changed = true; } }); } return { changed, fixes }; } /** * CRITICAL FIX #6: Ensure Descriptive Names * Renames generic "Node1", "node2" to descriptive names */ function ensureDescriptiveNames(workflow) { console.log('[ENSURE-DESCRIPTIVE-NAMES] Starting name validation...'); if (!workflow.nodes || !Array.isArray(workflow.nodes)) { console.log('[ENSURE-DESCRIPTIVE-NAMES] No nodes to check'); return { changed: false, fixes: [] }; } const fixes = []; let changed = false; const usedNames = new Set(); // First pass: collect all non-generic names for (const node of workflow.nodes) { if (node.name && !/^(Node|node)\d+$/i.test(node.name)) { usedNames.add(node.name); } } // Second pass: rename generic names for (const node of workflow.nodes) { if (!node.name || /^(Node|node)\d+$/i.test(node.name)) { const oldName = node.name; let newName = getSuggestedNodeName(node.type); let counter = 1; // Ensure uniqueness while (usedNames.has(newName)) { counter++; newName = `${getSuggestedNodeName(node.type)} ${counter}`; } // Update node name node.name = newName; usedNames.add(newName); // Update connections that reference this node if (workflow.connections && oldName) { // Update as source node if (workflow.connections[oldName]) { workflow.connections[newName] = workflow.connections[oldName]; delete workflow.connections[oldName]; } // Update as target node for (const conns of Object.values(workflow.connections)) { if (typeof conns === 'object' && conns !== null) { for (const outputs of Object.values(conns)) { if (Array.isArray(outputs)) { for (const connArray of outputs) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn && conn.node === oldName) { conn.node = newName; } } } } } } } } } fixes.push(`Renamed generic "${oldName}" to "${newName}"`); changed = true; console.log(`[ENSURE-DESCRIPTIVE-NAMES] Renamed "${oldName}" to "${newName}"`); } } console.log(`[ENSURE-DESCRIPTIVE-NAMES] ${changed ? 'Fixed' : 'No changes to'} node names`); return { changed, fixes }; } /** * CRITICAL FIX #7: Normalize AI Tool Indexes * ALL ai_tool connections must have index: 0 * Scans all connections, fixes any non-zero indexes */ function normalizeAIToolIndexes(workflow) { console.log('[NORMALIZE-AI-TOOL-INDEXES] Starting index validation...'); if (!workflow.connections) { console.log('[NORMALIZE-AI-TOOL-INDEXES] No connections to validate'); return { fixed: 0, fixes: [] }; } const fixes = []; let fixed = 0; // Scan all connections for (const [sourceName, conns] of Object.entries(workflow.connections)) { if (typeof conns === 'object' && conns !== null) { // Check for ai_tool connections if ('ai_tool' in conns) { const aiToolConns = conns.ai_tool; if (Array.isArray(aiToolConns)) { for (const connArray of aiToolConns) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn && typeof conn === 'object' && 'index' in conn) { if (conn.index !== 0) { const oldIndex = conn.index; conn.index = 0; fixed++; const fix = `Fixed ai_tool connection from "${sourceName}" to "${conn.node}": index ${oldIndex} -> 0`; fixes.push(fix); console.log(`[NORMALIZE-AI-TOOL-INDEXES] ${fix}`); } } } } } } } } } console.log(`[NORMALIZE-AI-TOOL-INDEXES] Fixed ${fixed} ai_tool connection indexes`); return { fixed, fixes }; } /** * CRITICAL FIX #8: Replace Deprecated Nodes (NEW from FlowEngine) * Replace deprecated node types with modern equivalents: * - Old: @n8n/n8n-nodes-langchain.openAi → New: @n8n/n8n-nodes-langchain.lmChatOpenAi * - Update node name to match new type */ function replaceDeprecatedNodes(workflow) { console.log('[REPLACE-DEPRECATED-NODES] Starting deprecated node check...'); if (!workflow.nodes || !Array.isArray(workflow.nodes)) { console.log('[REPLACE-DEPRECATED-NODES] No nodes to check'); return { changed: false, fixes: [] }; } const fixes = []; let changed = false; for (const node of workflow.nodes) { if (!node.type) continue; // Check if node type is deprecated if (DEPRECATED_NODES[node.type]) { const oldType = node.type; const newType = DEPRECATED_NODES[node.type]; node.type = newType; node.name = getSuggestedNodeName(newType); fixes.push(`Replaced deprecated node "${oldType}" with "${newType}" (name: "${node.name}")`); changed = true; console.log(`[REPLACE-DEPRECATED-NODES] Replaced "${oldType}" → "${newType}"`); } } console.log(`[REPLACE-DEPRECATED-NODES] ${changed ? 'Fixed' : 'No changes to'} deprecated nodes`); return { changed, fixes }; } /** * Helper: Find node connected to target via specific connection type */ function findConnectedNodeByType(workflow, targetName, connectionType) { const connections = workflow.connections || {}; for (const [sourceName, sourceConns] of Object.entries(connections)) { const typedConns = sourceConns; if (typedConns[connectionType]) { for (const connArray of typedConns[connectionType]) { for (const conn of connArray) { if (conn.node === targetName) { return workflow.nodes.find((n) => n.name === sourceName); } } } } } return null; } /** * Helper: Check if path exists between two nodes */ function hasPathBetween(workflow, fromNode, toNode, visited = new Set()) { if (fromNode === toNode) return true; if (visited.has(fromNode)) return false; visited.add(fromNode); const connections = workflow.connections || {}; const nodeConnections = connections[fromNode]?.main?.[0] || []; for (const conn of nodeConnections) { if (hasPathBetween(workflow, conn.node, toNode, visited)) { return true; } } return false; } /** * VALIDATION: Validate AI Agent Requirements (NEW from FlowEngine) * Validate EVERY AI agent has required connections: * - Must have language model via ai_languageModel * - Must have memory via ai_memory * - Should have tools via ai_tool (warning if missing) */ function validateAIAgentRequirements(workflow) { console.log('[VALIDATE-AI-AGENT-REQUIREMENTS] Starting AI agent validation...'); const errors = []; const warnings = []; if (!workflow.nodes || !Array.isArray(workflow.nodes)) { return { errors, warnings }; } // Find all AI agent nodes const aiAgents = workflow.nodes.filter((n) => isAIAgentNode(n.type)); if (aiAgents.length === 0) { console.log('[VALIDATE-AI-AGENT-REQUIREMENTS] No AI agents found - skipping validation'); return { errors, warnings }; } console.log(`[VALIDATE-AI-AGENT-REQUIREMENTS] Found ${aiAgents.length} AI agent(s)`); // Validate each agent for (const agent of aiAgents) { // Rule 1: Must have language model connection const modelNode = findConnectedNodeByType(workflow, agent.name, 'ai_languageModel'); if (!modelNode) { errors.push(`Agent "${agent.name}" missing language model connection (required: lmChatOpenAi/Anthropic/Gemini/Groq/Ollama)`); } else { // Validate it's a proper chat model if (!REQUIRED_MODEL_TYPES.includes(modelNode.type)) { errors.push(`Agent "${agent.name}" has invalid model type "${modelNode.type}" (expected one of: ${REQUIRED_MODEL_TYPES.join(', ')})`); } } // Rule 2: Must have memory connection const memoryNode = findConnectedNodeByType(workflow, agent.name, 'ai_memory'); if (!memoryNode) { errors.push(`Agent "${agent.name}" missing memory connection (required: memoryBufferWindow or similar)`); } else { // Validate it's a proper memory node if (!isMemoryNode(memoryNode.type)) { errors.push(`Agent "${agent.name}" has invalid memory type "${memoryNode.type}"`); } } // Rule 3: Should have tools (warning if missing) const connections = workflow.connections || {}; let hasTools = false; for (const [sourceName, conns] of Object.entries(connections)) { if (typeof conns === 'object' && conns !== null) { const aiToolConns = conns.ai_tool; if (Array.isArray(aiToolConns)) { for (const connArray of aiToolConns) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn && conn.node === agent.name) { hasTools = true; break; } } } } } } } if (!hasTools) { warnings.push(`Agent "${agent.name}" has no tools connected - agent may have limited capabilities`); } } console.log(`[VALIDATE-AI-AGENT-REQUIREMENTS] Validation complete: ${errors.length} errors, ${warnings.length} warnings`); return { errors, warnings }; } /** * CRITICAL FIX #9: Remove Over-linking (NEW from FlowEngine) * Remove excess connections - each node should connect to only ONE next node: * - Skip conditional nodes (IF/Switch) - they can have multiple outputs * - Keep only first connection, remove others */ function removeOverlinking(workflow) { console.log('[REMOVE-OVERLINKING] Starting over-linking check...'); if (!workflow.connections) { console.log('[REMOVE-OVERLINKING] No connections to check'); return { fixed: 0, fixes: [] }; } const fixes = []; let fixed = 0; for (const [sourceNode, nodeConns] of Object.entries(workflow.connections)) { // Only check main connections (not ai_tool, ai_languageModel, etc.) if (!nodeConns || !nodeConns.main) continue; const mainConns = nodeConns.main[0]; if (!Array.isArray(mainConns) || mainConns.length <= 1) continue; // Check if this is a conditional node (IF/Switch) that legitimately has multiple outputs const sourceNodeObj = workflow.nodes.find((n) => n.name === sourceNode); const isConditionalNode = sourceNodeObj?.type === 'n8n-nodes-base.if' || sourceNodeObj?.type === 'n8n-nodes-base.switch'; if (isConditionalNode) { console.log(`[REMOVE-OVERLINKING] Skipping conditional node "${sourceNode}" (legitimate multiple outputs)`); continue; } // Non-conditional node with multiple connections - keep only the FIRST connection const firstConnection = mainConns[0]; const removedConnections = mainConns.slice(1); console.log(`[REMOVE-OVERLINKING] Found over-linked node: "${sourceNode}" → ${mainConns.length} targets`); console.log(`[REMOVE-OVERLINKING] Keeping: "${firstConnection.node}"`); removedConnections.forEach((conn) => { console.log(`[REMOVE-OVERLINKING] Removing: "${conn.node}"`); }); // Keep only the first connection nodeConns.main[0] = [firstConnection]; removedConnections.forEach((conn) => { fixes.push(`Removed over-linking: "${sourceNode}" no longer connects to "${conn.node}" (keeping linear flow to "${firstConnection.node}")`); }); fixed += removedConnections.length; } console.log(`[REMOVE-OVERLINKING] Removed ${fixed} excess connections`); return { fixed, fixes }; } /** * CRITICAL FIX #10: Rebuild Orphaned Connections (NEW from FlowEngine) * Reconnect orphaned nodes (no incoming AND no outgoing connections): * - Skip AI agents (they don't need main output) * - Tool nodes → connect via ai_tool to nearest agent * - Regular nodes → connect from agent's main output */ function rebuildOrphanedConnections(workflow) { console.log('[REBUILD-ORPHANED-CONNECTIONS] Starting orphaned node check...'); if (!workflow.nodes || !Array.isArray(workflow.nodes)) { console.log('[REBUILD-ORPHANED-CONNECTIONS] No nodes to check'); return { fixed: 0, fixes: [] }; } const fixes = []; let fixed = 0; const connections = workflow.connections || {}; // Build incoming connections map to detect nodes that have incoming connections const incomingConnections = new Map(); for (const [sourceNode, conns] of Object.entries(connections)) { for (const [connType, connArrays] of Object.entries(conns)) { if (Array.isArray(connArrays)) { for (const connArray of connArrays) { if (Array.isArray(connArray)) { for (const conn of connArray) { if (conn.node) { incomingConnections.set(conn.node, (incomingConnections.get(conn.node) || 0) + 1); } } } } } } } // Find nodes with NO connections (neither incoming nor outgoing) for (const node of workflow.nodes) { const nodeConns = connections[node.name]; const hasOutgoing = nodeConns && Object.keys(nodeConns).length > 0; const hasIncoming = incomingConnections.has(node.name); // Only consider truly orphaned nodes (no incoming AND no outgoing) const isOrphaned = !hasOutgoing && !hasIncoming; if (isOrphaned) { console.log(`[REBUILD-ORPHANED-CONNECTIONS] Found orphaned node: "${node.name}" (type: ${node.type})`); // Skip AI Agents - they often have no main output (chat-based workflows) if (isAIAgentNode(node.type)) { console.log(`[REBUILD-ORPHANED-CONNECTIONS] Skipping AI Agent "${node.name}" - no main output needed for chat workflows`); continue; } // Check if it's a tool node const isToolNodeType = isLangChainTool(node.type) || isServiceTool(node.type); if (isToolNodeType) { // Rebuild ai_tool connection to nearest agent const agents = workflow.nodes.filter((n) => isAIAgentNode(n.type)); if (agents.length > 0) { const nearestAgent = agents[0]; if (!connections[node.name]) connections[node.name] = {}; connections[node.name].ai_tool = [ [ { node: nearestAgent.name, type: 'ai_tool', index: 0, }, ], ]; fixes.push(`Rebuilt ai_tool connection: "${node.name}" → "${nearestAgent.name}"`); fixed++; console.log(`[REBUILD-ORPHANED-CONNECTIONS] Reconnected tool "${node.name}" to agent "${nearestAgent.name}"`); } } else { // Regular node - connect from AI Agent's main output (after agent executes) const agents = workflow.nodes.filter((n) => isAIAgentNode(n.type)); if (agents.length > 0) { const agent = agents[0]; // Use first agent if (!connections[agent.name]) connections[agent.name] = {}; if (!connections[agent.name].main) connections[agent.name].main = [[]]; // Add connection from agent to orphaned node connections[agent.name].main[0].push({ node: node.name, type: 'main', index: 0, }); fixes.push(`Reconnected orphaned node "${node.name}" from agent output "${agent.name}"`); fixed++; console.log(`[REBUILD-ORPHANED-CONNECTIONS] Reconnected regular node "${node.name}" from agent "${agent.name}"`); } else { // No agents found - connect from first non-trigger node as fallback const targetNode = workflow.nodes.find((n) => !n.type.toLowerCase().includes('trigger') && n.name !== node.name); if (targetNode) { if (!connections[targetNode.name]) connections[targetNode.name] = {}; if (!connections[targetNode.name].main) connections[targetNode.name].main = [[]]; connections[targetNode.name].main[0].push({ node: node.name, type: 'main', index: 0, }); fixes.push(`Reconnected orphaned node "${node.name}" from "${targetNode.name}"`); fixed++; console.log(`[REBUILD-ORPHANED-CONNECTIONS] Reconnected regular node "${node.name}" from "${targetNode.name}" (no agent found)`); } } } } } workflow.connections = connections; console.log(`[REBUILD-ORPHANED-CONNECTIONS] Fixed ${fixed} orphaned nodes`); return { fixed, fixes }; } /** * Main validation function with FlowEngine-level auto-fixing */ export function validateWorkflow(workflowJson, autofix = true) { const errors = []; const warnings = []; const fixes = []; try { // STEP 0: CRITICAL - Malformed JSON Detection (FlowEngine feature #1) if (!workflowJson || typeof workflowJson !== 'object') { return { valid: false, errors: ['❌ CRITICAL: Invalid workflow format - not a valid JSON object'], warnings: [], fixes: [], autofixed: false, }; } // Check for incomplete node structures if (workflowJson.nodes && Array.isArray(workflowJson.nodes)) { for (let i = 0; i < workflowJson.nodes.length; i++) { const node = workflowJson.nodes[i]; if (!node || typeof node !== 'object') { errors.push(`❌ CRITICAL: Node at index ${i} is not a valid object - workflow JSON is malformed`); return { valid: false, errors, warnings, fixes: [], autofixed: false }; } // Check for incomplete parameters if ('parameters' in node) { if (typeof node.parameters !== 'object') { errors.push(`❌ CRITICAL: Node "${node.name || `at index ${i}`}" has malformed parameters`); return { valid: false, errors, warnings, fixes: [], autofixed: false }; } try { JSON.stringify(node.parameters); } catch (e) { errors.push(`❌ CRITICAL: Node "${node.name || `at index ${i}`}" has unparseable parameters - workflow JSON is incomplete`); return { valid: false, errors, warnings, fixes: [], autofixed: false }; } } // Check for missing required fields if (!node.name) { errors.push(`❌ CRITICAL: Node at index ${i} is missing 'name' field`); } if (!node.type) { errors.push(`❌ CRITICAL: Node at index ${i} is missing 'type' field`); } if (!node.position || !Array.isArray(node.position)) { errors.push(`❌ CRITICAL: Node "${node.name || `at index ${i}`}" is missing 'position' field`); } } if (errors.length > 0) { return { valid: false, errors: [ '❌ WORKFLOW JSON IS MALFORMED/INCOMPLETE', 'The workflow contains incomplete node definitions.', 'This usually means the AI response was cut off or truncated.', '', 'Specific errors:', ...errors ], warnings, fixes: [], autofixed: false }; } } // STEP 1: Schema Validation const schemaValidation = WorkflowSch