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
JavaScript
/**
* 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