Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2e6393b
feat(core): Expand workflow-sdk test fixtures to 1000 published workf…
mutdmour Feb 19, 2026
c4ddf9e
fix(core): Update workflow-sdk tests for 1000+ workflow fixtures
mutdmour Feb 19, 2026
bac0de4
fix(core): Fix codegen bugs to un-skip workflow-sdk test fixtures
mutdmour Feb 19, 2026
907ccf2
refactor(core): Move skipped workflows to manifest-level skip with re…
mutdmour Feb 19, 2026
379411f
fix(core): Fix codegen bugs to un-skip 8 workflow-sdk test fixtures
mutdmour Feb 19, 2026
3a78c99
fix(core): Fix codegen bugs for reversed AI connections, stack overfl…
mutdmour Feb 19, 2026
771db3d
fix(core): Fix composite dispatch and error connections in workflow-s…
mutdmour Feb 19, 2026
1c804db
fix(core): Fix color format bug in sticky note codegen, un-skip 2 wor…
mutdmour Feb 20, 2026
f6c3bf7
fix(core): Fix nested multiOutput codegen and un-skip 8 workflow-sdk …
mutdmour Feb 20, 2026
0cc664e
fix(core): Fix fan-in connection loss for multi-trigger merge pattern…
mutdmour Feb 20, 2026
c364bf2
fix(core): Fix merge input index for fan-out to visited merge branche…
mutdmour Feb 20, 2026
8f35891
fix(core): Fix nested .onError() chain extraction and duplicate node …
mutdmour Feb 20, 2026
c3ed228
remove skipped worfklow
mutdmour Feb 20, 2026
b7b88f9
clean up scripts
mutdmour Feb 20, 2026
2c9c7f6
clean up PR
mutdmour Feb 20, 2026
ea5d432
revert script changes
mutdmour Feb 20, 2026
9543890
fix(core): Add unit tests for PR review feedback and fix lint errors
mutdmour Feb 23, 2026
69048ed
chore: Remove unused warning validation scripts from package.json
mutdmour Feb 23, 2026
809cada
clean up tests
mutdmour Feb 23, 2026
f077ff0
refactor(workflow-sdk): Remove reversed AI connection normalization
mutdmour Feb 23, 2026
4fd08b5
refactor(workflow-sdk): Replace position-based AI subnode heuristic w…
mutdmour Feb 23, 2026
928fec9
clean up unnecessary redistribute
mutdmour Feb 23, 2026
ce6dd26
chore(workflow-sdk): Remove unused SKIP_WORKFLOWS and SKIP_VALIDATION…
mutdmour Feb 23, 2026
3689138
fix(workflow-sdk): Address PR review findings - input immutability, d…
mutdmour Feb 23, 2026
2c7660b
fix(workflow-sdk): Fix depth overflow in convergence patterns by skip…
mutdmour Feb 23, 2026
c9e3055
fix(workflow-sdk): Fix AI subnode connection index and add deep conne…
mutdmour Feb 23, 2026
b3953be
fixup roundtrip tests
mutdmour Feb 23, 2026
6eaab11
fix test
mutdmour Feb 23, 2026
5c4d56a
fix more tests
mutdmour Feb 23, 2026
101face
fix(workflow-sdk): Fix lint errors across workflow-sdk package
mutdmour Feb 23, 2026
b76c1ea
fix(workflow-sdk): Fix import ordering and remove unnecessary type as…
mutdmour Feb 23, 2026
1244d4f
fix(workflow-sdk): Remove unused IConnections imports
mutdmour Feb 23, 2026
b038522
Merge branch 'master' of github.com:n8n-io/n8n into feat/workflow-sdk…
mutdmour Feb 24, 2026
48a0ee3
Merge branch 'master' of github.com:n8n-io/n8n into feat/workflow-sdk…
mutdmour Feb 24, 2026
1515ff0
fix(core): Fix remaining codegen roundtrip failures (#26172)
mutdmour Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/@n8n/workflow-sdk/scripts/fetch-test-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ async function main() {
const allWorkflowIds: number[] = [];

// Fetch multiple pages to get enough workflow IDs
for (let page = 1; page <= 10; page++) {
for (let page = 1; page <= 20; page++) {
const results = await searchWorkflows(page, 100);
if (results.length === 0) break;

Expand All @@ -112,15 +112,15 @@ async function main() {
);

// Stop if we have enough candidates
if (allWorkflowIds.length >= 200) break;
if (allWorkflowIds.length >= 2000) break;
}

console.log(`\nFound ${allWorkflowIds.length} new workflow candidates\n`);
console.log('Fetching workflows (only published)...\n');

const results: { id: number; name: string; success: boolean }[] = [];
let publishedCount = 0;
const TARGET_COUNT = 100;
const TARGET_COUNT = 1000;

for (const id of allWorkflowIds) {
if (publishedCount >= TARGET_COUNT) {
Expand Down
20 changes: 20 additions & 0 deletions packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ export type SDKFunctions = Record<string, (...args: any[]) => unknown>;
* Walks the AST and evaluates SDK patterns.
*/
class SDKInterpreter {
private static readonly MAX_EVAL_DEPTH = 500;

private sdkFunctions: Map<string, (...args: unknown[]) => unknown>;
private variables: Map<string, unknown>;
private renamedVariables: Map<string, string> = new Map();
private sourceCode: string;
private evalDepth = 0;

constructor(sdkFunctions: SDKFunctions, sourceCode: string) {
this.sdkFunctions = new Map(Object.entries(sdkFunctions));
Expand Down Expand Up @@ -136,6 +139,23 @@ class SDKInterpreter {
private evaluate(node: ESTree.Expression | ESTree.SpreadElement | null): unknown {
if (node === null) return undefined;

if (this.evalDepth >= SDKInterpreter.MAX_EVAL_DEPTH) {
throw new InterpreterError(
'Expression nesting too deep (possible cycle in method chain)',
node.loc ?? undefined,
this.sourceCode,
);
}

this.evalDepth++;
try {
return this.evaluateNode(node);
} finally {
this.evalDepth--;
}
}

private evaluateNode(node: ESTree.Expression | ESTree.SpreadElement): unknown {
validateNodeType(node, this.sourceCode);

switch (node.type) {
Expand Down
294 changes: 293 additions & 1 deletion packages/@n8n/workflow-sdk/src/codegen/code-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { describe, it, expect, beforeAll } from '@jest/globals';
import * as fs from 'fs';
import * as path from 'path';

import { generateCode } from './code-generator';
import { generateCode, collectNestedMultiOutputs } from './code-generator';
import { buildCompositeTree } from './composite-builder';
import type { MultiOutputNode } from './composite-tree';
import { annotateGraph } from './graph-annotator';
import { parseWorkflowCode } from './parse-workflow-code';
import { buildSemanticGraph } from './semantic-graph';
Expand Down Expand Up @@ -1196,6 +1197,62 @@ describe('code-generator', () => {
// It should have empty nodes array
expect(code).toContain("sticky('Outer', []");
});
it('handles object color by omitting it from output', () => {
const json: WorkflowJSON = {
id: 'sticky-object-color-test',
name: 'Test',
nodes: [
{
id: '1',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [0, 0],
parameters: {
content: 'Note',
color: {},
width: 300,
height: 200,
},
},
],
connections: {},
};

const code = generateFromWorkflow(json);

// Object color should be omitted (it's an invalid value like {})
expect(code).not.toContain('[object Object]');
// Code should be parseable (no SyntaxError)
expect(() => parseWorkflowCode(code)).not.toThrow();
});

it('handles string color values correctly', () => {
const json: WorkflowJSON = {
id: 'sticky-string-color-test',
name: 'Test',
nodes: [
{
id: '1',
name: 'Sticky Note',
type: 'n8n-nodes-base.stickyNote',
typeVersion: 1,
position: [0, 0],
parameters: {
content: 'Note',
color: '#FF0000',
width: 300,
height: 200,
},
},
],
connections: {},
};

const code = generateFromWorkflow(json);

expect(code).toContain("color: '#FF0000'");
});
});

describe('AI subnodes', () => {
Expand Down Expand Up @@ -2132,6 +2189,111 @@ describe('code-generator', () => {
});
});

describe('multi-trigger fan-out to shared merge targets', () => {
it('preserves connections from second trigger when both fan out to same already-visited targets', () => {
// Pattern: Two triggers both fan out to the same pair of nodes that converge at a Merge.
// Trigger1 → [BranchA, BranchB] → Merge → Output
// Trigger2 → [BranchA, BranchB] → Merge → Output
// After Trigger1 builds BranchA and BranchB, Trigger2's fan-out targets
// are already visited. The codegen must still produce Trigger2's connections.
const json: WorkflowJSON = {
id: 'multi-trigger-merge',
name: 'Test',
nodes: [
{
id: '1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: '2',
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [0, 200],
parameters: {},
},
{
id: '3',
name: 'Branch A',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
position: [300, 0],
parameters: {},
},
{
id: '4',
name: 'Branch B',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
position: [300, 200],
parameters: {},
},
{
id: '5',
name: 'Merge',
type: 'n8n-nodes-base.merge',
typeVersion: 3,
position: [600, 100],
parameters: {},
},
{
id: '6',
name: 'Output',
type: 'n8n-nodes-base.set',
typeVersion: 3,
position: [900, 100],
parameters: {},
},
],
connections: {
'Manual Trigger': {
main: [
[
{ node: 'Branch A', type: 'main', index: 0 },
{ node: 'Branch B', type: 'main', index: 0 },
],
],
},
'Schedule Trigger': {
main: [
[
{ node: 'Branch A', type: 'main', index: 0 },
{ node: 'Branch B', type: 'main', index: 0 },
],
],
},
'Branch A': {
main: [[{ node: 'Merge', type: 'main', index: 0 }]],
},
'Branch B': {
main: [[{ node: 'Merge', type: 'main', index: 1 }]],
},
Merge: {
main: [[{ node: 'Output', type: 'main', index: 0 }]],
},
},
};

const code = generateFromWorkflow(json);
const parsed = parseWorkflowCode(code);

// Both triggers must appear as connection sources
const connectionKeys = Object.keys(parsed.connections);
expect(connectionKeys).toContain('Schedule Trigger');
expect(connectionKeys).toContain('Manual Trigger');

// Schedule Trigger must connect to both Branch A and Branch B
const scheduleTriggerConns = parsed.connections['Schedule Trigger']?.main?.[0];
expect(scheduleTriggerConns).toBeDefined();
const scheduleTargets = scheduleTriggerConns!.map((c: { node: string }) => c.node).sort();
expect(scheduleTargets).toEqual(['Branch A', 'Branch B']);
});
});

describe('merge node outgoing connections', () => {
it('preserves merge node outgoing connections when merge has .to()', () => {
// Pattern: trigger → [branch1, branch2] → merge → loopOverItems
Expand Down Expand Up @@ -2908,5 +3070,135 @@ describe('code-generator', () => {
expect(code).toContain('const merge_node =');
});
});

describe('nested multi-output nodes', () => {
it('generates .output().to() calls for multiOutput nodes nested inside another multiOutput chain', () => {
// Pattern: Trigger → Classifier1 (2 outputs)
// output 0 → NodeA
// output 1 → NodeB → Classifier2 (2 outputs)
// output 0 → NodeC
// output 1 → NodeD
// Classifier2 is nested inside Classifier1's output 1 chain.
// Both Classifier1 and Classifier2 should get .output(n).to() calls.
const json: WorkflowJSON = {
id: 'nested-multi-output-test',
name: 'Test',
nodes: [
{
id: '1',
name: 'Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
},
{
id: '2',
name: 'Classifier1',
type: '@n8n/n8n-nodes-langchain.textClassifier',
typeVersion: 1,
position: [200, 0],
parameters: {
inputText: '={{ $json.text }}',
categories: {
categories: [{ category: 'Cat1' }, { category: 'Cat2' }],
},
},
},
{
id: '3',
name: 'NodeA',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, -100],
},
{
id: '4',
name: 'NodeB',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 100],
},
{
id: '5',
name: 'Classifier2',
type: '@n8n/n8n-nodes-langchain.textClassifier',
typeVersion: 1,
position: [600, 100],
parameters: {
inputText: '={{ $json.text }}',
categories: {
categories: [{ category: 'CatA' }, { category: 'CatB' }],
},
},
},
{
id: '6',
name: 'NodeC',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [800, 0],
},
{
id: '7',
name: 'NodeD',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [800, 200],
},
],
connections: {
Trigger: { main: [[{ node: 'Classifier1', type: 'main', index: 0 }]] },
Classifier1: {
main: [
[{ node: 'NodeA', type: 'main', index: 0 }],
[{ node: 'NodeB', type: 'main', index: 0 }],
],
},
NodeB: { main: [[{ node: 'Classifier2', type: 'main', index: 0 }]] },
Classifier2: {
main: [
[{ node: 'NodeC', type: 'main', index: 0 }],
[{ node: 'NodeD', type: 'main', index: 0 }],
],
},
},
};

const code = generateFromWorkflow(json);

// Classifier1 should have .output() calls
expect(code).toContain('classifier1.output(0)');
expect(code).toContain('classifier1.output(1)');

// Classifier2 should ALSO have .output() calls (nested multi-output)
expect(code).toContain('classifier2.output(0)');
expect(code).toContain('classifier2.output(1)');

// All nodes should be parseable
const parsed = parseWorkflowCode(code);
const nodeNames = parsed.nodes.map((n) => n.name);
expect(nodeNames).toContain('NodeC');
expect(nodeNames).toContain('NodeD');
});
});
});

describe('collectNestedMultiOutputs', () => {
it('handles cycles without infinite recursion', () => {
const multiOutput: MultiOutputNode = {
kind: 'multiOutput',
sourceNode: { name: 'Switch1' } as MultiOutputNode['sourceNode'],
outputTargets: new Map(),
};
// Create a cycle: multiOutput's target points back to itself
multiOutput.outputTargets.set(0, multiOutput);

const collected: MultiOutputNode[] = [];
collectNestedMultiOutputs(multiOutput, collected);

// Should collect the node once and not infinite-loop
expect(collected).toHaveLength(1);
expect(collected[0]).toBe(multiOutput);
});
});
});
Loading
Loading