fix: resolve SplitInBatches output confusion for AI assistants (#97)

## Problem
AI assistants were consistently connecting SplitInBatches node outputs backwards because:
- Output index 0 = "done" (runs after loop completes)
- Output index 1 = "loop" (processes items inside loop)
This counterintuitive ordering caused incorrect workflow connections.

## Solution
Enhanced the n8n-mcp system to expose and clarify output information:

### Database & Schema
- Added `outputs` and `output_names` columns to nodes table
- Updated NodeRepository to store/retrieve output information

### Node Parsing
- Enhanced NodeParser to extract outputs and outputNames from nodes
- Properly handles versioned nodes like SplitInBatchesV3

### MCP Server
- Modified getNodeInfo to return detailed output descriptions
- Added connection guidance for each output
- Special handling for loop nodes (SplitInBatches, IF, Switch)

### Documentation
- Enhanced DocsMapper to inject critical output guidance
- Added warnings about counterintuitive output ordering
- Provides correct connection patterns for loop nodes

### Workflow Validation
- Added validateSplitInBatchesConnection method
- Detects reversed connections and provides specific errors
- Added checkForLoopBack with depth limit to prevent stack overflow
- Smart heuristics to identify likely connection mistakes

## Testing
- Created comprehensive test suite (81 tests)
- Unit tests for all modified components
- Edge case handling for malformed data
- Performance testing with large workflows

## Impact
AI assistants will now:
- See explicit output indices and names (e.g., "Output 0: done")
- Receive clear connection guidance
- Get validation errors when connections are reversed
- Have enhanced documentation explaining the correct pattern

Fixes #97

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-08-07 15:58:07 +02:00
parent a4e711a4e8
commit f508d9873b
12 changed files with 2895 additions and 12 deletions

View File

@@ -627,6 +627,9 @@ export class WorkflowValidator {
result: WorkflowValidationResult,
outputType: 'main' | 'error' | 'ai_tool'
): void {
// Get source node for special validation
const sourceNode = nodeMap.get(sourceName);
outputs.forEach((outputConnections, outputIndex) => {
if (!outputConnections) return;
@@ -641,12 +644,26 @@ export class WorkflowValidator {
return;
}
// Special validation for SplitInBatches node
if (sourceNode && sourceNode.type === 'n8n-nodes-base.splitInBatches') {
this.validateSplitInBatchesConnection(
sourceNode,
outputIndex,
connection,
nodeMap,
result
);
}
// Check for self-referencing connections
if (connection.node === sourceName) {
result.warnings.push({
type: 'warning',
message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.`
});
// This is only a warning for non-loop nodes
if (sourceNode && sourceNode.type !== 'n8n-nodes-base.splitInBatches') {
result.warnings.push({
type: 'warning',
message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.`
});
}
}
const targetNode = nodeMap.get(connection.node);
@@ -1470,4 +1487,133 @@ export class WorkflowValidator {
);
}
}
/**
* Validate SplitInBatches node connections for common mistakes
*/
private validateSplitInBatchesConnection(
sourceNode: WorkflowNode,
outputIndex: number,
connection: { node: string; type: string; index: number },
nodeMap: Map<string, WorkflowNode>,
result: WorkflowValidationResult
): void {
const targetNode = nodeMap.get(connection.node);
if (!targetNode) return;
// Check if connections appear to be reversed
// Output 0 = "done", Output 1 = "loop"
if (outputIndex === 0) {
// This is the "done" output (index 0)
// Check if target looks like it should be in the loop
const targetType = targetNode.type.toLowerCase();
const targetName = targetNode.name.toLowerCase();
// Common patterns that suggest this node should be inside the loop
if (targetType.includes('function') ||
targetType.includes('code') ||
targetType.includes('item') ||
targetName.includes('process') ||
targetName.includes('transform') ||
targetName.includes('handle')) {
// Check if this node connects back to the SplitInBatches
const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap);
if (hasLoopBack) {
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `SplitInBatches outputs appear reversed! Node "${targetNode.name}" is connected to output 0 ("done") but connects back to the loop. It should be connected to output 1 ("loop") instead. Remember: Output 0 = "done" (post-loop), Output 1 = "loop" (inside loop).`
});
} else {
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node "${targetNode.name}" is connected to the "done" output (index 0) but appears to be a processing node. Consider connecting it to the "loop" output (index 1) if it should process items inside the loop.`
});
}
}
} else if (outputIndex === 1) {
// This is the "loop" output (index 1)
// Check if target looks like it should be after the loop
const targetType = targetNode.type.toLowerCase();
const targetName = targetNode.name.toLowerCase();
// Common patterns that suggest this node should be after the loop
if (targetType.includes('aggregate') ||
targetType.includes('merge') ||
targetType.includes('email') ||
targetType.includes('slack') ||
targetName.includes('final') ||
targetName.includes('complete') ||
targetName.includes('summary') ||
targetName.includes('report')) {
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node "${targetNode.name}" is connected to the "loop" output (index 1) but appears to be a post-processing node. Consider connecting it to the "done" output (index 0) if it should run after all iterations complete.`
});
}
// Check if loop output doesn't eventually connect back
const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap);
if (!hasLoopBack) {
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `The "loop" output connects to "${targetNode.name}" but doesn't connect back to the SplitInBatches node. The last node in the loop should connect back to complete the iteration.`
});
}
}
}
/**
* Check if a node eventually connects back to a target node
*/
private checkForLoopBack(
startNode: string,
targetNode: string,
nodeMap: Map<string, WorkflowNode>,
visited: Set<string> = new Set(),
maxDepth: number = 50
): boolean {
if (maxDepth <= 0) return false; // Prevent stack overflow
if (visited.has(startNode)) return false;
visited.add(startNode);
const node = nodeMap.get(startNode);
if (!node) return false;
// Check direct connections from this node
const connections = (node as any).connections;
if (!connections) return false;
for (const [outputType, outputs] of Object.entries(connections)) {
if (!Array.isArray(outputs)) continue;
for (const outputConnections of outputs) {
if (!Array.isArray(outputConnections)) continue;
for (const conn of outputConnections) {
if (conn.node === targetNode) {
return true;
}
// Recursively check connected nodes
if (this.checkForLoopBack(conn.node, targetNode, nodeMap, visited, maxDepth - 1)) {
return true;
}
}
}
}
return false;
}
}