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:
mfsiega
2026-07-28 13:58:57 +02:00
committed by GitHub
parent b2af755502
commit 642ec341dc
19 changed files with 1049 additions and 2 deletions
@@ -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,
) {
@@ -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);
});
+28
View File
@@ -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':