feat(core): Complete v1-to-v2 workflow converter (no-changelog) (#35189)

This commit is contained in:
Iván Ovejero
2026-07-30 14:25:54 +00:00
committed by GitHub
parent 0932806c05
commit bcf7502adc
6 changed files with 1023 additions and 11 deletions
@@ -1,4 +1,10 @@
import { defineConfig } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig({ ignores: ['dist/**'] }, nodeConfig);
export default defineConfig({ ignores: ['dist/**'] }, nodeConfig, {
files: ['src/__tests__/**'],
rules: {
// Workflow fixtures key connections by node name, e.g. "When clicking Execute"
'@typescript-eslint/naming-convention': 'off',
},
});
@@ -3,6 +3,7 @@ import type { ExecuteContext } from 'n8n-core';
import { NoOp } from 'n8n-nodes-base/nodes/NoOp/NoOp.node';
import type {
CloseFunction,
IConnections,
IDataObject,
IExecuteFunctions,
INodeExecutionData,
@@ -90,6 +91,28 @@ class NewStyleEcho extends Node {
}
}
class TwoOutputs implements INodeType {
description = {
displayName: 'Two Outputs',
name: 'twoOutputs',
group: ['transform'],
version: 1,
description: 'Emits on two output slots',
defaults: { name: 'Two Outputs' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
properties: [],
} as unknown as INodeType['description'];
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
return await Promise.resolve([
items,
items.map((item) => ({ json: { ...item.json, second: true } })),
]);
}
}
const pushCloseFunction = (context: IExecuteFunctions, close: CloseFunction) => {
(context as unknown as ExecuteContext).closeFunctions.push(close);
};
@@ -163,6 +186,7 @@ const registry = new Map<string, INodeType>([
['test.alwaysFails', new AlwaysFails()],
['test.noExecute', new NoExecute()],
['test.newStyleEcho', new NewStyleEcho() as unknown as INodeType],
['test.twoOutputs', new TwoOutputs()],
['test.succeedsWithFailingCleanup', new SucceedsWithFailingCleanup()],
['test.failsWithFailingCleanup', new FailsWithFailingCleanup()],
['test.returnsEngineRequest', new ReturnsEngineRequest() as unknown as INodeType],
@@ -191,14 +215,21 @@ export const testAdditionalDataFactory = async (
} as unknown as IWorkflowExecuteAdditionalData);
export const v1Workflow = (
nodes: Array<{ id: string; name: string; type: string; parameters?: IDataObject }>,
nodes: Array<{
id: string;
name: string;
type: string;
typeVersion?: number;
parameters?: IDataObject;
}>,
connections: IConnections = {},
): IWorkflowBase =>
({
id: 'wf-1',
name: 'fixture',
active: false,
nodes: nodes.map((n) => ({ typeVersion: 1, position: [0, 0], parameters: {}, ...n })),
connections: {},
connections,
}) as unknown as IWorkflowBase;
export const stepRequest = (
@@ -137,6 +137,51 @@ describe('V1StepExecutor', () => {
await expect(execution).rejects.toThrow(EngineRequestNotSupportedError);
});
it('routes multi-output results to their output slots', async () => {
const graph = converter.convert(
v1Workflow(
[
{ id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' },
{ id: 'n', name: 'Splitter', type: 'test.twoOutputs' },
{ id: 'a', name: 'A', type: 'n8n-nodes-base.noOp' },
{ id: 'b', name: 'B', type: 'n8n-nodes-base.noOp' },
],
{
Manual: { main: [[{ node: 'Splitter', type: 'main', index: 0 }]] },
Splitter: {
main: [
[{ node: 'A', type: 'main', index: 0 }],
[{ node: 'B', type: 'main', index: 0 }],
],
},
},
),
);
const result = await executor.execute(stepRequest(graph, 'n', items({ a: 1 })));
expect(result.outputs).toEqual([[{ json: { a: 1 } }], [{ json: { a: 1, second: true } }]]);
});
it('rejects batch steps until the engine iterates loops natively', async () => {
const graph = converter.convert(
v1Workflow(
[
{ id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' },
{ id: 'loop', name: 'Loop', type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 },
{ id: 'body', name: 'Body', type: 'n8n-nodes-base.noOp' },
],
{
Manual: { main: [[{ node: 'Loop', type: 'main', index: 0 }]] },
Loop: { main: [[], [{ node: 'Body', type: 'main', index: 0 }]] },
Body: { main: [[{ node: 'Loop', type: 'main', index: 0 }]] },
},
),
);
const execution = executor.execute(stepRequest(graph, 'loop', items({ a: 1 })));
await expect(execution).rejects.toThrow(UnsupportedStepTypeError);
});
it('honors onError=continueRegularOutput as passthrough', async () => {
const workflow = v1Workflow([
{ id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' },
@@ -1,7 +1,12 @@
import type { INode, IWorkflowBase } from 'n8n-workflow';
import type { IConnection, INode, IWorkflowBase } from 'n8n-workflow';
import { describe, expect, it } from 'vitest';
import { UnsupportedTriggerError } from '../errors';
import {
UnsupportedConnectionTypeError,
UnsupportedCycleError,
UnsupportedTriggerError,
UnsupportedLoopEntryError,
} from '../errors';
import { V1WorkflowConverter } from '../v1-workflow-converter';
const converter = new V1WorkflowConverter();
@@ -15,6 +20,18 @@ const manualTrigger: INode = {
parameters: {},
};
const node = (id: string, name: string, extra: Partial<INode> = {}): INode => ({
id,
name,
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
...extra,
});
const main = (target: string, index = 0): IConnection => ({ node: target, type: 'main', index });
function workflow(overrides: Partial<IWorkflowBase>): IWorkflowBase {
return {
id: 'wf-1',
@@ -28,6 +45,13 @@ function workflow(overrides: Partial<IWorkflowBase>): IWorkflowBase {
} as IWorkflowBase;
}
// NOTE: Topology diagrams follow the convention of core's partial-execution
// tests (drawn with https://asciiflow.com/#/). If you update a test, update
// its diagram.
//
// oN / iN the output / input slot, where it matters
// XX the node is disabled
// (back) the loop-return edge expected to be marked `isBackEdge`
describe('V1WorkflowConverter', () => {
describe('trigger nodes', () => {
it('maps a manual trigger to a single trigger graph node', () => {
@@ -146,4 +170,609 @@ describe('V1WorkflowConverter', () => {
).toThrow(UnsupportedTriggerError);
});
});
describe('edges', () => {
it('maps connections to id-keyed edges with slot indexes', () => {
// ┌───────┐ ┌─┐
// │trigger├───►│A│
// └───────┘ └─┘
const graph = converter.convert(
workflow({
nodes: [manualTrigger, node('a-uuid', 'A')],
connections: { 'When clicking Execute': { main: [[main('A')]] } },
}),
);
expect(graph.edges).toEqual([
{ from: 'trigger-uuid', to: 'a-uuid', outputIndex: 0, inputIndex: 0 },
]);
});
it('maps multi-output sources to distinct output slots', () => {
// ┌───────┐ ┌──┐ o0 ┌─┐
// │trigger├───►│ ├──────►│A│
// └───────┘ │IF│ └─┘
// │ │ o1 ┌─┐
// │ ├──────►│B│
// └──┘ └─┘
const graph = converter.convert(
workflow({
nodes: [manualTrigger, node('if-uuid', 'IF'), node('a-uuid', 'A'), node('b-uuid', 'B')],
connections: {
'When clicking Execute': { main: [[main('IF')]] },
IF: { main: [[main('A')], [main('B')]] },
},
}),
);
expect(graph.edges).toContainEqual({
from: 'if-uuid',
to: 'a-uuid',
outputIndex: 0,
inputIndex: 0,
});
expect(graph.edges).toContainEqual({
from: 'if-uuid',
to: 'b-uuid',
outputIndex: 1,
inputIndex: 0,
});
});
it('maps multi-input targets to distinct input slots', () => {
// ┌─┐ i0 ┌─────┐
// │A├─────────►│ │
// └─┘ │Merge│
// ┌─┐ i1 │ │
// │B├─────────►│ │
// └─┘ └─────┘
const graph = converter.convert(
workflow({
nodes: [node('a-uuid', 'A'), node('b-uuid', 'B'), node('merge-uuid', 'Merge')],
connections: {
A: { main: [[main('Merge', 0)]] },
B: { main: [[main('Merge', 1)]] },
},
}),
);
expect(graph.edges).toEqual([
{ from: 'a-uuid', to: 'merge-uuid', outputIndex: 0, inputIndex: 0 },
{ from: 'b-uuid', to: 'merge-uuid', outputIndex: 0, inputIndex: 1 },
]);
});
it('leaves rootless nodes unconnected', () => {
const graph = converter.convert(
workflow({ nodes: [manualTrigger, node('orphan-uuid', 'Orphan')], connections: {} }),
);
expect(graph.nodes).toHaveLength(2);
expect(graph.edges).toEqual([]);
});
it('drops connections referencing missing nodes', () => {
// ┌─────┐ ┌─┐ ┌───────┐ ┌─────────────┐
// │Ghost├───►│A│ │trigger├───►│Another Ghost│
// └─────┘ └─┘ └───────┘ └─────────────┘
// neither ghost exists as a node
const graph = converter.convert(
workflow({
nodes: [manualTrigger, node('a-uuid', 'A')],
connections: {
Ghost: { main: [[main('A')]] },
'When clicking Execute': { main: [[main('Another Ghost')]] },
},
}),
);
expect(graph.edges).toEqual([]);
});
it('rejects non-main connection types', () => {
expect(() =>
converter.convert(
workflow({
nodes: [node('tool-uuid', 'Tool'), node('agent-uuid', 'Agent')],
connections: {
Tool: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] },
},
}),
),
).toThrow(UnsupportedConnectionTypeError);
});
});
describe('disabled nodes', () => {
it('splices out a disabled node, keeping the outer slot indexes', () => {
// XX
// ┌───────┐ ┌─┐ o1 ┌────────┐ i2 ┌─┐
// │trigger├───►│A├───────►│Disabled├───────►│S│
// └───────┘ └─┘ └────────┘ └─┘
const graph = converter.convert(
workflow({
nodes: [
manualTrigger,
node('a-uuid', 'A'),
node('d-uuid', 'Disabled', { disabled: true }),
node('s-uuid', 'S'),
],
connections: {
'When clicking Execute': { main: [[main('A')]] },
A: { main: [[], [main('Disabled')]] },
Disabled: { main: [[main('S', 2)]] },
},
}),
);
expect(graph.nodes.map((n) => n.id)).toEqual(['trigger-uuid', 'a-uuid', 's-uuid']);
expect(graph.edges).toContainEqual({
from: 'a-uuid',
to: 's-uuid',
outputIndex: 1,
inputIndex: 2,
});
expect(graph.edges).toHaveLength(2);
});
it('splices chained disabled nodes transitively', () => {
// XX XX
// ┌─┐ ┌──┐ ┌──┐ ┌─┐
// │A├──────►│D1├────►│D2├────►│B│
// └─┘ └──┘ └──┘ └─┘
const graph = converter.convert(
workflow({
nodes: [
node('a-uuid', 'A'),
node('d1-uuid', 'D1', { disabled: true }),
node('d2-uuid', 'D2', { disabled: true }),
node('b-uuid', 'B'),
],
connections: {
A: { main: [[main('D1')]] },
D1: { main: [[main('D2')]] },
D2: { main: [[main('B')]] },
},
}),
);
expect(graph.edges).toEqual([
{ from: 'a-uuid', to: 'b-uuid', outputIndex: 0, inputIndex: 0 },
]);
});
it('fans out the splice as a cross-product', () => {
// XX
// ┌──┐ ┌────────┐ ┌──┐
// │P1├──────►│ ├────►│S1│
// └──┘ │Disabled│ └──┘
// ┌──┐ │ │ ┌──┐
// │P2├──────►│ ├────►│S2│
// └──┘ └────────┘ └──┘
const graph = converter.convert(
workflow({
nodes: [
node('p1-uuid', 'P1'),
node('p2-uuid', 'P2'),
node('d-uuid', 'Disabled', { disabled: true }),
node('s1-uuid', 'S1'),
node('s2-uuid', 'S2'),
],
connections: {
P1: { main: [[main('Disabled')]] },
P2: { main: [[main('Disabled')]] },
Disabled: { main: [[main('S1'), main('S2')]] },
},
}),
);
expect(graph.edges).toHaveLength(4);
for (const from of ['p1-uuid', 'p2-uuid']) {
for (const to of ['s1-uuid', 's2-uuid']) {
expect(graph.edges).toContainEqual({ from, to, outputIndex: 0, inputIndex: 0 });
}
}
});
it('dedupes identical edges created by the splice', () => {
// XX
// ┌──┐
// ┌───►│D1├────┐
// ┌─┐ │ └──┘ │ ┌─┐
// │A├───┤ XX ├──►│S│
// └─┘ │ ┌──┐ │ └─┘
// └───►│D2├────┘
// └──┘
const graph = converter.convert(
workflow({
nodes: [
node('a-uuid', 'A'),
node('d1-uuid', 'D1', { disabled: true }),
node('d2-uuid', 'D2', { disabled: true }),
node('s-uuid', 'S'),
],
connections: {
A: { main: [[main('D1'), main('D2')]] },
D1: { main: [[main('S')]] },
D2: { main: [[main('S')]] },
},
}),
);
expect(graph.edges).toEqual([
{ from: 'a-uuid', to: 's-uuid', outputIndex: 0, inputIndex: 0 },
]);
});
it('drops predecessors feeding input slots other than 0, matching v1 pass-through', () => {
// XX
// ┌─┐ i0 ┌─────┐
// │A├────────────►│ │ ┌─┐
// └─┘ │Merge├────►│S│
// ┌─┐ i1 │ │ └─┘
// │B├────────────►│ │
// └─┘ └─────┘
const graph = converter.convert(
workflow({
nodes: [
node('a-uuid', 'A'),
node('b-uuid', 'B'),
node('merge-uuid', 'Merge', { disabled: true }),
node('s-uuid', 'S'),
],
connections: {
A: { main: [[main('Merge', 0)]] },
B: { main: [[main('Merge', 1)]] },
Merge: { main: [[main('S')]] },
},
}),
);
expect(graph.edges).toEqual([
{ from: 'a-uuid', to: 's-uuid', outputIndex: 0, inputIndex: 0 },
]);
});
it('ignores unsupported constructs on disabled nodes', () => {
// XX
// ┌───────┐ ┌─┐
// │Webhook├───►│A│ an unsupported trigger, but disabled
// └───────┘ └─┘
const graph = converter.convert(
workflow({
nodes: [
node('webhook-uuid', 'Webhook', { type: 'n8n-nodes-base.webhook', disabled: true }),
node('a-uuid', 'A'),
],
connections: { Webhook: { main: [[main('A')]] } },
}),
);
expect(graph.nodes.map((n) => n.id)).toEqual(['a-uuid']);
expect(graph.edges).toEqual([]);
});
});
describe('loops', () => {
it('maps Split In Batches to a batch step and marks its loop-back edge', () => {
// ┌───────┐ ┌────┐ o0 ┌────┐
// │trigger├───►│ ├──────►│Done│
// └───────┘ │Loop│ └────┘
// │ │ o1 ┌────┐
// │ ├──────►│Body│
// └─▲──┘ └──┬─┘
// └───(back)────┘
const graph = converter.convert(
workflow({
nodes: [
manualTrigger,
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('done-uuid', 'Done'),
node('body-uuid', 'Body'),
],
connections: {
'When clicking Execute': { main: [[main('Loop')]] },
Loop: { main: [[main('Done')], [main('Body')]] },
Body: { main: [[main('Loop')]] },
},
}),
);
expect(graph.nodes).toContainEqual({
id: 'loop-uuid',
name: 'Loop',
type: 'batch',
config: {
nodeType: 'n8n-nodes-base.splitInBatches',
typeVersion: 3,
parameters: {},
continueOnFail: false,
},
});
expect(graph.edges).toEqual([
{ from: 'trigger-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0 },
{ from: 'loop-uuid', to: 'done-uuid', outputIndex: 0, inputIndex: 0 },
{ from: 'loop-uuid', to: 'body-uuid', outputIndex: 1, inputIndex: 0 },
{ from: 'body-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
]);
});
it('marks both back edges of nested loops, but not the loop-entry edge', () => {
// ┌───────┐ ┌─────┐ o0 ┌───┐
// │trigger├───►│ ├──────►│End│
// └───────┘ │Outer│ └───┘
// │ │ o1 ┌─────┐ o0 ┌───────────┐
// │ ├──────►│ ├──────►│After Inner│
// └──▲──┘ │Inner│ └─────┬─────┘
// │ │ │ o1 ┌────┐│
// │ │ ├──────►│Body││
// │ └──▲──┘ └──┬─┘│
// │ └───(back)────┘ │
// └───(back)─────────────────────┘
const graph = converter.convert(
workflow({
nodes: [
manualTrigger,
node('outer-uuid', 'Outer', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('inner-uuid', 'Inner', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('body-uuid', 'Body'),
node('after-inner-uuid', 'After Inner'),
node('end-uuid', 'End'),
],
connections: {
'When clicking Execute': { main: [[main('Outer')]] },
Outer: { main: [[main('End')], [main('Inner')]] },
Inner: { main: [[main('After Inner')], [main('Body')]] },
Body: { main: [[main('Inner')]] },
'After Inner': { main: [[main('Outer')]] },
},
}),
);
const backEdges = graph.edges.filter((edge) => edge.isBackEdge);
expect(backEdges).toEqual([
{ from: 'body-uuid', to: 'inner-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
{
from: 'after-inner-uuid',
to: 'outer-uuid',
outputIndex: 0,
inputIndex: 0,
isBackEdge: true,
},
]);
});
it('marks the loop-back edge of a rootless loop regardless of node order', () => {
// ┌────┐ o1 ┌────┐
// │Loop├──────►│Body│ nothing else points in
// └─▲──┘ └──┬─┘
// └───(back)────┘
const graph = converter.convert(
workflow({
nodes: [
node('body-uuid', 'Body'),
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
],
connections: {
Body: { main: [[main('Loop')]] },
Loop: { main: [[], [main('Body')]] },
},
}),
);
expect(graph.edges).toEqual([
{ from: 'body-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
{ from: 'loop-uuid', to: 'body-uuid', outputIndex: 1, inputIndex: 0 },
]);
});
it('rejects a loop entered mid-body, regardless of node order', () => {
// ┌──┐ ┌────┐ o1 ┌────┐ ┌──┐
// │T1├───►│Loop├──────►│Body│◄───┤T2│
// └──┘ └─▲──┘ └──┬─┘ └──┘
// └─────────────┘
// two ways into the loop: through Loop (T1) and mid-body (T2)
const t1 = node('t1-uuid', 'T1', { type: 'n8n-nodes-base.manualTrigger' });
const t2 = node('t2-uuid', 'T2', { type: 'n8n-nodes-base.manualTrigger' });
const loop = node('loop-uuid', 'Loop', {
type: 'n8n-nodes-base.splitInBatches',
typeVersion: 3,
});
const body = node('body-uuid', 'Body');
const connections = {
T1: { main: [[main('Loop')]] },
T2: { main: [[main('Body')]] },
Loop: { main: [[], [main('Body')]] },
Body: { main: [[main('Loop')]] },
};
for (const nodes of [
[t1, t2, loop, body],
[t2, t1, loop, body],
]) {
expect(() => converter.convert(workflow({ nodes, connections }))).toThrow(
UnsupportedLoopEntryError,
);
}
});
it('converts a loop fed by multiple triggers through its batch node', () => {
// ┌──┐ ┌────┐ o1 ┌────┐
// │T1├───►│ ├──────►│Body│
// └──┘ │Loop│ └──┬─┘
// ┌──┐ │ │ │
// │T2├───►│ │◄─(back)──┘
// └──┘ └────┘ one way in, used by both triggers
const graph = converter.convert(
workflow({
nodes: [
node('t1-uuid', 'T1', { type: 'n8n-nodes-base.manualTrigger' }),
node('t2-uuid', 'T2', { type: 'n8n-nodes-base.manualTrigger' }),
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('body-uuid', 'Body'),
],
connections: {
T1: { main: [[main('Loop')]] },
T2: { main: [[main('Loop')]] },
Loop: { main: [[], [main('Body')]] },
Body: { main: [[main('Loop')]] },
},
}),
);
const backEdges = graph.edges.filter((edge) => edge.isBackEdge);
expect(backEdges).toEqual([
{ from: 'body-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
]);
});
it('rejects a non-batch cycle nested inside a loop body', () => {
// ┌───────┐ ┌────┐ o1 ┌─┐ ┌─┐
// │trigger├───►│Loop├──────►│A├────►│B│
// └───────┘ └─▲──┘ └▲┘ └┬┘
// │ └───────┤
// └───────────────────┘
// B feeds both Loop and A, making A -> B -> A a cycle with no batch node
expect(() =>
converter.convert(
workflow({
nodes: [
manualTrigger,
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('a-uuid', 'A'),
node('b-uuid', 'B'),
],
connections: {
'When clicking Execute': { main: [[main('Loop')]] },
Loop: { main: [[], [main('A')]] },
A: { main: [[main('B')]] },
B: { main: [[main('A'), main('Loop')]] },
},
}),
),
).toThrow(UnsupportedCycleError);
});
it('rejects an inner loop entered mid-body by the outer loop, regardless of edge order', () => {
// ┌───────┐ ┌─────┐ o1 ┌─────┐ o1 ┌──┐
// │trigger├──►│ ├────────►│ ├────────►│ │
// └───────┘ │Outer│ │Inner│ │B1│
// │ │ o1 │ │◄────────┤ │
// │ ├────────────────────────►│ │
// └──▲──┘ └──┬──┘ o0 └──┘
// │ ▼
// │ ┌───────────┐
// └────────┤After Inner│
// └───────────┘
// Outer o1 fans out to both Inner and B1: two ways into the inner loop
const nodes = [
manualTrigger,
node('outer-uuid', 'Outer', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('inner-uuid', 'Inner', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('b1-uuid', 'B1'),
node('after-inner-uuid', 'After Inner'),
];
for (const outerLoopOutput of [
[main('Inner'), main('B1')],
[main('B1'), main('Inner')],
]) {
const connections = {
'When clicking Execute': { main: [[main('Outer')]] },
Outer: { main: [[], outerLoopOutput] },
Inner: { main: [[main('After Inner')], [main('B1')]] },
B1: { main: [[main('Inner')]] },
'After Inner': { main: [[main('Outer')]] },
};
expect(() => converter.convert(workflow({ nodes, connections }))).toThrow(
UnsupportedLoopEntryError,
);
}
});
it('rejects rootless nested loops as ambiguous, regardless of node order', () => {
// ┌─────┐ o1 ┌─────┐ o0 ┌───────────┐
// │ ├──────►│ ├──────►│After Inner│
// │Outer│ │Inner│ └─────┬─────┘
// │ │ │ │ o1 ┌────┐│
// │ │ │ ├──────►│Body││
// └──▲──┘ └──▲──┘ └──┬─┘│
// │ └─────────────┘ │
// └──────────────────────────────┘
// no trigger anywhere: no way to tell which loop is the outer one
expect(() =>
converter.convert(
workflow({
nodes: [
node('inner-uuid', 'Inner', {
type: 'n8n-nodes-base.splitInBatches',
typeVersion: 3,
}),
node('outer-uuid', 'Outer', {
type: 'n8n-nodes-base.splitInBatches',
typeVersion: 3,
}),
node('body-uuid', 'Body'),
node('after-inner-uuid', 'After Inner'),
],
connections: {
Outer: { main: [[], [main('Inner')]] },
Inner: { main: [[main('After Inner')], [main('Body')]] },
Body: { main: [[main('Inner')]] },
'After Inner': { main: [[main('Outer')]] },
},
}),
),
).toThrow(UnsupportedLoopEntryError);
});
it('converts chained rootless loops, which have an unambiguous entry', () => {
// ┌──┐ o1 ┌──┐ ┌──┐ o1 ┌──┐
// │L1├──────►│B1│ │L2├──────►│B2│
// └─▲┘ └┬─┘ └─▲┘ └┬─┘
// └─(back)──┘ └─(back)──┘
// L1 -o0-> L2 chains the loops, no trigger anywhere
const graph = converter.convert(
workflow({
nodes: [
node('l2-uuid', 'L2', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('l1-uuid', 'L1', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
node('b1-uuid', 'B1'),
node('b2-uuid', 'B2'),
],
connections: {
L1: { main: [[main('L2')], [main('B1')]] },
B1: { main: [[main('L1')]] },
L2: { main: [[], [main('B2')]] },
B2: { main: [[main('L2')]] },
},
}),
);
const backEdges = graph.edges.filter((edge) => edge.isBackEdge);
expect(backEdges).toEqual([
{ from: 'b1-uuid', to: 'l1-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
{ from: 'b2-uuid', to: 'l2-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
]);
});
it('rejects cycles that are not Split In Batches loops', () => {
// ┌─┐ ┌─┐
// │A├────►│B│
// │ │◄────┤ │
// └─┘ └─┘
expect(() =>
converter.convert(
workflow({
nodes: [node('a-uuid', 'A'), node('b-uuid', 'B')],
connections: {
A: { main: [[main('B')]] },
B: { main: [[main('A')]] },
},
}),
),
).toThrow(UnsupportedCycleError);
});
});
});
@@ -1,5 +1,7 @@
import { UserError } from 'n8n-workflow';
const quote = (names: string[]) => names.map((name) => `"${name}"`).join(', ');
/**
* Thrown when a v1 workflow uses a construct the converter does not (yet)
* support. Caused by user-provided workflow content, hence a `UserError`.
@@ -14,6 +16,30 @@ export class UnsupportedTriggerError extends UserError {
}
}
export class UnsupportedConnectionTypeError extends UserError {
constructor(nodeName: string, connectionType: string) {
super(
`Node "${nodeName}" has a "${connectionType}" connection, which is not supported yet. Only "main" connections are currently supported.`,
);
}
}
export class UnsupportedCycleError extends UserError {
constructor(nodeNames: string[]) {
super(
`The cycle involving ${quote(nodeNames)} is not supported yet. Only Split In Batches loops are currently supported.`,
);
}
}
export class UnsupportedLoopEntryError extends UserError {
constructor(memberNames: string[], entryNames: string[]) {
super(
`The loop formed by ${quote(memberNames)} must be entered only through its Split In Batches node, but its entry points are: ${quote(entryNames)}.`,
);
}
}
export class UnsupportedStepTypeError extends UserError {
constructor(stepType: string) {
super(`V1StepExecutor only handles 'v1-node' steps, got '${stepType}'`);
@@ -1,10 +1,18 @@
import type { GraphNode, WorkflowGraph } from '@n8n/engine';
import type { INode, IWorkflowBase } from 'n8n-workflow';
import type { GraphEdge, GraphNode, WorkflowGraph } from '@n8n/engine';
import type { INode, INodeConnections, IWorkflowBase } from 'n8n-workflow';
import { UnsupportedTriggerError, UnsupportedWorkflowError } from './errors';
import {
UnsupportedConnectionTypeError,
UnsupportedCycleError,
UnsupportedLoopEntryError,
UnsupportedTriggerError,
UnsupportedWorkflowError,
} from './errors';
import type { V1NodeStepConfig } from './types';
const MANUAL_TRIGGER_TYPE = 'n8n-nodes-base.manualTrigger';
const SPLIT_IN_BATCHES_TYPE = 'n8n-nodes-base.splitInBatches';
const MAIN_CONNECTION_TYPE = 'main';
/**
* Common v1 trigger types that don't end in "Trigger". Non-exhaustive: combined
@@ -21,11 +29,44 @@ const KNOWN_TRIGGER_TYPES = new Set(['n8n-nodes-base.webhook', 'n8n-nodes-base.c
* graph nodes and edges and never executes anything. Supported surface is kept
* deliberately small (see the converter tests); unsupported constructs are
* rejected with a clear error rather than silently mistranslated.
*
* Branching is purely structural, so an edge records which output slot feeds
* which input slot, nothing more. Branches leaving the same node are
* unordered, so the engine may run them in parallel. This deliberately
* **diverges from v1**, which runs them one after another in connection order.
* If branch B must run after branch A, that needs a connection from A to B.
* We do not invent connections to force v1 order. If it is ever needed, the
* engine can be made to support running branches one at a time.
*
* Disabled nodes never appear in the graph: every edge into their input
* slot 0 is joined to every edge leaving them, so A -> disabled -> B becomes
* A -> B, keeping A's `outputIndex` and B's `inputIndex`. Input slot 0 is
* the only one joined because v1 passes nothing else through. On the way out
* we keep edges from every output slot, deliberately **diverging from v1**,
* where the pass-through data leaves on output 0 only.
*
* The only cycle allowed is a Split In Batches loop: items leave the batch
* node, traverse the body, and return on an edge marked `isBackEdge`. We can
* only tell which edge is the return when the sole way into a loop, at every
* nesting level, is through its batch node. Hence cycles with no batch node,
* loops entered mid-body, and loops with more than one candidate entry are
* all rejected.
*/
export class V1WorkflowConverter {
convert(workflow: IWorkflowBase): WorkflowGraph {
const nodes = workflow.nodes.map((node) => this.toGraphNode(node));
return { nodes, edges: [] };
const liveNodes = workflow.nodes.filter((node) => node.disabled !== true);
const disabledNodeIds = workflow.nodes
.filter((node) => node.disabled === true)
.map((node) => node.id);
const nodes = liveNodes.map((node) => this.toGraphNode(node));
let edges = this.toEdges(workflow);
edges = this.spliceOutDisabledNodes(edges, disabledNodeIds);
edges = this.dedupeEdges(edges);
this.markBackEdges(nodes, edges);
return { nodes, edges };
}
private toGraphNode(node: INode): GraphNode {
@@ -50,7 +91,9 @@ export class V1WorkflowConverter {
continueOnFail: node.continueOnFail === true || node.onError === 'continueRegularOutput',
};
return { id: node.id, name: node.name, type: 'v1-node', config };
const type = node.type === SPLIT_IN_BATCHES_TYPE ? 'batch' : 'v1-node';
return { id: node.id, name: node.name, type, config };
}
/** Heuristic trigger detection — see {@link KNOWN_TRIGGER_TYPES}. */
@@ -61,4 +104,236 @@ export class V1WorkflowConverter {
node.type.toLowerCase().endsWith('trigger')
);
}
private toEdges(workflow: IWorkflowBase): GraphEdge[] {
const idsByName = new Map(workflow.nodes.map((node) => [node.name, node.id]));
return Object.entries(workflow.connections).flatMap(([sourceName, connectionsByType]) =>
this.toEdgesForSource(sourceName, connectionsByType, idsByName),
);
}
/**
* v1 connections of one source node have the shape
* `{ main: [ [connection, ...], null, [connection, ...] ] }`: connections
* grouped by type, then by the source's output slot, where a slot with
* nothing connected may hold null.
*/
private toEdgesForSource(
sourceName: string,
connectionsByType: INodeConnections,
idsByName: Map<string, string>,
): GraphEdge[] {
const from = idsByName.get(sourceName);
const edges: GraphEdge[] = [];
for (const [connectionType, outputSlots] of Object.entries(connectionsByType)) {
this.validateSupportedConnectionType(sourceName, connectionType);
for (let outputIndex = 0; outputIndex < outputSlots.length; outputIndex++) {
for (const connection of outputSlots[outputIndex] ?? []) {
this.validateSupportedConnectionType(sourceName, connection.type);
const to = idsByName.get(connection.node);
// v1 skips connections whose endpoint no longer exists, so dropping
// them is the faithful translation
if (from === undefined || to === undefined) continue;
edges.push({ from, to, outputIndex, inputIndex: connection.index });
}
}
}
return edges;
}
private validateSupportedConnectionType(nodeName: string, connectionType: string): void {
if (connectionType !== MAIN_CONNECTION_TYPE) {
throw new UnsupportedConnectionTypeError(nodeName, connectionType);
}
}
private spliceOutDisabledNodes(edges: GraphEdge[], disabledNodeIds: string[]): GraphEdge[] {
for (const disabledNodeId of disabledNodeIds) {
const incoming = edges.filter(
(edge) =>
edge.to === disabledNodeId && edge.from !== disabledNodeId && edge.inputIndex === 0,
);
const outgoing = edges.filter(
(edge) => edge.from === disabledNodeId && edge.to !== disabledNodeId,
);
const spliced = incoming.flatMap((into) =>
outgoing.map((outOf) => ({
from: into.from,
to: outOf.to,
outputIndex: into.outputIndex,
inputIndex: outOf.inputIndex,
})),
);
edges = this.dedupeEdges(
edges
.filter((edge) => edge.from !== disabledNodeId && edge.to !== disabledNodeId)
.concat(spliced),
);
}
return edges;
}
private dedupeEdges(edges: GraphEdge[]): GraphEdge[] {
const byKey = new Map<string, GraphEdge>();
for (const edge of edges) {
byKey.set(`${edge.from}|${edge.to}|${edge.outputIndex}|${edge.inputIndex}`, edge);
}
return [...byKey.values()];
}
/**
* Finds every loop, marks its return edges as `isBackEdge`, and rejects
* every other cycle, peeling loops from the outside in. Each round: group
* the nodes that can reach each other in a circle ({@link computeSccs}),
* require each group to have a single valid entry
* ({@link resolveSingleBatchEntry}), mark the group's edges into that
* entry as returns, and cut them. Cutting breaks the outer circle, so a
* nested loop surfaces as its own group in the next round. When nothing
* circular remains, every loop is marked.
*
* Example with a nested loop:
*
* ┌───────┐ ┌─────┐ ┌─────┐ ┌──────────┐
* │Trigger├──►│ ├───►│ ├───►│AfterInner│
* └───────┘ │Outer│ │Inner│ └────┬─────┘
* │ │ │ │ ┌────┐ │
* │ │ │ ├►│Body│ │
* └──▲──┘ └──▲──┘ └──┬─┘ │
* │ └(back)─┘ │
* └───────(back)──────────┘
*
* Round 1 groups {Outer, Inner, Body, AfterInner} with entry Outer, then
* marks and cuts AfterInner -> Outer. Round 2 groups {Inner, Body} with
* entry Inner, then marks and cuts Body -> Inner. Round 3 finds nothing
* circular and stops.
*
* Only set membership decides, never traversal order, so the outcome does
* not depend on node or edge order.
*/
private markBackEdges(nodes: GraphNode[], edges: GraphEdge[]): void {
const batchNodeIds = new Set(nodes.filter((node) => node.type === 'batch').map((n) => n.id));
const namesById = new Map(nodes.map((node) => [node.id, node.name]));
let remaining = edges;
while (remaining.length > 0) {
const outgoingByNode = new Map<string, GraphEdge[]>();
const selfLoopNodeIds = new Set<string>();
for (const edge of remaining) {
const outgoing = outgoingByNode.get(edge.from);
if (outgoing) outgoing.push(edge);
else outgoingByNode.set(edge.from, [edge]);
if (edge.from === edge.to) selfLoopNodeIds.add(edge.from);
}
const cyclicSccs = this.computeSccs(nodes, outgoingByNode).filter(
(members) => members.length > 1 || selfLoopNodeIds.has(members[0]),
);
if (cyclicSccs.length === 0) return;
for (const members of cyclicSccs) {
const entryId = this.resolveSingleBatchEntry(members, remaining, batchNodeIds, namesById);
const memberSet = new Set(members);
for (const edge of remaining) {
if (edge.to === entryId && memberSet.has(edge.from)) edge.isBackEdge = true;
}
}
remaining = remaining.filter((edge) => !edge.isBackEdge);
}
}
/**
* A loop is convertible only when there is exactly one way into it from
* the outside and that way in is the batch node. Otherwise there is no
* single answer to which edge is the return. A loop that nothing points
* into has no outside entries, so its batch node stands in, provided it
* has exactly one.
*/
private resolveSingleBatchEntry(
members: string[],
edges: GraphEdge[],
batchNodeIds: Set<string>,
namesById: Map<string, string>,
): string {
const memberSet = new Set(members);
const toNames = (ids: string[]) => ids.map((id) => namesById.get(id) ?? id);
const batchMembers = members.filter((id) => batchNodeIds.has(id));
if (batchMembers.length === 0) {
throw new UnsupportedCycleError(toNames(members));
}
const externalEntries = new Set<string>();
for (const edge of edges) {
if (memberSet.has(edge.to) && !memberSet.has(edge.from)) externalEntries.add(edge.to);
}
const entries = externalEntries.size > 0 ? [...externalEntries] : batchMembers;
if (entries.length !== 1 || !batchNodeIds.has(entries[0])) {
throw new UnsupportedLoopEntryError(toNames(members), toNames(entries));
}
return entries[0];
}
/**
* Splits the nodes into groups (strongly connected components): two nodes
* share a group when you can walk from one to the other and back along
* edges, through any number of nodes. A -> B -> C -> A puts all three in
* one group. A node on no such round trip is a group of one, so a group
* with more than one member, or with a self-loop, is a cycle.
* Textbook Tarjan's algorithm, best reviewed against a reference:
* https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
*/
private computeSccs(nodes: GraphNode[], outgoingByNode: Map<string, GraphEdge[]>): string[][] {
const indexById = new Map<string, number>();
const lowlinkById = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
const sccs: string[][] = [];
let nextIndex = 0;
const connect = (nodeId: string): void => {
indexById.set(nodeId, nextIndex);
lowlinkById.set(nodeId, nextIndex);
nextIndex += 1;
stack.push(nodeId);
onStack.add(nodeId);
for (const edge of outgoingByNode.get(nodeId) ?? []) {
if (!indexById.has(edge.to)) {
connect(edge.to);
lowlinkById.set(nodeId, Math.min(lowlinkById.get(nodeId)!, lowlinkById.get(edge.to)!));
} else if (onStack.has(edge.to)) {
lowlinkById.set(nodeId, Math.min(lowlinkById.get(nodeId)!, indexById.get(edge.to)!));
}
}
if (lowlinkById.get(nodeId) === indexById.get(nodeId)) {
const members: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
members.push(member);
} while (member !== nodeId);
sccs.push(members);
}
};
for (const node of nodes) {
if (!indexById.has(node.id)) connect(node.id);
}
return sccs;
}
}