Files
n8n-mcp/src/services/n8n-validation.ts
Romuald Członkowski 1f0738e637 fix: auto-inject webhookId on webhook nodes during create/update (#643) (#657)
n8n 2.10+ requires webhookId (UUID) on webhook-type nodes for proper
webhook URL registration. Without it, webhooks silently fail with 404.
The n8n UI always generates webhookId but programmatic creation via
n8n-mcp did not.

Add ensureWebhookIds() helper that injects crypto.randomUUID() on
webhook, webhookTrigger, formTrigger, and chatTrigger nodes when
webhookId is missing. Called from both cleanWorkflowForCreate() and
cleanWorkflowForUpdate(). Existing webhookId values are preserved.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:20:34 +01:00

745 lines
27 KiB
TypeScript

import crypto from 'crypto';
import { z } from 'zod';
import { WorkflowNode, WorkflowConnection, Workflow } from '../types/n8n-api';
import { isTriggerNode, isActivatableTrigger } from '../utils/node-type-utils';
import { isNonExecutableNode } from '../utils/node-classification';
// Zod schemas for n8n API validation
export const workflowNodeSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
typeVersion: z.number(),
position: z.tuple([z.number(), z.number()]),
parameters: z.record(z.unknown()),
credentials: z.record(z.unknown()).optional(),
disabled: z.boolean().optional(),
notes: z.string().optional(),
notesInFlow: z.boolean().optional(),
continueOnFail: z.boolean().optional(),
retryOnFail: z.boolean().optional(),
maxTries: z.number().optional(),
waitBetweenTries: z.number().optional(),
alwaysOutputData: z.boolean().optional(),
executeOnce: z.boolean().optional(),
});
// Connection array schema used by all connection types
const connectionArraySchema = z.array(
z.array(
z.object({
node: z.string(),
type: z.string(),
index: z.number(),
})
)
);
/**
* Workflow connection schema supporting all connection types.
* Note: 'main' is optional because AI nodes exclusively use AI-specific
* connection types (ai_languageModel, ai_memory, etc.) without main connections.
*/
export const workflowConnectionSchema = z.record(
z.object({
main: connectionArraySchema.optional(),
error: connectionArraySchema.optional(),
ai_tool: connectionArraySchema.optional(),
ai_languageModel: connectionArraySchema.optional(),
ai_memory: connectionArraySchema.optional(),
ai_embedding: connectionArraySchema.optional(),
ai_vectorStore: connectionArraySchema.optional(),
}).catchall(connectionArraySchema) // Allow additional AI connection types (ai_outputParser, ai_document, ai_textSplitter, etc.)
);
export const workflowSettingsSchema = z.object({
executionOrder: z.enum(['v0', 'v1']).default('v1'),
timezone: z.string().optional(),
saveDataErrorExecution: z.enum(['all', 'none']).default('all'),
saveDataSuccessExecution: z.enum(['all', 'none']).default('all'),
saveManualExecutions: z.boolean().default(true),
saveExecutionProgress: z.boolean().default(true),
executionTimeout: z.number().optional(),
errorWorkflow: z.string().optional(),
callerPolicy: z.enum(['any', 'workflowsFromSameOwner', 'workflowsFromAList']).optional(),
availableInMCP: z.boolean().optional(),
});
// Default settings for workflow creation
export const defaultWorkflowSettings = {
executionOrder: 'v1' as const,
saveDataErrorExecution: 'all' as const,
saveDataSuccessExecution: 'all' as const,
saveManualExecutions: true,
saveExecutionProgress: true,
};
// Validation functions
export function validateWorkflowNode(node: unknown): WorkflowNode {
return workflowNodeSchema.parse(node);
}
export function validateWorkflowConnections(connections: unknown): WorkflowConnection {
return workflowConnectionSchema.parse(connections);
}
export function validateWorkflowSettings(settings: unknown): z.infer<typeof workflowSettingsSchema> {
return workflowSettingsSchema.parse(settings);
}
const WEBHOOK_NODE_TYPES = new Set([
'n8n-nodes-base.webhook',
'n8n-nodes-base.webhookTrigger',
'n8n-nodes-base.formTrigger',
'@n8n/n8n-nodes-langchain.chatTrigger',
]);
function ensureWebhookIds(nodes?: WorkflowNode[]): void {
if (!nodes) return;
for (const node of nodes) {
if (WEBHOOK_NODE_TYPES.has(node.type) && !node.webhookId) {
node.webhookId = crypto.randomUUID();
}
}
}
// Clean workflow data for API operations
export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Workflow> {
const {
// Remove read-only fields
id,
createdAt,
updatedAt,
versionId,
meta,
// Remove fields that cause API errors during creation
active,
tags,
// Keep everything else
...cleanedWorkflow
} = workflow;
// Ensure settings are present with defaults
// Treat empty settings object {} the same as missing settings
if (!cleanedWorkflow.settings || Object.keys(cleanedWorkflow.settings).length === 0) {
cleanedWorkflow.settings = defaultWorkflowSettings;
}
ensureWebhookIds(cleanedWorkflow.nodes);
return cleanedWorkflow;
}
/**
* Clean workflow data for update operations.
*
* This function removes read-only and computed fields that should not be sent
* in API update requests. It filters settings to known API-accepted properties
* to prevent "additional properties" errors.
*
* NOTE: This function filters settings to ALL known properties (12 total).
* For version-specific filtering (compatibility with older n8n versions),
* use N8nApiClient.updateWorkflow() which automatically detects the n8n version
* and filters settings accordingly.
*
* @param workflow - The workflow object to clean
* @returns A cleaned partial workflow suitable for API updates
*/
export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
const {
// Remove ALL read-only/computed fields (comprehensive list)
id,
createdAt,
updatedAt,
versionId,
versionCounter,
meta,
staticData,
pinData,
tags,
description,
isArchived,
usedCredentials,
sharedWithProjects,
triggerCount,
shared,
active,
activeVersionId,
activeVersion,
// Keep everything else
...cleanedWorkflow
} = workflow as any;
// ALL known settings properties accepted by n8n Public API (as of n8n 1.119.0+)
// This list is the UNION of all properties ever accepted by any n8n version
// Version-specific filtering is handled by N8nApiClient.updateWorkflow()
const ALL_KNOWN_SETTINGS_PROPERTIES = new Set([
// Core properties (all versions)
'saveExecutionProgress',
'saveManualExecutions',
'saveDataErrorExecution',
'saveDataSuccessExecution',
'executionTimeout',
'errorWorkflow',
'timezone',
// Added in n8n 1.37.0
'executionOrder',
// Added in n8n 1.119.0
'callerPolicy',
'callerIds',
'timeSavedPerExecution',
'availableInMCP',
]);
if (cleanedWorkflow.settings && typeof cleanedWorkflow.settings === 'object') {
// Filter to only known properties (security + prevent garbage)
const filteredSettings: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cleanedWorkflow.settings)) {
if (ALL_KNOWN_SETTINGS_PROPERTIES.has(key)) {
filteredSettings[key] = value;
}
}
// If no valid properties remain after filtering, use minimal defaults
// Issue #431: n8n API rejects empty settings objects
if (Object.keys(filteredSettings).length > 0) {
cleanedWorkflow.settings = filteredSettings;
} else {
// Minimal valid settings - executionOrder v1 is the modern default
cleanedWorkflow.settings = { executionOrder: 'v1' as const };
}
} else {
// No settings provided - use minimal valid defaults
cleanedWorkflow.settings = { executionOrder: 'v1' as const };
}
ensureWebhookIds(cleanedWorkflow.nodes);
return cleanedWorkflow;
}
// Validate workflow structure
export function validateWorkflowStructure(workflow: Partial<Workflow>): string[] {
const errors: string[] = [];
// Check required fields
if (!workflow.name) {
errors.push('Workflow name is required');
}
if (!workflow.nodes || workflow.nodes.length === 0) {
errors.push('Workflow must have at least one node');
}
// Check if workflow has only non-executable nodes (sticky notes)
if (workflow.nodes && workflow.nodes.length > 0) {
const hasExecutableNodes = workflow.nodes.some(node => !isNonExecutableNode(node.type));
if (!hasExecutableNodes) {
errors.push('Workflow must have at least one executable node. Sticky notes alone cannot form a valid workflow.');
}
}
if (!workflow.connections) {
errors.push('Workflow connections are required');
}
// Check for minimum viable workflow
if (workflow.nodes && workflow.nodes.length === 1) {
const singleNode = workflow.nodes[0];
const isWebhookOnly = singleNode.type === 'n8n-nodes-base.webhook' ||
singleNode.type === 'n8n-nodes-base.webhookTrigger';
if (!isWebhookOnly) {
errors.push(`Single non-webhook node workflow is invalid. Current node: "${singleNode.name}" (${singleNode.type}). Add another node using: {type: 'addNode', node: {name: 'Process Data', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 300], parameters: {}}}`);
}
}
// Check for disconnected nodes in multi-node workflows
if (workflow.nodes && workflow.nodes.length > 1 && workflow.connections) {
// Filter out non-executable nodes (sticky notes) when counting nodes
const executableNodes = workflow.nodes.filter(node => !isNonExecutableNode(node.type));
const connectionCount = Object.keys(workflow.connections).length;
// First check: workflow has no connections at all (only check if there are multiple executable nodes)
if (connectionCount === 0 && executableNodes.length > 1) {
const nodeNames = executableNodes.slice(0, 2).map(n => n.name);
errors.push(`Multi-node workflow has no connections between nodes. Add a connection using: {type: 'addConnection', source: '${nodeNames[0]}', target: '${nodeNames[1]}', sourcePort: 'main', targetPort: 'main'}`);
} else if (connectionCount > 0 || executableNodes.length > 1) {
// Second check: detect disconnected nodes (nodes with no incoming or outgoing connections)
const connectedNodes = new Set<string>();
// Collect all nodes that appear in connections (as source or target)
// Iterate over ALL connection types present in the data — not a hardcoded list —
// so that every AI connection type (ai_outputParser, ai_document, ai_textSplitter,
// ai_agent, ai_chain, ai_retriever, etc.) is covered automatically.
Object.entries(workflow.connections).forEach(([sourceName, connection]) => {
connectedNodes.add(sourceName); // Node has outgoing connection
// Check every connection type key present on this source node
const connectionRecord = connection as Record<string, unknown>;
Object.values(connectionRecord).forEach((connData) => {
if (connData && Array.isArray(connData)) {
connData.forEach((outputs) => {
if (Array.isArray(outputs)) {
outputs.forEach((target: { node: string }) => {
if (target?.node) {
connectedNodes.add(target.node); // Node has incoming connection
}
});
}
});
}
});
});
// Find disconnected nodes (excluding non-executable nodes and triggers)
// Non-executable nodes (sticky notes) are UI-only and don't need connections
// Trigger nodes need either outgoing connections OR inbound AI connections (for mcpTrigger)
const disconnectedNodes = workflow.nodes.filter(node => {
// Skip non-executable nodes (sticky notes, etc.) - they're UI-only annotations
if (isNonExecutableNode(node.type)) {
return false;
}
const isConnected = connectedNodes.has(node.name);
const isNodeTrigger = isTriggerNode(node.type);
// Trigger nodes need outgoing connections OR inbound connections (for mcpTrigger)
// mcpTrigger is special: it has "trigger" in its name but only receives inbound ai_tool connections
if (isNodeTrigger) {
const hasOutgoingConnections = !!workflow.connections?.[node.name];
const hasInboundConnections = isConnected;
return !hasOutgoingConnections && !hasInboundConnections; // Disconnected if NEITHER
}
// Regular nodes need at least one connection (incoming or outgoing)
return !isConnected;
});
if (disconnectedNodes.length > 0) {
const disconnectedList = disconnectedNodes.map(n => `"${n.name}" (${n.type})`).join(', ');
const firstDisconnected = disconnectedNodes[0];
const suggestedSource = workflow.nodes.find(n => connectedNodes.has(n.name))?.name || workflow.nodes[0].name;
errors.push(`Disconnected nodes detected: ${disconnectedList}. Each node must have at least one connection. Add a connection: {type: 'addConnection', source: '${suggestedSource}', target: '${firstDisconnected.name}', sourcePort: 'main', targetPort: 'main'}`);
}
}
}
// Validate nodes
if (workflow.nodes) {
workflow.nodes.forEach((node, index) => {
try {
validateWorkflowNode(node);
// Additional check for common node type mistakes
if (node.type.startsWith('nodes-base.')) {
errors.push(`Invalid node type "${node.type}" at index ${index}. Use "n8n-nodes-base.${node.type.substring(11)}" instead.`);
} else if (!node.type.includes('.')) {
errors.push(`Invalid node type "${node.type}" at index ${index}. Node types must include package prefix (e.g., "n8n-nodes-base.webhook").`);
}
} catch (error) {
errors.push(`Invalid node at index ${index}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
});
}
// Validate filter-based nodes (IF v2.2+, Switch v3.2+) have complete metadata
if (workflow.nodes) {
workflow.nodes.forEach((node, index) => {
const filterErrors = validateFilterBasedNodeMetadata(node);
if (filterErrors.length > 0) {
errors.push(...filterErrors.map(err => `Node "${node.name}" (index ${index}): ${err}`));
}
});
}
// Validate connections
if (workflow.connections) {
try {
validateWorkflowConnections(workflow.connections);
} catch (error) {
errors.push(`Invalid connections: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Validate active workflows have activatable triggers
// NOTE: Since n8n 2.0, executeWorkflowTrigger is now activatable and MUST be activated to work
if ((workflow as any).active === true && workflow.nodes && workflow.nodes.length > 0) {
const activatableTriggers = workflow.nodes.filter(node =>
!node.disabled && isActivatableTrigger(node.type)
);
if (activatableTriggers.length === 0) {
errors.push(
'Cannot activate workflow: No activatable trigger nodes found. ' +
'Workflows must have at least one enabled trigger node (webhook, schedule, executeWorkflowTrigger, etc.).'
);
}
}
// Validate Switch and IF node connection structures match their rules
if (workflow.nodes && workflow.connections) {
const switchNodes = workflow.nodes.filter(n => {
if (n.type !== 'n8n-nodes-base.switch') return false;
const mode = (n.parameters as any)?.mode;
return !mode || mode === 'rules'; // Default mode is 'rules'
});
for (const switchNode of switchNodes) {
const params = switchNode.parameters as any;
const rules = params?.rules?.rules || [];
const nodeConnections = workflow.connections[switchNode.name];
if (rules.length > 0 && nodeConnections?.main) {
const outputBranches = nodeConnections.main.length;
// Switch nodes in "rules" mode need output branches matching rules count
if (outputBranches !== rules.length) {
const ruleNames = rules.map((r: any, i: number) =>
r.outputKey ? `"${r.outputKey}" (index ${i})` : `Rule ${i}`
).join(', ');
errors.push(
`Switch node "${switchNode.name}" has ${rules.length} rules [${ruleNames}] ` +
`but only ${outputBranches} output branch${outputBranches !== 1 ? 'es' : ''} in connections. ` +
`Each rule needs its own output branch. When connecting to Switch outputs, specify sourceIndex: ` +
rules.map((_: any, i: number) => i).join(', ') +
` (or use case parameter for clarity).`
);
}
// Check for empty output branches (except trailing ones)
const nonEmptyBranches = nodeConnections.main.filter((branch: any[]) => branch.length > 0).length;
if (nonEmptyBranches < rules.length) {
const emptyIndices = nodeConnections.main
.map((branch: any[], i: number) => branch.length === 0 ? i : -1)
.filter((i: number) => i !== -1 && i < rules.length);
if (emptyIndices.length > 0) {
const ruleInfo = emptyIndices.map((i: number) => {
const rule = rules[i];
return rule.outputKey ? `"${rule.outputKey}" (index ${i})` : `Rule ${i}`;
}).join(', ');
errors.push(
`Switch node "${switchNode.name}" has unconnected output${emptyIndices.length !== 1 ? 's' : ''}: ${ruleInfo}. ` +
`Add connection${emptyIndices.length !== 1 ? 's' : ''} using sourceIndex: ${emptyIndices.join(' or ')}.`
);
}
}
}
}
}
// Validate that all connection references exist and use node NAMES (not IDs)
if (workflow.nodes && workflow.connections) {
const nodeNames = new Set(workflow.nodes.map(node => node.name));
const nodeIds = new Set(workflow.nodes.map(node => node.id));
const nodeIdToName = new Map(workflow.nodes.map(node => [node.id, node.name]));
Object.entries(workflow.connections).forEach(([sourceName, connection]) => {
// Check if source exists by name (correct)
if (!nodeNames.has(sourceName)) {
// Check if they're using an ID instead of name
if (nodeIds.has(sourceName)) {
const correctName = nodeIdToName.get(sourceName);
errors.push(`Connection uses node ID '${sourceName}' but must use node name '${correctName}'. Change connections.${sourceName} to connections['${correctName}']`);
} else {
errors.push(`Connection references non-existent node: ${sourceName}`);
}
}
// Check all connection types (main, error, ai_tool, ai_languageModel, etc.)
const connectionRecord = connection as Record<string, unknown>;
Object.values(connectionRecord).forEach((connData) => {
if (connData && Array.isArray(connData)) {
connData.forEach((outputs: any, outputIndex: number) => {
if (Array.isArray(outputs)) {
outputs.forEach((target: any, targetIndex: number) => {
if (!target?.node) return;
// Check if target exists by name (correct)
if (!nodeNames.has(target.node)) {
// Check if they're using an ID instead of name
if (nodeIds.has(target.node)) {
const correctName = nodeIdToName.get(target.node);
errors.push(`Connection target uses node ID '${target.node}' but must use node name '${correctName}' (from ${sourceName}[${outputIndex}][${targetIndex}])`);
} else {
errors.push(`Connection references non-existent target node: ${target.node} (from ${sourceName}[${outputIndex}][${targetIndex}])`);
}
}
});
}
});
}
});
});
}
return errors;
}
// Check if workflow has webhook trigger
export function hasWebhookTrigger(workflow: Workflow): boolean {
return workflow.nodes.some(node =>
node.type === 'n8n-nodes-base.webhook' ||
node.type === 'n8n-nodes-base.webhookTrigger'
);
}
/**
* Validate filter-based node metadata (IF v2.2+, Switch v3.2+)
* Returns array of error messages
*/
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
const errors: string[] = [];
// Check if node is filter-based
const isIFNode = node.type === 'n8n-nodes-base.if' && node.typeVersion >= 2.2;
const isSwitchNode = node.type === 'n8n-nodes-base.switch' && node.typeVersion >= 3.2;
if (!isIFNode && !isSwitchNode) {
return errors; // Not a filter-based node
}
// Validate IF node
if (isIFNode) {
const conditions = (node.parameters.conditions as any);
// Check conditions.options exists
if (!conditions?.options) {
errors.push(
'Missing required "conditions.options". ' +
'IF v2.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
);
} else {
// Validate required fields
const requiredFields = {
version: 2,
leftValue: '',
caseSensitive: 'boolean',
typeValidation: 'strict'
};
for (const [field, expectedValue] of Object.entries(requiredFields)) {
if (!(field in conditions.options)) {
errors.push(
`Missing required field "conditions.options.${field}". ` +
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
);
}
}
}
// Validate operators in conditions
if (conditions?.conditions && Array.isArray(conditions.conditions)) {
conditions.conditions.forEach((condition: any, i: number) => {
const operatorErrors = validateOperatorStructure(condition.operator, `conditions.conditions[${i}].operator`);
errors.push(...operatorErrors);
});
}
}
// Validate Switch node
if (isSwitchNode) {
const rules = (node.parameters.rules as any);
if (rules?.rules && Array.isArray(rules.rules)) {
rules.rules.forEach((rule: any, ruleIndex: number) => {
// Check rule.conditions.options
if (!rule.conditions?.options) {
errors.push(
`Missing required "rules.rules[${ruleIndex}].conditions.options". ` +
'Switch v3.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
);
} else {
// Validate required fields
const requiredFields = {
version: 2,
leftValue: '',
caseSensitive: 'boolean',
typeValidation: 'strict'
};
for (const [field, expectedValue] of Object.entries(requiredFields)) {
if (!(field in rule.conditions.options)) {
errors.push(
`Missing required field "rules.rules[${ruleIndex}].conditions.options.${field}". ` +
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
);
}
}
}
// Validate operators in rule conditions
if (rule.conditions?.conditions && Array.isArray(rule.conditions.conditions)) {
rule.conditions.conditions.forEach((condition: any, condIndex: number) => {
const operatorErrors = validateOperatorStructure(
condition.operator,
`rules.rules[${ruleIndex}].conditions.conditions[${condIndex}].operator`
);
errors.push(...operatorErrors);
});
}
});
}
}
return errors;
}
/**
* Validate operator structure
* Ensures operator has correct format: {type, operation, singleValue?}
*/
export function validateOperatorStructure(operator: any, path: string): string[] {
const errors: string[] = [];
if (!operator || typeof operator !== 'object') {
errors.push(`${path}: operator is missing or not an object`);
return errors;
}
// Check required field: type (data type, not operation name)
if (!operator.type) {
errors.push(
`${path}: missing required field "type". ` +
'Must be a data type: "string", "number", "boolean", "dateTime", "array", or "object"'
);
} else {
const validTypes = ['string', 'number', 'boolean', 'dateTime', 'array', 'object'];
if (!validTypes.includes(operator.type)) {
errors.push(
`${path}: invalid type "${operator.type}". ` +
`Type must be a data type (${validTypes.join(', ')}), not an operation name. ` +
'Did you mean to use the "operation" field?'
);
}
}
// Check required field: operation
if (!operator.operation) {
errors.push(
`${path}: missing required field "operation". ` +
'Operation specifies the comparison type (e.g., "equals", "contains", "isNotEmpty")'
);
}
// Check singleValue based on operator type
if (operator.operation) {
const unaryOperators = ['isEmpty', 'isNotEmpty', 'true', 'false', 'isNumeric'];
const isUnary = unaryOperators.includes(operator.operation);
if (isUnary) {
// Unary operators MUST have singleValue: true
if (operator.singleValue !== true) {
errors.push(
`${path}: unary operator "${operator.operation}" requires "singleValue: true". ` +
'Unary operators do not use rightValue.'
);
}
} else {
// Binary operators should NOT have singleValue: true
if (operator.singleValue === true) {
errors.push(
`${path}: binary operator "${operator.operation}" should not have "singleValue: true". ` +
'Only unary operators (isEmpty, isNotEmpty, true, false, isNumeric) need this property.'
);
}
}
}
return errors;
}
// Get webhook URL from workflow
export function getWebhookUrl(workflow: Workflow): string | null {
const webhookNode = workflow.nodes.find(node =>
node.type === 'n8n-nodes-base.webhook' ||
node.type === 'n8n-nodes-base.webhookTrigger'
);
if (!webhookNode || !webhookNode.parameters) {
return null;
}
// Check for path parameter
const path = webhookNode.parameters.path as string | undefined;
if (!path) {
return null;
}
// Note: We can't construct the full URL without knowing the n8n instance URL
// The caller will need to prepend the base URL
return path;
}
// Helper function to generate proper workflow structure examples
export function getWorkflowStructureExample(): string {
return `
Minimal Workflow Example:
{
"name": "My Workflow",
"nodes": [
{
"id": "manual-trigger-1",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [250, 300],
"parameters": {}
},
{
"id": "set-1",
"name": "Set Data",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [450, 300],
"parameters": {
"mode": "manual",
"assignments": {
"assignments": [{
"id": "1",
"name": "message",
"value": "Hello World",
"type": "string"
}]
}
}
}
],
"connections": {
"Manual Trigger": {
"main": [[{
"node": "Set Data",
"type": "main",
"index": 0
}]]
}
}
}
IMPORTANT: In connections, use the node NAME (e.g., "Manual Trigger"), NOT the node ID or type!`;
}
// Helper function to fix common workflow issues
export function getWorkflowFixSuggestions(errors: string[]): string[] {
const suggestions: string[] = [];
if (errors.some(e => e.includes('empty connections'))) {
suggestions.push('Add connections between your nodes. Each node (except endpoints) should connect to another node.');
suggestions.push('Connection format: connections: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }');
}
if (errors.some(e => e.includes('Single-node workflows'))) {
suggestions.push('Add at least one more node to process data. Common patterns: Trigger → Process → Output');
suggestions.push('Examples: Manual Trigger → Set, Webhook → HTTP Request, Schedule Trigger → Database Query');
}
if (errors.some(e => e.includes('node ID') && e.includes('instead of node name'))) {
suggestions.push('Replace node IDs with node names in connections. The name is what appears in the node header.');
suggestions.push('Wrong: connections: { "set-1": {...} }, Right: connections: { "Set Data": {...} }');
}
return suggestions;
}