From 642ec341dc76604bf2583683561cfe1cca17c3ba Mon Sep 17 00:00:00 2001 From: mfsiega <93014743+mfsiega@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:58:57 +0200 Subject: [PATCH] feat(core): Add v1 to v2 workflow converter (no-changelog) (#34870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Iván Ovejero --- .../eslint.config.mjs | 4 + .../node-engine-compatibility/package.json | 36 +++ .../src/__tests__/fixtures.ts | 214 ++++++++++++++++++ .../src/__tests__/io.test.ts | 53 +++++ .../src/__tests__/v1-step-executor.test.ts | 151 ++++++++++++ .../__tests__/v1-workflow-converter.test.ts | 149 ++++++++++++ .../node-engine-compatibility/src/errors.ts | 47 ++++ .../node-engine-compatibility/src/guards.ts | 15 ++ .../node-engine-compatibility/src/index.ts | 4 + .../@n8n/node-engine-compatibility/src/io.ts | 21 ++ .../node-engine-compatibility/src/types.ts | 38 ++++ .../src/v1-step-executor.ts | 195 ++++++++++++++++ .../src/v1-workflow-converter.ts | 64 ++++++ .../tsconfig.build.json | 10 + .../node-engine-compatibility/tsconfig.json | 14 ++ .../vitest.config.ts | 5 + .../node-execution-context/execute-context.ts | 2 +- .../get-input-connection-data.test.ts | 1 - pnpm-lock.yaml | 28 +++ 19 files changed, 1049 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/node-engine-compatibility/eslint.config.mjs create mode 100644 packages/@n8n/node-engine-compatibility/package.json create mode 100644 packages/@n8n/node-engine-compatibility/src/__tests__/fixtures.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/__tests__/io.test.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/__tests__/v1-workflow-converter.test.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/errors.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/guards.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/index.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/io.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/types.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts create mode 100644 packages/@n8n/node-engine-compatibility/src/v1-workflow-converter.ts create mode 100644 packages/@n8n/node-engine-compatibility/tsconfig.build.json create mode 100644 packages/@n8n/node-engine-compatibility/tsconfig.json create mode 100644 packages/@n8n/node-engine-compatibility/vitest.config.ts diff --git a/packages/@n8n/node-engine-compatibility/eslint.config.mjs b/packages/@n8n/node-engine-compatibility/eslint.config.mjs new file mode 100644 index 00000000000..37c64ca3838 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/eslint.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'eslint/config'; +import { nodeConfig } from '@n8n/eslint-config/node'; + +export default defineConfig({ ignores: ['dist/**'] }, nodeConfig); diff --git a/packages/@n8n/node-engine-compatibility/package.json b/packages/@n8n/node-engine-compatibility/package.json new file mode 100644 index 00000000000..78166e3ca9d --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/package.json @@ -0,0 +1,36 @@ +{ + "name": "@n8n/node-engine-compatibility", + "version": "0.1.0", + "description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model", + "scripts": { + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json", + "build:unchecked": "tsc -p tsconfig.build.json --noCheck", + "format": "biome format --write src", + "format:check": "biome ci src", + "lint": "eslint . --quiet", + "lint:fix": "eslint . --fix", + "test": "vitest run --passWithNoTests", + "test:dev": "vitest --watch", + "watch": "tsc -p tsconfig.build.json --watch" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "dependencies": { + "@n8n/engine": "workspace:*", + "n8n-core": "workspace:*", + "n8n-workflow": "workspace:*" + }, + "devDependencies": { + "@n8n/typescript-config": "workspace:*", + "@n8n/vitest-config": "workspace:*", + "n8n-nodes-base": "workspace:*", + "typescript": "catalog:typescript", + "vitest": "catalog:" + }, + "license": "LicenseRef-n8n-sustainable-use" +} diff --git a/packages/@n8n/node-engine-compatibility/src/__tests__/fixtures.ts b/packages/@n8n/node-engine-compatibility/src/__tests__/fixtures.ts new file mode 100644 index 00000000000..dc53cfed73d --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/__tests__/fixtures.ts @@ -0,0 +1,214 @@ +import type { JsonObject, JsonValue, StepExecutionRequest, WorkflowGraph } from '@n8n/engine'; +import type { ExecuteContext } from 'n8n-core'; +import { NoOp } from 'n8n-nodes-base/nodes/NoOp/NoOp.node'; +import type { + CloseFunction, + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + INodeTypes, + IVersionedNodeType, + IWorkflowBase, + IWorkflowExecuteAdditionalData, +} from 'n8n-workflow'; +import { Node, NodeConnectionTypes } from 'n8n-workflow'; + +class EchoParam implements INodeType { + description = { + displayName: 'Echo Param', + name: 'echoParam', + group: ['transform'], + version: 1, + description: 'Echoes a parameter', + defaults: { name: 'Echo Param' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [{ displayName: 'Message', name: 'message', type: 'string', default: '' }], + } as unknown as INodeType['description']; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + return await Promise.resolve([ + items.map((_, i) => ({ + json: { message: this.getNodeParameter('message', i) as string }, + })), + ]); + } +} + +class AlwaysFails implements INodeType { + description = { + displayName: 'Always Fails', + name: 'alwaysFails', + group: ['transform'], + version: 1, + description: 'Throws', + defaults: { name: 'Always Fails' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [], + } as unknown as INodeType['description']; + + async execute(this: IExecuteFunctions): Promise { + return await Promise.reject(new Error('boom from node')); + } +} + +class NoExecute implements INodeType { + description = { + displayName: 'No Execute', + name: 'noExecute', + group: ['trigger'], + version: 1, + description: 'Cannot run as a step', + defaults: { name: 'No Execute' }, + inputs: [], + outputs: [NodeConnectionTypes.Main], + properties: [], + } as unknown as INodeType['description']; +} + +class NewStyleEcho extends Node { + description = { + displayName: 'New Style Echo', + name: 'newStyleEcho', + group: ['transform'], + version: 1, + description: 'New context API node', + defaults: { name: 'New Style Echo' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [], + } as unknown as INodeTypeDescription; + + async execute(context: IExecuteFunctions): Promise { + return await Promise.resolve([ + context.getInputData().map((item) => ({ json: { ...item.json, newStyle: true } })), + ]); + } +} + +const pushCloseFunction = (context: IExecuteFunctions, close: CloseFunction) => { + (context as unknown as ExecuteContext).closeFunctions.push(close); +}; + +class SucceedsWithFailingCleanup implements INodeType { + description = { + displayName: 'Succeeds With Failing Cleanup', + name: 'succeedsWithFailingCleanup', + group: ['transform'], + version: 1, + description: 'Registers failing cleanup', + defaults: { name: 'Succeeds With Failing Cleanup' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [], + } as unknown as INodeType['description']; + + async execute(this: IExecuteFunctions): Promise { + pushCloseFunction(this, async () => { + return await Promise.reject(new Error('cleanup boom')); + }); + return await Promise.resolve([this.getInputData()]); + } +} + +class FailsWithFailingCleanup implements INodeType { + description = { + displayName: 'Fails With Failing Cleanup', + name: 'failsWithFailingCleanup', + group: ['transform'], + version: 1, + description: 'Fails and registers failing cleanup', + defaults: { name: 'Fails With Failing Cleanup' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [], + } as unknown as INodeType['description']; + + async execute(this: IExecuteFunctions): Promise { + pushCloseFunction(this, async () => { + return await Promise.reject(new Error('cleanup boom')); + }); + return await Promise.reject(new Error('boom from node')); + } +} + +class ReturnsEngineRequest extends Node { + description = { + displayName: 'Returns Engine Request', + name: 'returnsEngineRequest', + group: ['transform'], + version: 1, + description: 'Returns a sub-node execution request', + defaults: { name: 'Returns Engine Request' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + properties: [], + } as unknown as INodeTypeDescription; + + async execute(): Promise { + return await Promise.resolve({ + actions: [], + metadata: {}, + } as unknown as INodeExecutionData[][]); + } +} + +const registry = new Map([ + ['n8n-nodes-base.noOp', new NoOp()], + ['test.echoParam', new EchoParam()], + ['test.alwaysFails', new AlwaysFails()], + ['test.noExecute', new NoExecute()], + ['test.newStyleEcho', new NewStyleEcho() as unknown as INodeType], + ['test.succeedsWithFailingCleanup', new SucceedsWithFailingCleanup()], + ['test.failsWithFailingCleanup', new FailsWithFailingCleanup()], + ['test.returnsEngineRequest', new ReturnsEngineRequest() as unknown as INodeType], +]); + +export const testNodeTypes: INodeTypes = { + getByName: (type: string): INodeType | IVersionedNodeType => registry.get(type)!, + getByNameAndVersion: (type: string): INodeType => registry.get(type)!, + getKnownTypes: (): IDataObject => ({}), +}; + +export const testAdditionalDataFactory = async ( + executionId: string, +): Promise => + await Promise.resolve({ + executionId, + restApiUrl: 'http://localhost:5678/rest', + instanceBaseUrl: 'http://localhost:5678', + webhookBaseUrl: 'http://localhost:5678/webhook', + webhookTestBaseUrl: 'http://localhost:5678/webhook-test', + webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting', + formWaitingBaseUrl: 'http://localhost:5678/form-waiting', + variables: {}, + hooks: undefined, + credentialsHelper: undefined, + } as unknown as IWorkflowExecuteAdditionalData); + +export const v1Workflow = ( + nodes: Array<{ id: string; name: string; type: string; parameters?: IDataObject }>, +): IWorkflowBase => + ({ + id: 'wf-1', + name: 'fixture', + active: false, + nodes: nodes.map((n) => ({ typeVersion: 1, position: [0, 0], parameters: {}, ...n })), + connections: {}, + }) as unknown as IWorkflowBase; + +export const stepRequest = ( + graph: WorkflowGraph, + nodeId: string, + inputs: JsonValue, +): StepExecutionRequest => ({ + node: graph.nodes.find((n) => n.id === nodeId)!, + inputs, + context: { executionId: 'exec-1', stepId: 'step-1', workflowId: 'wf-1', mode: 'manual' }, +}); + +export const items = (...objects: JsonObject[]): JsonValue => [objects.map((json) => ({ json }))]; diff --git a/packages/@n8n/node-engine-compatibility/src/__tests__/io.test.ts b/packages/@n8n/node-engine-compatibility/src/__tests__/io.test.ts new file mode 100644 index 00000000000..1804ba9a0f0 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/__tests__/io.test.ts @@ -0,0 +1,53 @@ +import type { INodeExecutionData } from 'n8n-workflow'; +import { describe, expect, it } from 'vitest'; + +import { fromStepInputs, toStepOutputs } from '../io'; + +describe('fromStepInputs', () => { + it('passes through well-formed items', () => { + const inputs = [[{ json: { a: 1 } }, { json: { a: 2 } }], [{ json: { b: 1 } }]]; + const result = fromStepInputs(inputs); + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(2); + expect(result[0][0].json).toEqual({ a: 1 }); + expect(result[1][0].json).toEqual({ b: 1 }); + }); + + it('wraps bare objects as `{ json: ... }`', () => { + const result = fromStepInputs([[{ a: 1 }]]); + expect(result[0][0]).toEqual({ json: { a: 1 } }); + }); + + it('wraps primitives as `{ json: { value: ... } }`', () => { + const result = fromStepInputs([[42]]); + expect(result[0][0]).toEqual({ json: { value: 42 } }); + }); + + it.each([ + ['null', null], + ['a string', 'str'], + ['an array', [1, 2]], + ])('re-wraps items whose `json` is %s', (_label, json) => { + expect(fromStepInputs([[{ json }]])).toEqual([[{ json: { json } }]]); + }); + + it('yields a single empty item list for non-array payloads', () => { + expect(fromStepInputs({})).toEqual([[]]); + expect(fromStepInputs(null)).toEqual([[]]); + }); + + it('yields an empty item list for non-array elements', () => { + expect(fromStepInputs(['nope'])).toEqual([[]]); + }); +}); + +describe('toStepOutputs', () => { + it('survives a JSON round-trip', () => { + const outputs: INodeExecutionData[][] = [[{ json: { x: 1 } }], []]; + const payload = toStepOutputs(outputs); + expect(payload).toEqual(outputs); + + // eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify + expect(JSON.parse(JSON.stringify(payload))).toEqual(outputs); + }); +}); diff --git a/packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts new file mode 100644 index 00000000000..ef01d05e735 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-step-executor.test.ts @@ -0,0 +1,151 @@ +import type { WorkflowGraph } from '@n8n/engine'; +import { describe, expect, it } from 'vitest'; + +import { + EngineRequestNotSupportedError, + MalformedStepConfigError, + UnknownNodeTypeError, + UnsupportedNodeTypeError, + UnsupportedStepTypeError, +} from '../errors'; +import { V1StepExecutor } from '../v1-step-executor'; +import { V1WorkflowConverter } from '../v1-workflow-converter'; +import { + items, + stepRequest, + testAdditionalDataFactory, + testNodeTypes, + v1Workflow, +} from './fixtures'; + +const converter = new V1WorkflowConverter(); +const executor = new V1StepExecutor({ + nodeTypes: testNodeTypes, + additionalDataFactory: testAdditionalDataFactory, +}); + +const graphWith = (type: string, parameters = {}): WorkflowGraph => + converter.convert( + v1Workflow([ + { id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' }, + { id: 'n', name: 'Subject', type, parameters }, + ]), + ); + +describe('V1StepExecutor', () => { + it('resolves `getNodeParameter` per item', async () => { + const graph = graphWith('test.echoParam', { message: 'hi' }); + const result = await executor.execute(stepRequest(graph, 'n', items({ a: 1 }, { a: 2 }))); + expect(result.outputs).toEqual([[{ json: { message: 'hi' } }, { json: { message: 'hi' } }]]); + // eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify + expect(JSON.parse(JSON.stringify(result.outputs))).toEqual(result.outputs); + }); + + it('guarantees one item for empty input', async () => { + const graph = graphWith('test.echoParam', { message: 'solo' }); + const result = await executor.execute(stepRequest(graph, 'n', [])); + expect(result.outputs).toEqual([[{ json: { message: 'solo' } }]]); + }); + + it('rejects non-v1-node steps', async () => { + const graph = graphWith('n8n-nodes-base.noOp'); + const triggerStep = stepRequest(graph, 't', []); + const execution = executor.execute(triggerStep); + await expect(execution).rejects.toThrow(UnsupportedStepTypeError); + }); + + it.each([ + ['missing', undefined], + ['null', null], + ['a string', 'nonsense'], + ['missing nodeType', { typeVersion: 1, parameters: {}, continueOnFail: false }], + [ + 'non-record parameters', + { nodeType: 'x', typeVersion: 1, parameters: 'bad', continueOnFail: false }, + ], + ])('rejects a v1-node step whose config is %s', async (_, config) => { + const graph = graphWith('n8n-nodes-base.noOp'); + const request = stepRequest(graph, 'n', []); + request.node = { ...request.node, config }; + const execution = executor.execute(request); + await expect(execution).rejects.toThrow(MalformedStepConfigError); + }); + + it('rejects unknown node types', async () => { + const graph = graphWith('test.doesNotExist'); + const execution = executor.execute(stepRequest(graph, 'n', [])); + await expect(execution).rejects.toThrow(UnknownNodeTypeError); + }); + + it('rejects node types without an execute method', async () => { + const graph = graphWith('test.noExecute'); + const execution = executor.execute(stepRequest(graph, 'n', [])); + await expect(execution).rejects.toThrow(UnsupportedNodeTypeError); + }); + + it('propagates node errors per the IStepExecutor failure contract', async () => { + const graph = graphWith('test.alwaysFails'); + const execution = executor.execute(stepRequest(graph, 'n', [])); + await expect(execution).rejects.toThrow('boom from node'); + }); + + it('invokes new-style Node subclasses with the context as argument', async () => { + const graph = graphWith('test.newStyleEcho'); + const result = await executor.execute(stepRequest(graph, 'n', items({ a: 1 }))); + expect(result.outputs).toEqual([[{ json: { a: 1, newStyle: true } }]]); + }); + + it('passes input through when the node throws and continueOnFail is set', async () => { + const workflow = v1Workflow([ + { id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' }, + { id: 'n', name: 'Fails', type: 'test.alwaysFails' }, + ]); + (workflow.nodes[1] as { continueOnFail?: boolean }).continueOnFail = true; + const graph = converter.convert(workflow); + + const result = await executor.execute(stepRequest(graph, 'n', items({ keep: 'me' }))); + expect(result.outputs).toEqual([[{ json: { keep: 'me' } }]]); + }); + + it('propagates cleanup errors when the node succeeded', async () => { + const graph = graphWith('test.succeedsWithFailingCleanup'); + const execution = executor.execute(stepRequest(graph, 'n', items({ a: 1 }))); + await expect(execution).rejects.toThrow('cleanup boom'); + }); + + it('preserves the node error over cleanup errors when both fail', async () => { + const graph = graphWith('test.failsWithFailingCleanup'); + const execution = executor.execute(stepRequest(graph, 'n', items({ a: 1 }))); + await expect(execution).rejects.toThrow('boom from node'); + }); + + it('throws on EngineRequest results instead of dropping them', async () => { + const graph = graphWith('test.returnsEngineRequest'); + const execution = executor.execute(stepRequest(graph, 'n', items({ a: 1 }))); + await expect(execution).rejects.toThrow(EngineRequestNotSupportedError); + }); + + it('does not let continueOnFail swallow the EngineRequest rejection', async () => { + const workflow = v1Workflow([ + { id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' }, + { id: 'n', name: 'Agent', type: 'test.returnsEngineRequest' }, + ]); + (workflow.nodes[1] as { continueOnFail?: boolean }).continueOnFail = true; + const graph = converter.convert(workflow); + + const execution = executor.execute(stepRequest(graph, 'n', items({ a: 1 }))); + await expect(execution).rejects.toThrow(EngineRequestNotSupportedError); + }); + + it('honors onError=continueRegularOutput as passthrough', async () => { + const workflow = v1Workflow([ + { id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' }, + { id: 'n', name: 'Fails', type: 'test.alwaysFails' }, + ]); + (workflow.nodes[1] as { onError?: string }).onError = 'continueRegularOutput'; + const graph = converter.convert(workflow); + + const result = await executor.execute(stepRequest(graph, 'n', items({ keep: 'me' }))); + expect(result.outputs).toEqual([[{ json: { keep: 'me' } }]]); + }); +}); diff --git a/packages/@n8n/node-engine-compatibility/src/__tests__/v1-workflow-converter.test.ts b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-workflow-converter.test.ts new file mode 100644 index 00000000000..56a5c98a7e4 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/__tests__/v1-workflow-converter.test.ts @@ -0,0 +1,149 @@ +import type { INode, IWorkflowBase } from 'n8n-workflow'; +import { describe, expect, it } from 'vitest'; + +import { UnsupportedTriggerError } from '../errors'; +import { V1WorkflowConverter } from '../v1-workflow-converter'; + +const converter = new V1WorkflowConverter(); + +const manualTrigger: INode = { + id: 'trigger-uuid', + name: 'When clicking Execute', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, +}; + +function workflow(overrides: Partial): IWorkflowBase { + return { + id: 'wf-1', + name: 'Test workflow', + active: false, + isArchived: false, + nodes: [], + connections: {}, + settings: {}, + ...overrides, + } as IWorkflowBase; +} + +describe('V1WorkflowConverter', () => { + describe('trigger nodes', () => { + it('maps a manual trigger to a single trigger graph node', () => { + const graph = converter.convert( + workflow({ + nodes: [ + { + id: 'node-uuid-1', + name: 'When clicking Execute', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + }), + ); + + expect(graph.nodes).toEqual([ + { id: 'node-uuid-1', name: 'When clicking Execute', type: 'trigger' }, + ]); + expect(graph.edges).toEqual([]); + }); + }); + + describe('v1 nodes', () => { + it('maps a regular node to a v1-node step carrying its config', () => { + const graph = converter.convert( + workflow({ + nodes: [ + manualTrigger, + { + id: 'set-uuid', + name: 'Edit Fields', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [200, 0], + parameters: { mode: 'manual', includeOtherFields: true }, + continueOnFail: true, + }, + ], + }), + ); + + expect(graph.nodes).toContainEqual({ + id: 'set-uuid', + name: 'Edit Fields', + type: 'v1-node', + config: { + nodeType: 'n8n-nodes-base.set', + typeVersion: 3.4, + parameters: { mode: 'manual', includeOtherFields: true }, + continueOnFail: true, + }, + }); + }); + + it('defaults continueOnFail to false when the node omits it', () => { + const graph = converter.convert( + workflow({ + nodes: [ + manualTrigger, + { + id: 'noop-uuid', + name: 'No Operation', + type: 'n8n-nodes-base.noOp', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }, + ], + }), + ); + + const noOp = graph.nodes.find((n) => n.id === 'noop-uuid'); + expect(noOp?.config).toMatchObject({ continueOnFail: false }); + }); + }); + + describe('unsupported constructs', () => { + it('rejects a non-manual trigger with a clear error', () => { + expect(() => + converter.convert( + workflow({ + nodes: [ + { + id: 'webhook-uuid', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 2, + position: [0, 0], + parameters: {}, + }, + ], + }), + ), + ).toThrow(UnsupportedTriggerError); + }); + + it('rejects a schedule trigger (type ending in "Trigger")', () => { + expect(() => + converter.convert( + workflow({ + nodes: [ + { + id: 'sched-uuid', + name: 'Schedule Trigger', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + }), + ), + ).toThrow(UnsupportedTriggerError); + }); + }); +}); diff --git a/packages/@n8n/node-engine-compatibility/src/errors.ts b/packages/@n8n/node-engine-compatibility/src/errors.ts new file mode 100644 index 00000000000..2dc73e40a60 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/errors.ts @@ -0,0 +1,47 @@ +import { UserError } from 'n8n-workflow'; + +/** + * Thrown when a v1 workflow uses a construct the converter does not (yet) + * support. Caused by user-provided workflow content, hence a `UserError`. + */ +export class UnsupportedWorkflowError extends UserError {} + +export class UnsupportedTriggerError extends UserError { + constructor(nodeName: string, nodeType: string) { + super( + `Trigger node "${nodeName}" (${nodeType}) is not supported yet; only the Manual Trigger is currently supported.`, + ); + } +} + +export class UnsupportedStepTypeError extends UserError { + constructor(stepType: string) { + super(`V1StepExecutor only handles 'v1-node' steps, got '${stepType}'`); + } +} + +export class MalformedStepConfigError extends UserError { + constructor(stepName: string) { + super(`Step "${stepName}" has a missing or malformed v1-node config`); + } +} + +export class UnknownNodeTypeError extends UserError { + constructor(nodeType: string) { + super(`Unknown node type "${nodeType}"`); + } +} + +export class UnsupportedNodeTypeError extends UserError { + constructor(nodeType: string) { + super(`Node type "${nodeType}" has no execute method and cannot run as a step`); + } +} + +export class EngineRequestNotSupportedError extends UserError { + constructor(nodeType: string) { + super( + `Node type "${nodeType}" returned an engine request, but sub-node execution is not supported`, + ); + } +} diff --git a/packages/@n8n/node-engine-compatibility/src/guards.ts b/packages/@n8n/node-engine-compatibility/src/guards.ts new file mode 100644 index 00000000000..4189d75108e --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/guards.ts @@ -0,0 +1,15 @@ +import type { V1NodeStepConfig } from './types'; + +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export function isV1NodeStepConfig(config: unknown): config is V1NodeStepConfig { + if (!isRecord(config)) return false; + return ( + typeof config.nodeType === 'string' && + config.nodeType.length > 0 && + typeof config.typeVersion === 'number' && + isRecord(config.parameters) && + typeof config.continueOnFail === 'boolean' + ); +} diff --git a/packages/@n8n/node-engine-compatibility/src/index.ts b/packages/@n8n/node-engine-compatibility/src/index.ts new file mode 100644 index 00000000000..ce9df6da461 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/index.ts @@ -0,0 +1,4 @@ +export { V1WorkflowConverter } from './v1-workflow-converter'; +export { V1StepExecutor } from './v1-step-executor'; +export { UnsupportedTriggerError, UnsupportedWorkflowError } from './errors'; +export type { V1StepExecutorDeps } from './types'; diff --git a/packages/@n8n/node-engine-compatibility/src/io.ts b/packages/@n8n/node-engine-compatibility/src/io.ts new file mode 100644 index 00000000000..0631e8b9fcb --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/io.ts @@ -0,0 +1,21 @@ +import type { JsonValue } from '@n8n/engine'; +import type { IDataObject, INodeExecutionData } from 'n8n-workflow'; + +import { isRecord } from './guards'; + +export function fromStepInputs(value: JsonValue): INodeExecutionData[][] { + if (!Array.isArray(value)) return [[]]; + + return value.map((items) => { + if (!Array.isArray(items)) return []; + return items.map((item): INodeExecutionData => { + if (isRecord(item) && isRecord(item.json)) return item as unknown as INodeExecutionData; + if (isRecord(item)) return { json: item as IDataObject }; + return { json: { value: item } as IDataObject }; + }); + }); +} + +export function toStepOutputs(outputs: INodeExecutionData[][]): JsonValue { + return outputs as unknown as JsonValue; +} diff --git a/packages/@n8n/node-engine-compatibility/src/types.ts b/packages/@n8n/node-engine-compatibility/src/types.ts new file mode 100644 index 00000000000..aa5bc971846 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/types.ts @@ -0,0 +1,38 @@ +import type { StepExecutionContext } from '@n8n/engine'; +import type { ExecuteContext } from 'n8n-core'; +import type { + INodeExecutionData, + INodeParameters, + INodeType, + INodeTypes, + IWorkflowExecuteAdditionalData, +} from 'n8n-workflow'; + +export interface V1NodeStepConfig { + nodeType: string; + typeVersion: number; + parameters: INodeParameters; + continueOnFail: boolean; +} + +export interface V1StepExecutorDeps { + nodeTypes: INodeTypes; + additionalDataFactory: (executionId: string) => Promise; +} + +export type ExecutableNodeType = INodeType & { execute: NonNullable }; + +export type NodeRunResult = { ok: true; value: unknown } | { ok: false; error: unknown }; + +export interface CreateNodeExecuteContextParams { + nodeId: string; + nodeName: string; + stepConfig: V1NodeStepConfig; + itemsByConnection: INodeExecutionData[][]; + stepContext: StepExecutionContext; +} + +export interface RunNodeParams { + nodeType: ExecutableNodeType; + nodeExecuteContext: ExecuteContext; +} diff --git a/packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts b/packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts new file mode 100644 index 00000000000..a1c66477651 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/v1-step-executor.ts @@ -0,0 +1,195 @@ +import type { + GraphNode, + IStepExecutor, + StepExecutionRequest, + StepExecutionResult, +} from '@n8n/engine'; +import { ExecuteContext } from 'n8n-core'; +import type { + IExecuteData, + INode, + INodeExecutionData, + ITaskDataConnections, + WorkflowExecuteMode, +} from 'n8n-workflow'; +import { + createRunExecutionData, + isNodeClassInstance, + UnexpectedError, + Workflow, +} from 'n8n-workflow'; + +import { + EngineRequestNotSupportedError, + MalformedStepConfigError, + UnsupportedNodeTypeError, + UnknownNodeTypeError, + UnsupportedStepTypeError, +} from './errors'; +import { isV1NodeStepConfig } from './guards'; +import { fromStepInputs, toStepOutputs } from './io'; +import type { + CreateNodeExecuteContextParams, + ExecutableNodeType, + NodeRunResult, + RunNodeParams, + V1NodeStepConfig, + V1StepExecutorDeps, +} from './types'; + +/** + * Runs `v1-node` steps by adapting them to the v1 node runtime. + * + * A step is executable when its type is `v1-node`, its config is a valid + * `V1NodeStepConfig`, and the config names a registered node type with an + * `execute` method. + */ +export class V1StepExecutor implements IStepExecutor { + constructor(private readonly deps: V1StepExecutorDeps) {} + + async execute(request: StepExecutionRequest): Promise { + const stepConfig = this.validateStepConfig(request.node); + const nodeType = this.resolveNodeType(stepConfig); + + const nodeExecuteContext = await this.createNodeExecuteContext({ + nodeId: request.node.id, + nodeName: request.node.name, + stepContext: request.context, + stepConfig, + itemsByConnection: fromStepInputs(request.inputs), + }); + + const nodeResult = await this.runNode({ nodeType, nodeExecuteContext }); + + return { outputs: toStepOutputs(nodeResult) }; + } + + private validateStepConfig({ type, name, config }: GraphNode): V1NodeStepConfig { + if (type !== 'v1-node') throw new UnsupportedStepTypeError(type); + if (!isV1NodeStepConfig(config)) throw new MalformedStepConfigError(name); + + return config; + } + + private resolveNodeType({ + nodeType: typeName, + typeVersion, + }: V1NodeStepConfig): ExecutableNodeType { + const nodeType = this.deps.nodeTypes.getByNameAndVersion(typeName, typeVersion); + if (!nodeType) throw new UnknownNodeTypeError(typeName); + if (typeof nodeType.execute !== 'function') throw new UnsupportedNodeTypeError(typeName); + + return nodeType as ExecutableNodeType; + } + + private async createNodeExecuteContext({ + nodeId, + nodeName, + stepConfig, + stepContext: stepCtx, + itemsByConnection, + }: CreateNodeExecuteContextParams) { + const node: INode = { + id: nodeId, + name: nodeName, + type: stepConfig.nodeType, + typeVersion: stepConfig.typeVersion, + position: [0, 0], + parameters: stepConfig.parameters, + continueOnFail: stepConfig.continueOnFail, + }; + + const workflow = new Workflow({ + id: stepCtx.workflowId, + nodes: [node], + connections: {}, + active: false, + nodeTypes: this.deps.nodeTypes, + }); + + const firstInput = itemsByConnection[0] ?? []; + const connectionInputData = firstInput.length > 0 ? firstInput : [{ json: {} }]; + const inputData: ITaskDataConnections = { + main: [connectionInputData, ...itemsByConnection.slice(1)], + }; + + const runExecutionData = createRunExecutionData({ + startData: {}, + resultData: { runData: {} }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: null, + }, + }); + + const executeData: IExecuteData = { node, data: inputData, source: null }; + const mode: WorkflowExecuteMode = stepCtx.mode === 'production' ? 'trigger' : 'manual'; + const additionalData = await this.deps.additionalDataFactory(stepCtx.executionId); + + return new ExecuteContext( + workflow, + node, + additionalData, + mode, + runExecutionData, + 0, + connectionInputData, + inputData, + executeData, + [], + ); + } + + /** + * Runs the node and settles its cleanup functions. + * + * Cleanup errors propagate only when the node itself succeeded. A node + * failure with `continueOnFail` passes the input items through as output. + * A null result yields no output. + */ + private async runNode({ + nodeType, + nodeExecuteContext, + }: RunNodeParams): Promise { + let result: NodeRunResult; + try { + const value = isNodeClassInstance(nodeType) + ? await nodeType.execute(nodeExecuteContext) + : await nodeType.execute.call(nodeExecuteContext); + result = { ok: true, value }; + } catch (error) { + result = { ok: false, error }; + } + + const { closeFunctions } = nodeExecuteContext; + if (closeFunctions.length > 0) { + const closeResults = await Promise.allSettled(closeFunctions.map(async (fn) => await fn())); + if (result.ok) { + const rejected = closeResults.find( + (closeResult): closeResult is PromiseRejectedResult => closeResult.status === 'rejected', + ); + if (rejected) { + throw rejected.reason instanceof Error + ? rejected.reason + : new UnexpectedError("Error on node's close function", { + extra: { nodeName: nodeExecuteContext.getNode().name }, + }); + } + } + } + + if (!result.ok) { + if (!nodeExecuteContext.continueOnFail()) throw result.error; + return [nodeExecuteContext.getInputData()]; + } + + if (result.value === null || result.value === undefined) return []; + + if (Array.isArray(result.value)) return result.value as INodeExecutionData[][]; + + throw new EngineRequestNotSupportedError(nodeExecuteContext.getNode().type); + } +} diff --git a/packages/@n8n/node-engine-compatibility/src/v1-workflow-converter.ts b/packages/@n8n/node-engine-compatibility/src/v1-workflow-converter.ts new file mode 100644 index 00000000000..15568580d71 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/src/v1-workflow-converter.ts @@ -0,0 +1,64 @@ +import type { GraphNode, WorkflowGraph } from '@n8n/engine'; +import type { INode, IWorkflowBase } from 'n8n-workflow'; + +import { UnsupportedTriggerError, UnsupportedWorkflowError } from './errors'; +import type { V1NodeStepConfig } from './types'; + +const MANUAL_TRIGGER_TYPE = 'n8n-nodes-base.manualTrigger'; + +/** + * Common v1 trigger types that don't end in "Trigger". Non-exhaustive: combined + * with the name heuristic below, it exists to reject unsupported triggers with a + * clear message. A trigger we fail to recognise falls through to `v1-node` and + * fails later with a less specific "node has no execute method" error. + */ +const KNOWN_TRIGGER_TYPES = new Set(['n8n-nodes-base.webhook', 'n8n-nodes-base.cron']); + +/** + * Converts a v1 workflow (node-based JSON) into the Engine 2.0 `WorkflowGraph`. + * + * A pure, deterministic topology translation: it maps nodes and connections to + * 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. + */ +export class V1WorkflowConverter { + convert(workflow: IWorkflowBase): WorkflowGraph { + const nodes = workflow.nodes.map((node) => this.toGraphNode(node)); + return { nodes, edges: [] }; + } + + private toGraphNode(node: INode): GraphNode { + if (node.type === MANUAL_TRIGGER_TYPE) { + return { id: node.id, name: node.name, type: 'trigger' }; + } + + if (this.isTriggerNode(node)) { + throw new UnsupportedTriggerError(node.name, node.type); + } + + if (node.onError === 'continueErrorOutput') { + throw new UnsupportedWorkflowError( + `Node "${node.name}" uses onError=continueErrorOutput, which is not supported yet.`, + ); + } + + const config: V1NodeStepConfig = { + nodeType: node.type, + typeVersion: node.typeVersion, + parameters: node.parameters, + continueOnFail: node.continueOnFail === true || node.onError === 'continueRegularOutput', + }; + + return { id: node.id, name: node.name, type: 'v1-node', config }; + } + + /** Heuristic trigger detection — see {@link KNOWN_TRIGGER_TYPES}. */ + private isTriggerNode(node: INode): boolean { + return ( + node.type === MANUAL_TRIGGER_TYPE || + KNOWN_TRIGGER_TYPES.has(node.type) || + node.type.toLowerCase().endsWith('trigger') + ); + } +} diff --git a/packages/@n8n/node-engine-compatibility/tsconfig.build.json b/packages/@n8n/node-engine-compatibility/tsconfig.build.json new file mode 100644 index 00000000000..1143e768624 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**", "**/*.test.ts"] +} diff --git a/packages/@n8n/node-engine-compatibility/tsconfig.json b/packages/@n8n/node-engine-compatibility/tsconfig.json new file mode 100644 index 00000000000..4b798cc7096 --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": [ + "@n8n/typescript-config/tsconfig.common.go.json", + "@n8n/typescript-config/tsconfig.backend.go.json" + ], + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "types": ["node"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/@n8n/node-engine-compatibility/vitest.config.ts b/packages/@n8n/node-engine-compatibility/vitest.config.ts new file mode 100644 index 00000000000..c312d4bbd0a --- /dev/null +++ b/packages/@n8n/node-engine-compatibility/vitest.config.ts @@ -0,0 +1,5 @@ +import { createVitestConfig } from '@n8n/vitest-config/node'; + +export default createVitestConfig({ + exclude: ['**/node_modules/**', '**/dist/**'], +}); diff --git a/packages/core/src/execution-engine/node-execution-context/execute-context.ts b/packages/core/src/execution-engine/node-execution-context/execute-context.ts index 714793ddc8b..1e29e5b7119 100644 --- a/packages/core/src/execution-engine/node-execution-context/execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/execute-context.ts @@ -61,7 +61,7 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, executeData: IExecuteData, - private readonly closeFunctions: CloseFunction[], + readonly closeFunctions: CloseFunction[], abortSignal?: AbortSignal, public subNodeExecutionResults?: EngineResponse, ) { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts index 5cb2cfaa51d..8d0fb80d8c1 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts @@ -284,7 +284,6 @@ describe('getInputConnectionData', () => { const result = await executeContext.getInputConnectionData(connectionType, 0); expect(result).toBe(response); expect(supplyData).toHaveBeenCalled(); - // @ts-expect-error private property expect(executeContext.closeFunctions).toContain(closeFunction); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ec0b0a67a0..2b6d77e9bec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3065,6 +3065,34 @@ importers: specifier: 'catalog:' version: 3.1.0(@typescript/typescript6@6.0.2)(vitest@4.1.9) + packages/@n8n/node-engine-compatibility: + dependencies: + '@n8n/engine': + specifier: workspace:* + version: link:../engine + n8n-core: + specifier: workspace:* + version: link:../../core + n8n-workflow: + specifier: workspace:* + version: link:../../workflow + devDependencies: + '@n8n/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@n8n/vitest-config': + specifier: workspace:* + version: link:../vitest-config + n8n-nodes-base: + specifier: workspace:* + version: link:../../nodes-base + typescript: + specifier: catalog:typescript + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + packages/@n8n/nodes-langchain: dependencies: '@aws-sdk/client-bedrock-runtime':