fix: field normalization, AI connection validation, autofix filter (#581) (#638)

- Normalize name→nodeName and id→nodeId for node-targeting operations in
  the Zod schema transform, so LLMs using natural field names no longer
  get "Node not found" errors
- Replace hardcoded ALL_CONNECTION_TYPES with dynamic iteration so AI
  sub-nodes (ai_outputParser, ai_document, ai_textSplitter, etc.) are
  not flagged as disconnected during save
- Add .catchall() to workflowConnectionSchema and extend connection
  reference validation to cover all connection types, not just main
- Fix filterOperationsByFixes ID-vs-name mismatch: typeversion-upgrade
  operations now include nodeName alongside nodeId, and the filter checks
  both fields

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-03-15 14:32:14 +01:00
committed by GitHub
parent 65ab94deb2
commit f7a1cfe8bf
41 changed files with 657 additions and 154 deletions

View File

@@ -31,6 +31,11 @@ function getValidator(repository: NodeRepository): WorkflowValidator {
return cachedValidator;
}
// Operation types that identify nodes by nodeId/nodeName
const NODE_TARGETING_OPERATIONS = new Set([
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode'
]);
// Zod schema for the diff request
const workflowDiffSchema = z.object({
id: z.string(),
@@ -63,6 +68,23 @@ const workflowDiffSchema = z.object({
settings: z.any().optional(),
name: z.string().optional(),
tag: z.string().optional(),
// Aliases: LLMs often use "id" instead of "nodeId" — accept both
id: z.string().optional(),
}).transform((op) => {
// Normalize common field aliases for node-targeting operations:
// - "name" → "nodeName" (LLMs confuse the updateName "name" field with node identification)
// - "id" → "nodeId" (natural alias)
if (NODE_TARGETING_OPERATIONS.has(op.type)) {
if (!op.nodeName && !op.nodeId && op.name) {
op.nodeName = op.name;
op.name = undefined;
}
if (!op.nodeId && op.id) {
op.nodeId = op.id;
op.id = undefined;
}
}
return op;
})),
validateOnly: z.boolean().optional(),
continueOnError: z.boolean().optional(),

View File

@@ -49,7 +49,7 @@ export const workflowConnectionSchema = z.record(
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({
@@ -248,15 +248,15 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
const connectedNodes = new Set<string>();
// Collect all nodes that appear in connections (as source or target)
// Check ALL connection types, not just 'main' - AI workflows use ai_tool, ai_languageModel, etc.
const ALL_CONNECTION_TYPES = ['main', 'error', 'ai_tool', 'ai_languageModel', 'ai_memory', 'ai_embedding', 'ai_vectorStore'] as const;
// 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 all connection types for target nodes
ALL_CONNECTION_TYPES.forEach(connType => {
const connData = (connection as Record<string, unknown>)[connType];
// 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)) {
@@ -429,24 +429,29 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
}
}
if (connection.main && Array.isArray(connection.main)) {
connection.main.forEach((outputs, outputIndex) => {
if (Array.isArray(outputs)) {
outputs.forEach((target, targetIndex) => {
// 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}])`);
// 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}])`);
}
}
}
});
}
});
}
});
}
});
}
});
});
}

View File

@@ -671,11 +671,13 @@ export class WorkflowAutoFixer {
filteredFixes: FixOperation[],
allFixes: FixOperation[]
): WorkflowDiffOperation[] {
// fixedNodes contains node names (FixOperation.node = node.name)
const fixedNodes = new Set(filteredFixes.map(f => f.node));
const hasConnectionFixes = filteredFixes.some(f => CONNECTION_FIX_TYPES.includes(f.type));
return operations.filter(op => {
if (op.type === 'updateNode') {
return fixedNodes.has(op.nodeId || '');
// Check both nodeName and nodeId — operations may use either
return fixedNodes.has(op.nodeName || '') || fixedNodes.has(op.nodeId || '');
}
if (op.type === 'replaceConnections') {
return hasConnectionFixes;
@@ -1186,10 +1188,11 @@ export class WorkflowAutoFixer {
description: `Upgrade ${node.name} from v${currentVersion} to v${latestVersion}. ${analysis.reason}`
});
// Create update operation
// Create update operation — both nodeId and nodeName needed for fix filtering
const operation: UpdateNodeOperation = {
type: 'updateNode',
nodeId: node.id,
nodeName: node.name,
updates: {
typeVersion: parseFloat(latestVersion),
parameters: migrationResult.updatedNode.parameters,