mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 14:32:04 +00:00
feat: implement rewireConnection operation (Phase 1, Task 1)
Added intuitive rewireConnection operation for changing connection targets in a single semantic step: "rewire from X to Y" Changes: - Added RewireConnectionOperation type with from/to semantics - Implemented validation (checks source, from, to nodes and connection existence) - Implemented operation as remove + add wrapper - Added 8 comprehensive tests covering all scenarios - All 134 tests passing (126 Phase 0 + 8 new) Test Coverage: - Basic rewiring - Rewiring with sourceOutput specified - Preserving parallel connections - Error handling (source/from/to not found, connection doesn't exist) - IF node branch rewiring Expected Impact: 4/10 → 9/10 rating for rewiring tasks Related: Issue #272 Phase 1 implementation Phase 0 PR: #274 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
AddConnectionOperation,
|
||||
RemoveConnectionOperation,
|
||||
UpdateConnectionOperation,
|
||||
RewireConnectionOperation,
|
||||
UpdateSettingsOperation,
|
||||
UpdateNameOperation,
|
||||
AddTagOperation,
|
||||
@@ -225,6 +226,8 @@ export class WorkflowDiffEngine {
|
||||
return this.validateRemoveConnection(workflow, operation);
|
||||
case 'updateConnection':
|
||||
return this.validateUpdateConnection(workflow, operation);
|
||||
case 'rewireConnection':
|
||||
return this.validateRewireConnection(workflow, operation as RewireConnectionOperation);
|
||||
case 'updateSettings':
|
||||
case 'updateName':
|
||||
case 'addTag':
|
||||
@@ -271,6 +274,9 @@ export class WorkflowDiffEngine {
|
||||
case 'updateConnection':
|
||||
this.applyUpdateConnection(workflow, operation);
|
||||
break;
|
||||
case 'rewireConnection':
|
||||
this.applyRewireConnection(workflow, operation as RewireConnectionOperation);
|
||||
break;
|
||||
case 'updateSettings':
|
||||
this.applyUpdateSettings(workflow, operation);
|
||||
break;
|
||||
@@ -458,20 +464,20 @@ export class WorkflowDiffEngine {
|
||||
private validateUpdateConnection(workflow: Workflow, operation: UpdateConnectionOperation): string | null {
|
||||
const sourceNode = this.findNode(workflow, operation.source, operation.source);
|
||||
const targetNode = this.findNode(workflow, operation.target, operation.target);
|
||||
|
||||
|
||||
if (!sourceNode) {
|
||||
return `Source node not found: ${operation.source}`;
|
||||
}
|
||||
if (!targetNode) {
|
||||
return `Target node not found: ${operation.target}`;
|
||||
}
|
||||
|
||||
|
||||
// Check if connection exists to update
|
||||
const existingConnections = workflow.connections[sourceNode.name];
|
||||
if (!existingConnections) {
|
||||
return `No connections found from "${sourceNode.name}"`;
|
||||
}
|
||||
|
||||
|
||||
// Check if any connection to target exists
|
||||
let hasConnection = false;
|
||||
Object.values(existingConnections).forEach(outputs => {
|
||||
@@ -481,11 +487,57 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
if (!hasConnection) {
|
||||
return `No connection exists from "${sourceNode.name}" to "${targetNode.name}"`;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private validateRewireConnection(workflow: Workflow, operation: RewireConnectionOperation): string | null {
|
||||
// Validate source node exists
|
||||
const sourceNode = this.findNode(workflow, operation.source, operation.source);
|
||||
if (!sourceNode) {
|
||||
const availableNodes = workflow.nodes
|
||||
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||
.join(', ');
|
||||
return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
|
||||
}
|
||||
|
||||
// Validate "from" node exists (current target)
|
||||
const fromNode = this.findNode(workflow, operation.from, operation.from);
|
||||
if (!fromNode) {
|
||||
const availableNodes = workflow.nodes
|
||||
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||
.join(', ');
|
||||
return `"From" node not found: "${operation.from}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
|
||||
}
|
||||
|
||||
// Validate "to" node exists (new target)
|
||||
const toNode = this.findNode(workflow, operation.to, operation.to);
|
||||
if (!toNode) {
|
||||
const availableNodes = workflow.nodes
|
||||
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||
.join(', ');
|
||||
return `"To" node not found: "${operation.to}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
|
||||
}
|
||||
|
||||
// Validate that connection from source to "from" exists
|
||||
const sourceOutput = operation.sourceOutput || 'main';
|
||||
const connections = workflow.connections[sourceNode.name]?.[sourceOutput];
|
||||
if (!connections) {
|
||||
return `No connections found from "${sourceNode.name}" on output "${sourceOutput}"`;
|
||||
}
|
||||
|
||||
const hasConnection = connections.some(conns =>
|
||||
conns.some(c => c.node === fromNode.name)
|
||||
);
|
||||
|
||||
if (!hasConnection) {
|
||||
return `No connection exists from "${sourceNode.name}" to "${fromNode.name}" on output "${sourceOutput}"`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -702,6 +754,36 @@ export class WorkflowDiffEngine {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewire a connection from one target to another
|
||||
* This is a semantic wrapper around removeConnection + addConnection
|
||||
* that provides clear intent: "rewire connection from X to Y"
|
||||
*
|
||||
* @param workflow - Workflow to modify
|
||||
* @param operation - Rewire operation specifying source, from, and to
|
||||
*/
|
||||
private applyRewireConnection(workflow: Workflow, operation: RewireConnectionOperation): void {
|
||||
// First, remove the old connection (source → from)
|
||||
this.applyRemoveConnection(workflow, {
|
||||
type: 'removeConnection',
|
||||
source: operation.source,
|
||||
target: operation.from,
|
||||
sourceOutput: operation.sourceOutput,
|
||||
targetInput: operation.targetInput
|
||||
});
|
||||
|
||||
// Then, add the new connection (source → to)
|
||||
this.applyAddConnection(workflow, {
|
||||
type: 'addConnection',
|
||||
source: operation.source,
|
||||
target: operation.to,
|
||||
sourceOutput: operation.sourceOutput,
|
||||
targetInput: operation.targetInput,
|
||||
sourceIndex: operation.sourceIndex,
|
||||
targetIndex: 0 // Default target index for new connection
|
||||
});
|
||||
}
|
||||
|
||||
// Metadata operation appliers
|
||||
private applyUpdateSettings(workflow: Workflow, operation: UpdateSettingsOperation): void {
|
||||
if (!workflow.settings) {
|
||||
|
||||
@@ -87,6 +87,16 @@ export interface UpdateConnectionOperation extends DiffOperation {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RewireConnectionOperation extends DiffOperation {
|
||||
type: 'rewireConnection';
|
||||
source: string; // Source node name or ID
|
||||
from: string; // Current target to rewire FROM
|
||||
to: string; // New target to rewire TO
|
||||
sourceOutput?: string; // Optional: which output to rewire (default: 'main')
|
||||
targetInput?: string; // Optional: which input type (default: 'main')
|
||||
sourceIndex?: number; // Optional: which source index (default: 0)
|
||||
}
|
||||
|
||||
// Workflow Metadata Operations
|
||||
export interface UpdateSettingsOperation extends DiffOperation {
|
||||
type: 'updateSettings';
|
||||
@@ -140,6 +150,7 @@ export type WorkflowDiffOperation =
|
||||
| AddConnectionOperation
|
||||
| RemoveConnectionOperation
|
||||
| UpdateConnectionOperation
|
||||
| RewireConnectionOperation
|
||||
| UpdateSettingsOperation
|
||||
| UpdateNameOperation
|
||||
| AddTagOperation
|
||||
@@ -187,8 +198,8 @@ export function isNodeOperation(op: WorkflowDiffOperation): op is
|
||||
}
|
||||
|
||||
export function isConnectionOperation(op: WorkflowDiffOperation): op is
|
||||
AddConnectionOperation | RemoveConnectionOperation | UpdateConnectionOperation | CleanStaleConnectionsOperation | ReplaceConnectionsOperation {
|
||||
return ['addConnection', 'removeConnection', 'updateConnection', 'cleanStaleConnections', 'replaceConnections'].includes(op.type);
|
||||
AddConnectionOperation | RemoveConnectionOperation | UpdateConnectionOperation | RewireConnectionOperation | CleanStaleConnectionsOperation | ReplaceConnectionsOperation {
|
||||
return ['addConnection', 'removeConnection', 'updateConnection', 'rewireConnection', 'cleanStaleConnections', 'replaceConnections'].includes(op.type);
|
||||
}
|
||||
|
||||
export function isMetadataOperation(op: WorkflowDiffOperation): op is
|
||||
|
||||
@@ -867,6 +867,283 @@ describe('WorkflowDiffEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('RewireConnection Operation (Phase 1)', () => {
|
||||
it('should rewire connection from one target to another', async () => {
|
||||
// Setup: Create a connection Webhook → HTTP Request
|
||||
// Then rewire it to Webhook → Slack instead
|
||||
const rewireOp: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'Webhook',
|
||||
from: 'HTTP Request',
|
||||
to: 'Slack'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [rewireOp]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.workflow).toBeDefined();
|
||||
|
||||
// Old connection should be removed
|
||||
const webhookConnections = result.workflow!.connections['Webhook']['main'][0];
|
||||
expect(webhookConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false);
|
||||
|
||||
// New connection should exist
|
||||
expect(webhookConnections.some((c: any) => c.node === 'Slack')).toBe(true);
|
||||
});
|
||||
|
||||
it('should rewire connection with specified sourceOutput', async () => {
|
||||
// Add IF node with connection on 'true' output
|
||||
const addNode: AddNodeOperation = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'IF',
|
||||
type: 'n8n-nodes-base.if',
|
||||
position: [600, 300]
|
||||
}
|
||||
};
|
||||
|
||||
const addConn: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'IF',
|
||||
target: 'HTTP Request',
|
||||
sourceOutput: 'true'
|
||||
};
|
||||
|
||||
const rewire: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'IF',
|
||||
from: 'HTTP Request',
|
||||
to: 'Slack',
|
||||
sourceOutput: 'true'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [addNode, addConn, rewire]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify rewiring on 'true' output
|
||||
const trueConnections = result.workflow!.connections['IF']['true'][0];
|
||||
expect(trueConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false);
|
||||
expect(trueConnections.some((c: any) => c.node === 'Slack')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve other parallel connections when rewiring', async () => {
|
||||
// Setup: Webhook connects to both HTTP Request (in baseWorkflow) and Slack (added here)
|
||||
// Add a Set node, then rewire HTTP Request → Set
|
||||
// Slack connection should remain unchanged
|
||||
|
||||
// Add Slack connection in parallel
|
||||
const addSlackConn: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'Webhook',
|
||||
target: 'Slack'
|
||||
};
|
||||
|
||||
// Add Set node to rewire to
|
||||
const addSetNode: AddNodeOperation = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [800, 300]
|
||||
}
|
||||
};
|
||||
|
||||
// Rewire HTTP Request → Set
|
||||
const rewire: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'Webhook',
|
||||
from: 'HTTP Request',
|
||||
to: 'Set'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [addSlackConn, addSetNode, rewire]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const webhookConnections = result.workflow!.connections['Webhook']['main'][0];
|
||||
|
||||
// HTTP Request should be removed
|
||||
expect(webhookConnections.some((c: any) => c.node === 'HTTP Request')).toBe(false);
|
||||
|
||||
// Set should be added
|
||||
expect(webhookConnections.some((c: any) => c.node === 'Set')).toBe(true);
|
||||
|
||||
// Slack should still be there (parallel connection preserved)
|
||||
expect(webhookConnections.some((c: any) => c.node === 'Slack')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject rewireConnection when source node not found', async () => {
|
||||
const rewire: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'NonExistent',
|
||||
from: 'HTTP Request',
|
||||
to: 'Slack'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [rewire]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors![0].message).toContain('Source node not found');
|
||||
expect(result.errors![0].message).toContain('NonExistent');
|
||||
expect(result.errors![0].message).toContain('Available nodes');
|
||||
});
|
||||
|
||||
it('should reject rewireConnection when "from" node not found', async () => {
|
||||
const rewire: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'Webhook',
|
||||
from: 'NonExistent',
|
||||
to: 'Slack'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [rewire]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors![0].message).toContain('"From" node not found');
|
||||
expect(result.errors![0].message).toContain('NonExistent');
|
||||
});
|
||||
|
||||
it('should reject rewireConnection when "to" node not found', async () => {
|
||||
const rewire: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'Webhook',
|
||||
from: 'HTTP Request',
|
||||
to: 'NonExistent'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [rewire]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors![0].message).toContain('"To" node not found');
|
||||
expect(result.errors![0].message).toContain('NonExistent');
|
||||
});
|
||||
|
||||
it('should reject rewireConnection when connection does not exist', async () => {
|
||||
// Slack node exists but doesn't have any outgoing connections
|
||||
// So this should fail with "No connections found" error
|
||||
const rewire: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'Slack', // Slack has no outgoing connections in baseWorkflow
|
||||
from: 'HTTP Request',
|
||||
to: 'Webhook' // Use existing node
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [rewire]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors![0].message).toContain('No connections found from');
|
||||
expect(result.errors![0].message).toContain('Slack');
|
||||
});
|
||||
|
||||
it('should handle rewiring IF node branches correctly', async () => {
|
||||
// Add IF node with true/false branches
|
||||
const addIF: AddNodeOperation = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'IF',
|
||||
type: 'n8n-nodes-base.if',
|
||||
position: [600, 300]
|
||||
}
|
||||
};
|
||||
|
||||
const addSuccess: AddNodeOperation = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'SuccessHandler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [800, 200]
|
||||
}
|
||||
};
|
||||
|
||||
const addError: AddNodeOperation = {
|
||||
type: 'addNode',
|
||||
node: {
|
||||
name: 'ErrorHandler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [800, 400]
|
||||
}
|
||||
};
|
||||
|
||||
const connectTrue: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'IF',
|
||||
target: 'SuccessHandler',
|
||||
sourceOutput: 'true'
|
||||
};
|
||||
|
||||
const connectFalse: AddConnectionOperation = {
|
||||
type: 'addConnection',
|
||||
source: 'IF',
|
||||
target: 'ErrorHandler',
|
||||
sourceOutput: 'false'
|
||||
};
|
||||
|
||||
// Rewire the false branch to go to SuccessHandler instead
|
||||
const rewireFalse: any = {
|
||||
type: 'rewireConnection',
|
||||
source: 'IF',
|
||||
from: 'ErrorHandler',
|
||||
to: 'Slack',
|
||||
sourceOutput: 'false'
|
||||
};
|
||||
|
||||
const request: WorkflowDiffRequest = {
|
||||
id: 'test-workflow',
|
||||
operations: [addIF, addSuccess, addError, connectTrue, connectFalse, rewireFalse]
|
||||
};
|
||||
|
||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// True branch should still point to SuccessHandler
|
||||
expect(result.workflow!.connections['IF']['true'][0][0].node).toBe('SuccessHandler');
|
||||
|
||||
// False branch should now point to Slack
|
||||
expect(result.workflow!.connections['IF']['false'][0][0].node).toBe('Slack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddConnection with sourceIndex (Phase 0 Fix)', () => {
|
||||
it('should add connection to correct sourceIndex', async () => {
|
||||
// Add IF node
|
||||
|
||||
Reference in New Issue
Block a user