mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Add v1 to v2 workflow converter (no-changelog) (#34870)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
|
||||
export default defineConfig({ ignores: ['dist/**'] }, nodeConfig);
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<INodeExecutionData[][]> {
|
||||
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<INodeExecutionData[][]> {
|
||||
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<INodeExecutionData[][]> {
|
||||
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<INodeExecutionData[][]> {
|
||||
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<INodeExecutionData[][]> {
|
||||
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<INodeExecutionData[][]> {
|
||||
return await Promise.resolve({
|
||||
actions: [],
|
||||
metadata: {},
|
||||
} as unknown as INodeExecutionData[][]);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new Map<string, INodeType>([
|
||||
['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<IWorkflowExecuteAdditionalData> =>
|
||||
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 }))];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' } }]]);
|
||||
});
|
||||
});
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { V1NodeStepConfig } from './types';
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
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'
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<IWorkflowExecuteAdditionalData>;
|
||||
}
|
||||
|
||||
export type ExecutableNodeType = INodeType & { execute: NonNullable<INodeType['execute']> };
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<StepExecutionResult> {
|
||||
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<INodeExecutionData[][]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createVitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default createVitestConfig({
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
});
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
-1
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Generated
+28
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user