From 3a1b89c0346bdb899ec87a620e3af1212c5ca77f Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 14:05:50 +0100 Subject: [PATCH 01/28] switch to structured destination node --- .../manual-execution.service.test.ts | 6 +- .../test-runner/test-runner.service.ee.ts | 4 +- .../__tests__/telemetry-event-relay.test.ts | 13 ++- .../events/relays/telemetry.event-relay.ts | 4 +- .../execution-lifecycle-hooks.test.ts | 4 +- .../src/executions/execution-data.service.ts | 5 +- packages/cli/src/manual-execution.service.ts | 9 +- .../data-request-response-stripper.test.ts | 2 +- .../__tests__/webhook-helpers.test.ts | 2 +- packages/cli/src/webhooks/test-webhooks.ts | 15 ++- packages/cli/src/webhooks/webhook-helpers.ts | 13 ++- .../workflows/workflow-execution.service.ts | 23 ++-- ...process-process-run-execution-data.test.ts | 2 +- .../__tests__/workflow-execute.test.ts | 105 ++++++++---------- .../src/execution-engine/workflow-execute.ts | 46 ++++---- .../editor-ui/src/app/types/externalHooks.ts | 3 +- packages/workflow/src/interfaces.ts | 7 +- .../src/run-execution-data-factory.ts | 19 ++-- .../run-execution-data/run-execution-data.ts | 2 +- .../run-execution-data.v1.ts | 11 +- .../test/run-execution-data-factory.test.ts | 6 +- 21 files changed, 154 insertions(+), 147 deletions(-) diff --git a/packages/cli/src/__tests__/manual-execution.service.test.ts b/packages/cli/src/__tests__/manual-execution.service.test.ts index 4ee37262d709f..2e59a7c0b9169 100644 --- a/packages/cli/src/__tests__/manual-execution.service.test.ts +++ b/packages/cli/src/__tests__/manual-execution.service.test.ts @@ -198,7 +198,7 @@ describe('ManualExecutionService', () => { }, startNodes: [{ name: startNodeName }], executionMode: 'manual', - destinationNode: destinationNodeName, + destinationNode: { nodeName: destinationNodeName, mode: 'inclusive' }, }); const startNode = mock({ name: startNodeName }); @@ -384,7 +384,7 @@ describe('ManualExecutionService', () => { runData: mockRunData, startNodes: [{ name: 'node1' }], dirtyNodeNames, - destinationNode: destinationNodeName, + destinationNode: { nodeName: destinationNodeName, mode: 'inclusive' }, }); const workflow = mock({ @@ -493,7 +493,7 @@ describe('ManualExecutionService', () => { executionMode: 'manual', runData: mockRunData, startNodes: [], - destinationNode: destinationNodeName, + destinationNode: { nodeName: destinationNodeName, mode: 'inclusive' }, pinData: {}, dirtyNodeNames: [], agentRequest: undefined, diff --git a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index ab882ee07f2c9..9daca69a62dd4 100644 --- a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -318,7 +318,7 @@ export class TestRunnerService { }; const data: IWorkflowExecutionDataProcess = { - destinationNode: triggerNode.name, + destinationNode: { nodeName: triggerNode.name, mode: 'inclusive' }, executionMode: 'manual', runData: {}, workflowData: { @@ -334,7 +334,7 @@ export class TestRunnerService { userId: metadata.userId, executionData: createRunExecutionData({ startData: { - destinationNode: triggerNode.name, + destinationNode: { nodeName: triggerNode.name, mode: 'inclusive' }, }, manualData: { userId: metadata.userId, diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 2566a1cd923ef..9141fd1317531 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -14,6 +14,7 @@ import { import { mock } from 'jest-mock-extended'; import { type BinaryDataConfig, InstanceSettings } from 'n8n-core'; import { + createRunExecutionData, type INode, type INodesGraphResult, type IRun, @@ -1284,9 +1285,9 @@ describe('TelemetryEventRelay', () => { const runData = { status: 'error', mode: 'manual', - data: { + data: createRunExecutionData({ startData: { - destinationNode: 'OpenAI', + destinationNode: { nodeName: 'OpenAI', mode: 'inclusive' }, runNodeFilter: ['OpenAI'], }, resultData: { @@ -1315,7 +1316,7 @@ describe('TelemetryEventRelay', () => { }, ), }, - }, + }), } as IRun; const nodeGraph: INodesGraphResult = { @@ -1386,9 +1387,9 @@ describe('TelemetryEventRelay', () => { const runData = { status: 'error', mode: 'manual', - data: { + data: createRunExecutionData({ startData: { - destinationNode: 'OpenAI', + destinationNode: { nodeName: 'OpenAI', mode: 'inclusive' }, runNodeFilter: ['OpenAI'], }, resultData: { @@ -1417,7 +1418,7 @@ describe('TelemetryEventRelay', () => { }, ), }, - }, + }), } as IRun; const nodeGraph: INodesGraphResult = { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 94852f76dd8b4..3be50be21809d 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -792,9 +792,9 @@ export class TelemetryEventRelay extends EventRelay { ...manualExecEventProperties, node_type: TelemetryHelpers.getNodeTypeForName( workflow, - runData.data.startData?.destinationNode, + runData.data.startData?.destinationNode.nodeName, )?.type, - node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], + node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode.nodeName], }; this.telemetry.track('Manual node exec finished', telemetryPayload); diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 6f72e5c4ed26c..1bb39fbc90adb 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -131,8 +131,8 @@ describe('Execution Lifecycle Hooks', () => { }); successfulRunWithRewiredDestination.data = createRunExecutionData({ startData: { - destinationNode: 'PartialExecutionToolExecutor', - originalDestinationNode: nodeName, + destinationNode: { nodeName: 'PartialExecutionToolExecutor', mode: 'inclusive' }, + originalDestinationNode: { nodeName, mode: 'inclusive' }, }, resultData: { runData: {}, diff --git a/packages/cli/src/executions/execution-data.service.ts b/packages/cli/src/executions/execution-data.service.ts index e48b885e008f8..fdd436eebe1f5 100644 --- a/packages/cli/src/executions/execution-data.service.ts +++ b/packages/cli/src/executions/execution-data.service.ts @@ -36,7 +36,10 @@ export class ExecutionDataService { if (node) { returnData.data.startData = { - destinationNode: node.name, + destinationNode: { + nodeName: node.name, + mode: 'inclusive', + }, runNodeFilter: [node.name], }; returnData.data.resultData.lastNodeExecuted = node.name; diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts index 158e0f5ee45cb..75effb78c00d0 100644 --- a/packages/cli/src/manual-execution.service.ts +++ b/packages/cli/src/manual-execution.service.ts @@ -69,7 +69,7 @@ export class ManualExecutionService { let waitingExecution: IWaitingForExecution = {}; let waitingExecutionSource: IWaitingForExecutionSource = {}; - if (data.destinationNode !== data.triggerToStartFrom.name) { + if (data.destinationNode?.nodeName !== data.triggerToStartFrom.name) { const recreatedStack = recreateNodeExecutionStack( filterDisabledNodes(DirectedGraph.fromWorkflow(workflow)), new Set(startNodes), @@ -119,10 +119,10 @@ export class ManualExecutionService { const startNode = this.getExecutionStartNode(data, workflow); if (data.destinationNode) { - const destinationNode = workflow.getNode(data.destinationNode); + const destinationNode = workflow.getNode(data.destinationNode.nodeName); a.ok( destinationNode, - `Could not find a node named "${data.destinationNode}" in the workflow.`, + `Could not find a node named "${data.destinationNode.nodeName}" in the workflow.`, ); const destinationNodeType = workflow.nodeTypes.getByNameAndVersion( @@ -147,7 +147,8 @@ export class ManualExecutionService { data.executionData.startData.originalDestinationNode = data.destinationNode; } // Set destination to Tool Executor - data.destinationNode = TOOL_EXECUTOR_NODE_NAME; + // TODO(CAT-1265): Verify that this works as expected with inclusive mode. + data.destinationNode = { nodeName: TOOL_EXECUTOR_NODE_NAME, mode: 'inclusive' }; } } diff --git a/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts index df79ace0f05d4..739f5e7e5431e 100644 --- a/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts +++ b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts @@ -105,7 +105,7 @@ const taskData: DataRequestResponse = { node: codeNode, runExecutionData: createRunExecutionData({ startData: { - destinationNode: codeNode.name, + destinationNode: { nodeName: codeNode.name, mode: 'inclusive' }, runNodeFilter: [triggerNode.name, debugHelperNode.name, codeNode.name], }, resultData: { diff --git a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts index 462bea76e92f8..feb840596fdb7 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts @@ -396,7 +396,7 @@ describe('prepareExecutionData', () => { webhookResultData, undefined, {}, - 'targetNode', + { nodeName: 'targetNode', mode: 'inclusive' }, ); expect(runExecutionData.startData?.destinationNode).toBe('targetNode'); diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index 5ef590eaf74f0..56c20e78ab10b 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -10,6 +10,7 @@ import type { IHttpRequestMethods, IRunData, IWorkflowBase, + IDestinationNode, } from 'n8n-workflow'; import { authAllowlistedNodes } from './constants'; @@ -104,7 +105,14 @@ export class TestWebhooks implements IWebhookManager { }); } - const { destinationNode, pushRef, workflowEntity, webhook: testWebhook } = registration; + const { pushRef, workflowEntity, webhook: testWebhook } = registration; + // TODO(CAT-1265): support destination node mode in test webhook registration. + const destinationNode: IDestinationNode | undefined = registration.destinationNode + ? { + nodeName: registration.destinationNode, + mode: 'inclusive', + } + : undefined; const workflow = this.toWorkflow(workflowEntity); @@ -269,7 +277,7 @@ export class TestWebhooks implements IWebhookManager { additionalData: IWorkflowExecuteAdditionalData; runData?: IRunData; pushRef?: string; - destinationNode?: string; + destinationNode?: IDestinationNode; triggerToStartFrom?: WorkflowRequest.ManualRunPayload['triggerToStartFrom']; }) { const { @@ -344,10 +352,11 @@ export class TestWebhooks implements IWebhookManager { cacheableWebhook.userId = userId; + // TODO(CAT-1265): support destination node mode in test webhook registration. const registration: TestWebhookRegistration = { pushRef, workflowEntity, - destinationNode, + destinationNode: destinationNode?.nodeName, webhook: cacheableWebhook as IWebhookData, }; diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 8435e0f8257ed..4dafdfc38ae98 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -29,6 +29,7 @@ import type { IWorkflowExecutionDataProcess, IWorkflowBase, WebhookResponseData, + IDestinationNode, } from 'n8n-workflow'; import { CHAT_TRIGGER_NODE_TYPE, @@ -93,7 +94,7 @@ export function handleHostedChatResponse( export function getWorkflowWebhooks( workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, - destinationNode?: string, + destinationNode?: IDestinationNode, ignoreRestartWebhooks = false, ): IWebhookData[] { // Check all the nodes in the workflow if they have webhooks @@ -102,9 +103,11 @@ export function getWorkflowWebhooks( let parentNodes: string[] | undefined; if (destinationNode !== undefined) { - parentNodes = workflow.getParentNodes(destinationNode); + parentNodes = workflow.getParentNodes(destinationNode.nodeName); // Also add the destination node in case it itself is a webhook node - parentNodes.push(destinationNode); + if (destinationNode.mode === 'inclusive') { + parentNodes.push(destinationNode.nodeName); + } } for (const node of Object.values(workflow.nodes)) { @@ -297,7 +300,7 @@ export function prepareExecutionData( webhookResultData: IWebhookResponseData, runExecutionData: IRunExecutionData | undefined, runExecutionDataMerge: object = {}, - destinationNode?: string, + destinationNode?: IDestinationNode, executionId?: string, workflowData?: IWorkflowBase, ): { runExecutionData: IRunExecutionData; pinData: IPinData | undefined } { @@ -363,7 +366,7 @@ export async function executeWebhook( error: Error | null, data: IWebhookResponseCallbackData | WebhookResponse, ) => void, - destinationNode?: string, + destinationNode?: IDestinationNode, ): Promise { // Get the nodeType to know which responseMode is set const nodeType = workflow.nodeTypes.getByNameAndVersion( diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 1b1f624bb8c33..ff1d1527cadeb 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -17,6 +17,7 @@ import type { WorkflowExecuteMode, IWorkflowExecutionDataProcess, IWorkflowBase, + IDestinationNode, } from 'n8n-workflow'; import { SubworkflowOperationError, Workflow, createRunExecutionData } from 'n8n-workflow'; @@ -92,26 +93,26 @@ export class WorkflowExecutionService { } async executeManually( - { - workflowData, - runData, - startNodes, - destinationNode, - dirtyNodeNames, - triggerToStartFrom, - agentRequest, - }: WorkflowRequest.ManualRunPayload, + payload: WorkflowRequest.ManualRunPayload, user: User, pushRef?: string, streamingEnabled?: boolean, httpResponse?: Response, ) { + const { workflowData, startNodes, dirtyNodeNames, triggerToStartFrom, agentRequest } = payload; + let { runData } = payload; + const destinationNode: IDestinationNode | undefined = payload.destinationNode + ? { + nodeName: payload.destinationNode, + mode: 'inclusive', + } + : undefined; const pinData = workflowData.pinData; let pinnedTrigger = this.selectPinnedActivatorStarter( workflowData, startNodes?.map((nodeData) => nodeData.name), pinData, - destinationNode, + destinationNode?.nodeName, ); // TODO: Reverse the order of events, first find out if the execution is @@ -126,7 +127,7 @@ export class WorkflowExecutionService { // here and either create the runData (e.g. scheduler trigger) or wait for // a webhook or event. if (destinationNode) { - if (this.isDestinationNodeATrigger(destinationNode, workflowData)) { + if (this.isDestinationNodeATrigger(destinationNode.nodeName, workflowData)) { runData = undefined; } } diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts index 1d1ca5418d285..d411e762f07bd 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute-process-process-run-execution-data.test.ts @@ -256,7 +256,7 @@ describe('processRunExecutionData', () => { const executionData = createRunExecutionData({ startData: { startNodes: [{ name: node1.name, sourceData: null }], - destinationNode: node1.name, + destinationNode: { nodeName: node1.name, mode: 'inclusive' }, }, executionData: { nodeExecutionStack: [{ data: taskDataConnection, node: node1, source: null }], diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index b22b43e676acf..ee30cc99316ad 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -34,6 +34,7 @@ import type { RelatedExecution, IExecuteFunctions, IDataObject, + IDestinationNode, } from 'n8n-workflow'; import { ApplicationError, @@ -234,7 +235,10 @@ describe('WorkflowExecute', () => { const workflowExecute = new WorkflowExecute(additionalData, executionMode); // ACT - await workflowExecute.run(workflowInstance, trigger, 'node1'); + await workflowExecute.run(workflowInstance, trigger, { + nodeName: 'node1', + mode: 'inclusive', + }); // ASSERT const workflowHooks = runHookSpy.mock.calls.filter( @@ -311,7 +315,10 @@ describe('WorkflowExecute', () => { const workflowExecute = new WorkflowExecute(additionalData, executionMode); // ACT - await workflowExecute.run(workflowInstance, trigger, 'node1'); + await workflowExecute.run(workflowInstance, trigger, { + nodeName: 'node1', + mode: 'inclusive', + }); // ASSERT const workflowHooks = runHookSpy.mock.calls.filter( @@ -461,7 +468,7 @@ describe('WorkflowExecute', () => { [node2.name]: [toITaskData([{ data: { name: node2.name } }])], }; const dirtyNodeNames = [node1.name]; - const destinationNode = node2.name; + const destinationNode: IDestinationNode = { nodeName: node2.name, mode: 'inclusive' }; jest.spyOn(workflowExecute, 'processRunExecutionData').mockImplementationOnce(jest.fn()); @@ -524,7 +531,7 @@ describe('WorkflowExecute', () => { [destination.name]: [toITaskData([{ data: { node: 'destination' } }])], }; const dirtyNodeNames = [set2.name]; - const destinationNode = destination.name; + const destinationNode: IDestinationNode = { nodeName: destination.name, mode: 'inclusive' }; // ACT await workflowExecute.runPartialWorkflow2( @@ -569,7 +576,7 @@ describe('WorkflowExecute', () => { [node2.name]: [toITaskData([{ data: { name: node2.name } }])], }; const dirtyNodeNames: string[] = []; - const destinationNode = node2.name; + const destinationNode: IDestinationNode = { nodeName: node2.name, mode: 'inclusive' }; const processRunExecutionDataSpy = jest .spyOn(workflowExecute, 'processRunExecutionData') @@ -643,13 +650,10 @@ describe('WorkflowExecute', () => { ); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - afterLoop.name, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: afterLoop.name, + mode: 'inclusive', + }); // ASSERT expect(recreateNodeExecutionStackSpy).toHaveBeenNthCalledWith( @@ -696,13 +700,10 @@ describe('WorkflowExecute', () => { const cleanRunDataSpy = jest.spyOn(partialExecutionUtils, 'cleanRunData'); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - node1.name, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: node1.name, + mode: 'inclusive', + }); // ASSERT const subgraph = new DirectedGraph() @@ -752,13 +753,10 @@ describe('WorkflowExecute', () => { const cleanRunDataSpy = jest.spyOn(partialExecutionUtils, 'cleanRunData'); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - destination.name, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: destination.name, + mode: 'inclusive', + }); // ASSERT const subgraph = new DirectedGraph() @@ -808,13 +806,10 @@ describe('WorkflowExecute', () => { .mockImplementationOnce(jest.fn()); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - orphan.name, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: orphan.name, + mode: 'inclusive', + }); // ASSERT expect(processRunExecutionDataSpy).toHaveBeenCalledTimes(1); @@ -886,13 +881,10 @@ describe('WorkflowExecute', () => { .toWorkflow({ ...workflow }); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - tool.name, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: tool.name, + mode: 'inclusive', + }); // ASSERT expect(processRunExecutionDataSpy).toHaveBeenCalledTimes(1); @@ -934,13 +926,10 @@ describe('WorkflowExecute', () => { const processRunExecutionDataSpy = jest.spyOn(workflowExecute, 'processRunExecutionData'); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - destinationNode, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: destinationNode, + mode: 'inclusive', + }); // ASSERT expect(processRunExecutionDataSpy).toHaveBeenCalledTimes(1); @@ -983,13 +972,10 @@ describe('WorkflowExecute', () => { const processRunExecutionDataSpy = jest.spyOn(workflowExecute, 'processRunExecutionData'); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - destinationNode, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: destinationNode, + mode: 'inclusive', + }); // ASSERT expect(processRunExecutionDataSpy).toHaveBeenCalledTimes(1); @@ -1028,13 +1014,10 @@ describe('WorkflowExecute', () => { .mockImplementationOnce(jest.fn()); // ACT - await workflowExecute.runPartialWorkflow2( - workflow, - runData, - pinData, - dirtyNodeNames, - destinationNode, - ); + await workflowExecute.runPartialWorkflow2(workflow, runData, pinData, dirtyNodeNames, { + nodeName: destinationNode, + mode: 'inclusive', + }); // ASSERT expect(processRunExecutionDataSpy).toHaveBeenCalledTimes(1); diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index 0c8753e1170a9..34ee1f52a99da 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -40,6 +40,7 @@ import type { IWorkflowExecutionDataProcess, EngineRequest, EngineResponse, + IDestinationNode, } from 'n8n-workflow'; import { LoggerProxy as Logger, @@ -108,14 +109,14 @@ export class WorkflowExecute { run( workflow: Workflow, startNode?: INode, - destinationNode?: string, + destinationNode?: IDestinationNode, pinData?: IPinData, triggerToStartFrom?: IWorkflowExecutionDataProcess['triggerToStartFrom'], ): PCancelable { this.status = 'running'; // Get the nodes to start workflow execution from - startNode = startNode || workflow.getStartNode(destinationNode); + startNode = startNode || workflow.getStartNode(destinationNode?.nodeName); if (startNode === undefined) { throw new ApplicationError('No node to start the workflow from could be found'); @@ -124,8 +125,10 @@ export class WorkflowExecute { // If a destination node is given we only run the direct parent nodes and no others let runNodeFilter: string[] | undefined; if (destinationNode) { - runNodeFilter = workflow.getParentNodes(destinationNode); - runNodeFilter.push(destinationNode); + runNodeFilter = workflow.getParentNodes(destinationNode.nodeName); + if (destinationNode.mode === 'inclusive') { + runNodeFilter.push(destinationNode.nodeName); + } } // Initialize the data of the start nodes @@ -174,15 +177,15 @@ export class WorkflowExecute { runData: IRunData, pinData: IPinData = {}, dirtyNodeNames: string[] = [], - destinationNodeName: string, + destinationNode: IDestinationNode, agentRequest?: AiAgentRequest, ): PCancelable { - const originalDestination = destinationNodeName; + const originalDestination = { ...destinationNode }; - let destination = workflow.getNode(destinationNodeName); + let destination = workflow.getNode(destinationNode.nodeName); assert.ok( destination, - `Could not find a node with the name ${destinationNodeName} in the workflow.`, + `Could not find a node with the name ${destinationNode.nodeName} in the workflow.`, ); let graph = DirectedGraph.fromWorkflow(workflow); @@ -201,7 +204,8 @@ export class WorkflowExecute { throw new OperationalError('ToolExecutor can not be found'); } destination = toolExecutorNode; - destinationNodeName = toolExecutorNode.name; + // TODO(CAT-1265): Verify that this functionality works as expected. + destinationNode = { nodeName: toolExecutorNode.name, mode: 'inclusive' }; } else { // Edge Case 1: // Support executing a single node that is not connected to a trigger @@ -221,7 +225,7 @@ export class WorkflowExecute { this.status = 'running'; this.runExecutionData = createRunExecutionData({ startData: { - destinationNode: destinationNodeName, + destinationNode, runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name), }, resultData: { @@ -240,14 +244,14 @@ export class WorkflowExecute { } // 1. Find the Trigger - let trigger = findTriggerForPartialExecution(workflow, destinationNodeName, runData); + let trigger = findTriggerForPartialExecution(workflow, destinationNode.nodeName, runData); if (trigger === undefined) { // destination has parents but none of them are triggers, so find the closest // parent node that has run data, and treat that parent as starting point let startNode; - const parentNodes = workflow.getParentNodes(destinationNodeName); + const parentNodes = workflow.getParentNodes(destinationNode.nodeName); for (const nodeName of parentNodes) { if (runData[nodeName]) { @@ -291,7 +295,7 @@ export class WorkflowExecute { this.status = 'running'; this.runExecutionData = createRunExecutionData({ startData: { - destinationNode: destinationNodeName, + destinationNode: destinationNode, originalDestinationNode: originalDestination, runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name), }, @@ -836,7 +840,7 @@ export class WorkflowExecute { workflow: Workflow, inputData: { startNode?: string; - destinationNode?: string; + destinationNode?: IDestinationNode; pinDataNodeNames?: string[]; } = {}, ): IWorkflowIssues | null { @@ -846,8 +850,10 @@ export class WorkflowExecute { if (inputData.destinationNode) { // If a destination node is given we have to check all the nodes // leading up to it - checkNodes = workflow.getParentNodes(inputData.destinationNode); - checkNodes.push(inputData.destinationNode); + checkNodes = workflow.getParentNodes(inputData.destinationNode.nodeName); + if (inputData.destinationNode.mode === 'inclusive') { + checkNodes.push(inputData.destinationNode.nodeName); + } } else if (inputData.startNode) { // If a start node is given we have to check all nodes which // come after it @@ -1343,7 +1349,7 @@ export class WorkflowExecute { // Trigger. const startNode = this.runExecutionData.executionData.nodeExecutionStack.at(0)?.node.name; - let destinationNode: string | undefined; + let destinationNode: IDestinationNode | undefined; if (this.runExecutionData.startData?.destinationNode) { destinationNode = this.runExecutionData.startData.destinationNode; } @@ -1959,11 +1965,7 @@ export class WorkflowExecute { break; } - if ( - this.runExecutionData.startData && - this.runExecutionData.startData.destinationNode && - this.runExecutionData.startData.destinationNode === executionNode.name - ) { + if (this.runExecutionData?.startData?.destinationNode?.nodeName === executionNode.name) { // Before stopping, make sure we are executing hooks so // That frontend is notified for example for manual executions. await hooks.runHook('nodeExecuteAfter', [ diff --git a/packages/frontend/editor-ui/src/app/types/externalHooks.ts b/packages/frontend/editor-ui/src/app/types/externalHooks.ts index 0173f4868fc01..b9758e2e1b664 100644 --- a/packages/frontend/editor-ui/src/app/types/externalHooks.ts +++ b/packages/frontend/editor-ui/src/app/types/externalHooks.ts @@ -3,6 +3,7 @@ import type { ExecutionError, GenericValue, IConnections, + IDestinationNode, INodeProperties, INodeTypeDescription, ITelemetryTrackProperties, @@ -64,7 +65,7 @@ interface OutputModeChangedEventData { } interface ExecutionFinishedEventData { runDataExecutedStartData: - | { destinationNode?: string | undefined; runNodeFilter?: string[] | undefined } + | { destinationNode?: IDestinationNode; runNodeFilter?: string[] | undefined } | undefined; nodeName?: string; errorMessage: string; diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index bebaf0c854b43..3d49781fadc92 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -2569,8 +2569,13 @@ export interface IWorkflowCredentials { }; } +export interface IDestinationNode { + nodeName: string; + mode: 'inclusive' | 'exclusive'; +} + export interface IWorkflowExecutionDataProcess { - destinationNode?: string; + destinationNode?: IDestinationNode; restartExecutionId?: string; executionMode: WorkflowExecuteMode; /** diff --git a/packages/workflow/src/run-execution-data-factory.ts b/packages/workflow/src/run-execution-data-factory.ts index 788aa5d913272..525109288daa1 100644 --- a/packages/workflow/src/run-execution-data-factory.ts +++ b/packages/workflow/src/run-execution-data-factory.ts @@ -13,7 +13,7 @@ import type { INode, } from './interfaces'; import type { IRunExecutionData } from './run-execution-data/run-execution-data'; -import type { IRunExecutionDataV0 } from './run-execution-data/run-execution-data.v0'; +import type { IRunExecutionDataV1 } from './run-execution-data/run-execution-data.v1'; export interface CreateFullRunExecutionDataOptions { startData?: { @@ -55,7 +55,7 @@ export function createRunExecutionData( options: CreateFullRunExecutionDataOptions = {}, ): IRunExecutionData { return { - version: 0, + version: 1, startData: options.startData ?? {}, resultData: { error: options.resultData?.error, @@ -82,7 +82,7 @@ export function createRunExecutionData( waitTill: options.waitTill, manualData: options.manualData, pushRef: options.pushRef, - } satisfies IRunExecutionDataV0 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type. + } satisfies IRunExecutionDataV1 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type. } /** @@ -92,11 +92,11 @@ export function createRunExecutionData( */ export function createEmptyRunExecutionData(): IRunExecutionData { return { - version: 0, + version: 1, resultData: { runData: {}, }, - } satisfies IRunExecutionDataV0 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type. + } satisfies IRunExecutionDataV1 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type. } /** @@ -109,9 +109,12 @@ export function createEmptyRunExecutionData(): IRunExecutionData { */ export function createErrorExecutionData(node: INode, error: ExecutionError): IRunExecutionData { return { - version: 0, + version: 1, startData: { - destinationNode: node.name, + destinationNode: { + nodeName: node.name, + mode: 'inclusive', + }, runNodeFilter: [node.name], }, executionData: { @@ -153,5 +156,5 @@ export function createErrorExecutionData(node: INode, error: ExecutionError): IR error, lastNodeExecuted: node.name, }, - } satisfies IRunExecutionDataV0 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type. + } satisfies IRunExecutionDataV1 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type. } diff --git a/packages/workflow/src/run-execution-data/run-execution-data.ts b/packages/workflow/src/run-execution-data/run-execution-data.ts index 39c0142e62085..0e931d0b32752 100644 --- a/packages/workflow/src/run-execution-data/run-execution-data.ts +++ b/packages/workflow/src/run-execution-data/run-execution-data.ts @@ -20,6 +20,6 @@ const __brand = Symbol('brand'); /** * Current version of IRunExecutionData. */ -export type IRunExecutionData = IRunExecutionDataV0 & { +export type IRunExecutionData = IRunExecutionDataV1 & { [__brand]: 'Use createRunExecutionData factory instead of constructing manually'; }; diff --git a/packages/workflow/src/run-execution-data/run-execution-data.v1.ts b/packages/workflow/src/run-execution-data/run-execution-data.v1.ts index ded645b6d2090..9678f83cd45ae 100644 --- a/packages/workflow/src/run-execution-data/run-execution-data.v1.ts +++ b/packages/workflow/src/run-execution-data/run-execution-data.v1.ts @@ -1,5 +1,6 @@ import type { ExecutionError, + IDestinationNode, IExecuteContextData, IExecuteData, IExecutionContext, @@ -19,14 +20,8 @@ export interface IRunExecutionDataV1 { version: 1; startData?: { startNodes?: StartNodeData[]; - destinationNode?: { - nodeName: string; - mode: 'inclusive' | 'exclusive'; - }; - originalDestinationNode?: { - nodeName: string; - mode: 'inclusive' | 'exclusive'; - }; + destinationNode?: IDestinationNode; + originalDestinationNode?: IDestinationNode; runNodeFilter?: string[]; }; resultData: { diff --git a/packages/workflow/test/run-execution-data-factory.test.ts b/packages/workflow/test/run-execution-data-factory.test.ts index bdd4530394180..dc9baee521cec 100644 --- a/packages/workflow/test/run-execution-data-factory.test.ts +++ b/packages/workflow/test/run-execution-data-factory.test.ts @@ -14,7 +14,7 @@ describe('RunExecutionDataFactory', () => { const result = createRunExecutionData(); expect(result).toEqual({ - version: 0, + version: 1, startData: {}, manualData: undefined, parentExecution: undefined, @@ -42,7 +42,7 @@ describe('RunExecutionDataFactory', () => { const options = { startData: { startNodes: [{ name: 'Start', sourceData: { previousNode: 'Previous' } }], - destinationNode: 'End', + destinationNode: { nodeName: 'End', mode: 'inclusive' }, }, resultData: { runData: { testNode: [] }, @@ -113,7 +113,7 @@ describe('RunExecutionDataFactory', () => { const result = createEmptyRunExecutionData(); expect(result).toEqual({ - version: 0, + version: 1, resultData: { runData: {}, }, From 3cbf6b5f8c3b8ab17b873d2744a6ff373c5319ca Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 14:49:31 +0100 Subject: [PATCH 02/28] test fixes --- .../manual-execution.service.test.ts | 27 +++++++++++++------ .../__tests__/test-runner.service.ee.test.ts | 15 ++++++++--- .../__tests__/telemetry-event-relay.test.ts | 2 +- .../events/relays/telemetry.event-relay.ts | 2 +- .../execution-lifecycle-hooks.test.ts | 5 +++- .../__tests__/webhook-helpers.test.ts | 5 +++- .../workflow-execution.service.test.ts | 4 +-- .../__tests__/workflow-execute.test.ts | 2 +- .../src/execution-engine/workflow-execute.ts | 2 +- 9 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/__tests__/manual-execution.service.test.ts b/packages/cli/src/__tests__/manual-execution.service.test.ts index 2e59a7c0b9169..d3c9a18d166cd 100644 --- a/packages/cli/src/__tests__/manual-execution.service.test.ts +++ b/packages/cli/src/__tests__/manual-execution.service.test.ts @@ -13,6 +13,7 @@ import type { IWaitingForExecution, IWaitingForExecutionSource, INodeExecutionData, + IDestinationNode, } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; @@ -190,6 +191,10 @@ describe('ManualExecutionService', () => { const startNodeName = 'startNode'; const triggerNodeName = 'triggerNode'; const destinationNodeName = 'destinationNode'; + const destinationNode: IDestinationNode = { + nodeName: destinationNodeName, + mode: 'inclusive', + }; const data = mock({ triggerToStartFrom: { @@ -198,7 +203,7 @@ describe('ManualExecutionService', () => { }, startNodes: [{ name: startNodeName }], executionMode: 'manual', - destinationNode: { nodeName: destinationNodeName, mode: 'inclusive' }, + destinationNode, }); const startNode = mock({ name: startNodeName }); @@ -225,9 +230,7 @@ describe('ManualExecutionService', () => { additionalData, data.executionMode, expect.objectContaining({ - startData: { - destinationNode: destinationNodeName, - }, + startData: { destinationNode }, resultData: expect.any(Object), executionData: expect.any(Object), }), @@ -379,12 +382,16 @@ describe('ManualExecutionService', () => { const mockRunData = { node1: [{ data: { main: [[{ json: {} }]] } }] }; const dirtyNodeNames = ['node2', 'node3']; const destinationNodeName = 'destinationNode'; + const destinationNode: IDestinationNode = { + nodeName: destinationNodeName, + mode: 'inclusive', + }; const data = mock({ executionMode: 'manual', runData: mockRunData, startNodes: [{ name: 'node1' }], dirtyNodeNames, - destinationNode: { nodeName: destinationNodeName, mode: 'inclusive' }, + destinationNode, }); const workflow = mock({ @@ -411,7 +418,7 @@ describe('ManualExecutionService', () => { expect(mockRunPartialWorkflow2).toHaveBeenCalled(); expect(mockRunPartialWorkflow2.mock.calls[0][0]).toBe(workflow); - expect(mockRunPartialWorkflow2.mock.calls[0][4]).toBe(destinationNodeName); + expect(mockRunPartialWorkflow2.mock.calls[0][4]).toEqual(destinationNode); }); it('should validate nodes exist before execution', async () => { @@ -489,11 +496,15 @@ describe('ManualExecutionService', () => { it('should call runPartialWorkflow2 with runData and empty startNodes', async () => { const mockRunData = { nodeA: [{ data: { main: [[{ json: { value: 'test' } }]] } }] }; const destinationNodeName = 'nodeB'; + const destinationNode: IDestinationNode = { + nodeName: destinationNodeName, + mode: 'inclusive', + }; const data = mock({ executionMode: 'manual', runData: mockRunData, startNodes: [], - destinationNode: { nodeName: destinationNodeName, mode: 'inclusive' }, + destinationNode, pinData: {}, dirtyNodeNames: [], agentRequest: undefined, @@ -526,7 +537,7 @@ describe('ManualExecutionService', () => { mockRunData, data.pinData, data.dirtyNodeNames, - destinationNodeName, + destinationNode, data.agentRequest, ); }); diff --git a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts index 0b79a85c96aa2..bce903dc82946 100644 --- a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts +++ b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts @@ -458,7 +458,10 @@ describe('TestRunnerService', () => { const runCallArg = workflowRunner.run.mock.calls[0][0]; // Verify it has the correct structure - expect(runCallArg).toHaveProperty('destinationNode', triggerNodeName); + expect(runCallArg).toHaveProperty('destinationNode', { + nodeName: triggerNodeName, + mode: 'inclusive', + }); expect(runCallArg).toHaveProperty('executionMode', 'manual'); expect(runCallArg).toHaveProperty('workflowData.settings.saveManualExecutions', false); expect(runCallArg).toHaveProperty('workflowData.settings.saveDataErrorExecution', 'none'); @@ -531,7 +534,10 @@ describe('TestRunnerService', () => { const runCallArg = workflowRunner.run.mock.calls[0][0]; // Verify it has the correct structure - expect(runCallArg).toHaveProperty('destinationNode', triggerNodeName); + expect(runCallArg).toHaveProperty('destinationNode', { + nodeName: triggerNodeName, + mode: 'inclusive', + }); expect(runCallArg).toHaveProperty('executionMode', 'manual'); expect(runCallArg).toHaveProperty('workflowData.settings.saveManualExecutions', false); expect(runCallArg).toHaveProperty('workflowData.settings.saveDataErrorExecution', 'none'); @@ -546,7 +552,10 @@ describe('TestRunnerService', () => { // But executionData itself should still exist with startData and manualData expect(runCallArg).toHaveProperty('executionData'); expect(runCallArg.executionData).toBeDefined(); - expect(runCallArg).toHaveProperty('executionData.startData.destinationNode', triggerNodeName); + expect(runCallArg).toHaveProperty('executionData.startData.destinationNode', { + nodeName: triggerNodeName, + mode: 'inclusive', + }); expect(runCallArg).toHaveProperty('executionData.manualData.userId', metadata.userId); expect(runCallArg).toHaveProperty( 'executionData.manualData.triggerToStartFrom.name', diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 9141fd1317531..17a0752108de0 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1599,7 +1599,7 @@ describe('TelemetryEventRelay', () => { nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }], }, startData: { - destinationNode: 'OpenAI', + destinationNode: { nodeName: 'OpenAI', mode: 'inclusive' }, runNodeFilter: ['OpenAI'], }, resultData: { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 3be50be21809d..36ec566882dc9 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -787,7 +787,7 @@ export class TelemetryEventRelay extends EventRelay { manualExecEventProperties.is_managed = credential.isManaged; } } - + console.log(runData.data.startData); const telemetryPayload: ITelemetryTrackProperties = { ...manualExecEventProperties, node_type: TelemetryHelpers.getNodeTypeForName( diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 1bb39fbc90adb..a7e07778a3e79 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -183,7 +183,10 @@ describe('Execution Lifecycle Hooks', () => { userId: expectedUserId, }); - expect(successfulRunWithRewiredDestination.data.startData?.destinationNode).toBe(nodeName); + expect(successfulRunWithRewiredDestination.data.startData?.destinationNode).toEqual({ + nodeName, + mode: 'inclusive', + }); expect( successfulRunWithRewiredDestination.data.startData?.originalDestinationNode, ).toBeUndefined(); diff --git a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts index feb840596fdb7..863319b13b47f 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts @@ -399,7 +399,10 @@ describe('prepareExecutionData', () => { { nodeName: 'targetNode', mode: 'inclusive' }, ); - expect(runExecutionData.startData?.destinationNode).toBe('targetNode'); + expect(runExecutionData.startData?.destinationNode).toEqual({ + nodeName: 'targetNode', + mode: 'inclusive', + }); }); test('should update execution data with execution data merge', () => { diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index a8f80602906b7..dabf4c77d8d55 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -163,7 +163,7 @@ describe('WorkflowExecutionService', () => { const result = await workflowExecutionService.executeManually(runPayload, user); expect(workflowRunner.run).toHaveBeenCalledWith({ - destinationNode: runPayload.destinationNode, + destinationNode: { nodeName: runPayload.destinationNode, mode: 'inclusive' }, executionMode: 'manual', runData: undefined, pinData: runPayload.workflowData.pinData, @@ -213,7 +213,7 @@ describe('WorkflowExecutionService', () => { const result = await workflowExecutionService.executeManually(runPayload, user); expect(workflowRunner.run).toHaveBeenCalledWith({ - destinationNode: runPayload.destinationNode, + destinationNode: { nodeName: runPayload.destinationNode, mode: 'inclusive' }, executionMode: 'manual', runData: runPayload.runData, pinData: runPayload.workflowData.pinData, diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index ee30cc99316ad..854ca8b6eea26 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -2588,7 +2588,7 @@ describe('WorkflowExecute', () => { const updatedExecutionData = { data: { - version: 0, + version: 1, startData: { startNodes: [{ name: 'Start', sourceData: null }] }, resultData: { runData: { diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index 34ee1f52a99da..09b499d349b32 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -295,7 +295,7 @@ export class WorkflowExecute { this.status = 'running'; this.runExecutionData = createRunExecutionData({ startData: { - destinationNode: destinationNode, + destinationNode, originalDestinationNode: originalDestination, runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name), }, From a08dcf4371f9afaa2d3306d1dccba974e076823f Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 15:01:20 +0100 Subject: [PATCH 03/28] another test update --- packages/workflow/test/run-execution-data-factory.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/workflow/test/run-execution-data-factory.test.ts b/packages/workflow/test/run-execution-data-factory.test.ts index dc9baee521cec..0b74830e008cc 100644 --- a/packages/workflow/test/run-execution-data-factory.test.ts +++ b/packages/workflow/test/run-execution-data-factory.test.ts @@ -139,7 +139,10 @@ describe('RunExecutionDataFactory', () => { const result = createErrorExecutionData(node, error); - expect(result.startData?.destinationNode).toEqual('TestNode'); + expect(result.startData?.destinationNode).toEqual({ + nodeName: 'TestNode', + mode: 'inclusive', + }); expect(result.startData?.runNodeFilter).toEqual(['TestNode']); expect(result.executionData?.contextData).toEqual({}); From 0475b55396898f99798ccfc2b1ff5671dc8ab51f Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 15:32:40 +0100 Subject: [PATCH 04/28] more test fixes --- .../src/js-task-runner/__tests__/js-task-runner.test.ts | 4 ++-- .../frontend/editor-ui/src/app/stores/workflows.store.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index d8c31cf4f8016..6b19077bbd743 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -455,7 +455,7 @@ describe('JsTaskRunner', () => { }); const helsinkiTimeNow = DateTime.now().setZone('Europe/Helsinki').toSeconds(); - expect(outcome.result).toEqual({ val: expect.closeTo(helsinkiTimeNow, 1) }); + expect(outcome.result).toEqual({ val: expect.closeTo(helsinkiTimeNow, 0) }); }); it('should use the default timezone', async () => { @@ -472,7 +472,7 @@ describe('JsTaskRunner', () => { }); const helsinkiTimeNow = DateTime.now().setZone('Europe/Helsinki').toSeconds(); - expect(outcome.result).toEqual({ val: expect.closeTo(helsinkiTimeNow, 1) }); + expect(outcome.result).toEqual({ val: expect.closeTo(helsinkiTimeNow, 0) }); }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts index 60dbd77a348c0..69fe16adca043 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts @@ -1192,7 +1192,7 @@ describe('useWorkflowsStore', () => { expect(workflowsStore.workflowExecutionData).toEqual({ ...executionResponse, data: { - version: 0, + version: 1, resultData: { lastNodeExecuted: 'When clicking ‘Execute workflow’', runData: { @@ -1224,7 +1224,7 @@ describe('useWorkflowsStore', () => { expect(workflowsStore.workflowExecutionData).toEqual({ ...executionResponse, data: { - version: 0, + version: 1, resultData: { lastNodeExecuted: 'Edit Fields', runData: { From d4db7c572fa8263f3411cb5d73adc328f01f605a Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 16:42:44 +0100 Subject: [PATCH 05/28] migrate v0 to v1 RunExecutionData at the repository layer --- .../src/repositories/execution.repository.ts | 7 +- .../repositories/execution.repository.test.ts | 47 ++++++++++ packages/workflow/src/index.ts | 1 + .../run-execution-data/run-execution-data.ts | 13 ++- .../run-execution-data.test.ts | 85 +++++++++++++++++++ 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 packages/workflow/test/run-execution-data/run-execution-data.test.ts diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 948ed02fd2f81..84587acc72add 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -28,11 +28,12 @@ import type { AnnotationVote, ExecutionStatus, ExecutionSummary, - IRunExecutionData, + IRunExecutionDataAll, } from 'n8n-workflow'; import { createEmptyRunExecutionData, ManualExecutionCancelledError, + migrateRunExecutionData, UnexpectedError, } from 'n8n-workflow'; @@ -217,7 +218,7 @@ export class ExecutionRepository extends Repository { const { executionData, metadata, ...rest } = execution; return { ...rest, - data: parse(executionData.data) as IRunExecutionData, + data: migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll), workflowData: executionData.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), } as IExecutionResponse; @@ -343,7 +344,7 @@ export class ExecutionRepository extends Repository { ...rest, ...(options?.includeData && { data: options?.unflattenData - ? (parse(executionData.data) as IRunExecutionData) + ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) : executionData.data, workflowData: executionData?.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index d72c0468a2e78..439fc160d91e4 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -2,6 +2,8 @@ import { createWorkflow, testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { ExecutionDataRepository, ExecutionRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { IRunExecutionData, IRunExecutionDataAll } from 'n8n-workflow'; +import { stringify } from 'flatted'; describe('ExecutionRepository', () => { beforeAll(async () => { @@ -85,4 +87,49 @@ describe('ExecutionRepository', () => { expect(executionEntities).toBeEmptyArray(); }); }); + + describe('V0 to V1 migration', () => { + it('should automatically migrate IRunExecutionDataV0 to V1 when reading', async () => { + const executionRepo = Container.get(ExecutionRepository); + const executionDataRepo = Container.get(ExecutionDataRepository); + const workflow = await createWorkflow({ settings: { executionOrder: 'v1' } }); + + // Create V0 data with string destinationNode + const v0Data: IRunExecutionDataAll = { + version: 0, + startData: { destinationNode: 'TestNode' }, + resultData: { runData: {} }, + }; + + // Insert execution with V0 data directly into the database + const { identifiers } = await executionRepo.insert({ + workflowId: workflow.id, + mode: 'manual', + startedAt: new Date(), + status: 'success', + finished: true, + createdAt: new Date(), + }); + const executionId = identifiers[0].id as string; + await executionDataRepo.insert({ + executionId, + workflowData: { id: workflow.id, connections: {}, nodes: [], name: workflow.name }, + data: stringify(v0Data), + }); + + // Read the execution back + const execution = await executionRepo.findSingleExecution(executionId, { + includeData: true, + unflattenData: true, + }); + + // Verify that the data was migrated to V1 + const data = execution?.data as IRunExecutionData; + expect(data.version).toBe(1); + expect(data.startData?.destinationNode).toEqual({ + nodeName: 'TestNode', + mode: 'inclusive', + }); + }); + }); }); diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index dff857e1bbd97..02a81d184584e 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -30,6 +30,7 @@ export * from './type-validation'; export * from './result'; export * from './schemas'; export type * from './run-execution-data/run-execution-data'; +export { migrateRunExecutionData } from './run-execution-data/run-execution-data'; export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers }; export { isObjectEmpty, diff --git a/packages/workflow/src/run-execution-data/run-execution-data.ts b/packages/workflow/src/run-execution-data/run-execution-data.ts index 0e931d0b32752..9153afb3a7379 100644 --- a/packages/workflow/src/run-execution-data/run-execution-data.ts +++ b/packages/workflow/src/run-execution-data/run-execution-data.ts @@ -6,7 +6,7 @@ */ import type { IRunExecutionDataV0 } from './run-execution-data.v0'; -import type { IRunExecutionDataV1 } from './run-execution-data.v1'; +import { runExecutionDataV0ToV1, type IRunExecutionDataV1 } from './run-execution-data.v1'; /** * All the versions of the interface. @@ -23,3 +23,14 @@ const __brand = Symbol('brand'); export type IRunExecutionData = IRunExecutionDataV1 & { [__brand]: 'Use createRunExecutionData factory instead of constructing manually'; }; + +export function migrateRunExecutionData(data: IRunExecutionDataAll): IRunExecutionData { + switch (data.version) { + case 0: + case undefined: // Missing version means version 0 + data = runExecutionDataV0ToV1(data); + // Fall through to subsequent versions as they're added. + } + // NOTE: it's safe to assert here because we have handled all previous versions. + return data as IRunExecutionData; +} diff --git a/packages/workflow/test/run-execution-data/run-execution-data.test.ts b/packages/workflow/test/run-execution-data/run-execution-data.test.ts new file mode 100644 index 0000000000000..a5f44f472cf4d --- /dev/null +++ b/packages/workflow/test/run-execution-data/run-execution-data.test.ts @@ -0,0 +1,85 @@ +import { migrateRunExecutionData } from '../../src/run-execution-data/run-execution-data'; +import type { IRunExecutionDataV0 } from '../../src/run-execution-data/run-execution-data.v0'; +import type { IRunExecutionDataV1 } from '../../src/run-execution-data/run-execution-data.v1'; + +describe('migrateRunExecutionData', () => { + it('should migrate IRunExecutionDataV0 to V1', () => { + const v0Data: IRunExecutionDataV0 = { + version: 0, + startData: { + startNodes: [], + destinationNode: 'TestNode', + originalDestinationNode: 'OriginalTestNode', + runNodeFilter: ['filter1'], + }, + resultData: { + runData: {}, + lastNodeExecuted: 'LastNode', + metadata: { key: 'value' }, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: null, + }, + validateSignature: true, + pushRef: 'test-ref', + }; + + const result = migrateRunExecutionData(v0Data); + + expect(result).toEqual({ + ...v0Data, + version: 1, + startData: { + ...v0Data.startData, + destinationNode: { + nodeName: 'TestNode', + mode: 'inclusive', + }, + originalDestinationNode: { + nodeName: 'OriginalTestNode', + mode: 'inclusive', + }, + }, + }); + }); + + it('should return V1 data unchanged (no-op)', () => { + const v1Data: IRunExecutionDataV1 = { + version: 1, + startData: { + startNodes: [], + destinationNode: { + nodeName: 'TestNode', + mode: 'exclusive', + }, + originalDestinationNode: { + nodeName: 'OriginalTestNode', + mode: 'inclusive', + }, + runNodeFilter: ['filter1'], + }, + resultData: { + runData: {}, + lastNodeExecuted: 'LastNode', + metadata: { key: 'value' }, + }, + executionData: { + contextData: {}, + nodeExecutionStack: [], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: null, + }, + validateSignature: true, + pushRef: 'test-ref', + }; + + const result = migrateRunExecutionData(v1Data); + + expect(result).toEqual(v1Data); + }); +}); From 813513fc671f4dd2a5b001f87d97dcd64d9d7e4e Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 16:50:52 +0100 Subject: [PATCH 06/28] remove console log --- packages/cli/src/events/relays/telemetry.event-relay.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 36ec566882dc9..fc1f75c4c5788 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -787,7 +787,6 @@ export class TelemetryEventRelay extends EventRelay { manualExecEventProperties.is_managed = credential.isManaged; } } - console.log(runData.data.startData); const telemetryPayload: ITelemetryTrackProperties = { ...manualExecEventProperties, node_type: TelemetryHelpers.getNodeTypeForName( From 6954d2f3b83aef77e2e7023e752b3c181ce6dd57 Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 16:53:26 +0100 Subject: [PATCH 07/28] better test name --- .../database/repositories/execution.repository.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index 439fc160d91e4..866bb951a6864 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -88,7 +88,7 @@ describe('ExecutionRepository', () => { }); }); - describe('V0 to V1 migration', () => { + describe('run execution data migration', () => { it('should automatically migrate IRunExecutionDataV0 to V1 when reading', async () => { const executionRepo = Container.get(ExecutionRepository); const executionDataRepo = Container.get(ExecutionDataRepository); From db583676510b81b9b4204c01cb808680c2711a31 Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 19:28:31 +0100 Subject: [PATCH 08/28] small cleanup --- .../db/src/repositories/execution.repository.ts | 11 +++++++---- packages/workflow/src/index.ts | 3 +-- .../src/run-execution-data/run-execution-data.ts | 16 +++++++++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 84587acc72add..2c702318043ec 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -218,7 +218,9 @@ export class ExecutionRepository extends Repository { const { executionData, metadata, ...rest } = execution; return { ...rest, - data: migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll), + data: executionData.data + ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) + : undefined, workflowData: executionData.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), } as IExecutionResponse; @@ -343,9 +345,10 @@ export class ExecutionRepository extends Repository { return { ...rest, ...(options?.includeData && { - data: options?.unflattenData - ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) - : executionData.data, + data: + options?.unflattenData && executionData.data + ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) + : executionData.data, workflowData: executionData?.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), }), diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 02a81d184584e..e5bccb95ddde7 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -29,8 +29,7 @@ export * from './versioned-node-type'; export * from './type-validation'; export * from './result'; export * from './schemas'; -export type * from './run-execution-data/run-execution-data'; -export { migrateRunExecutionData } from './run-execution-data/run-execution-data'; +export * from './run-execution-data/run-execution-data'; export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers }; export { isObjectEmpty, diff --git a/packages/workflow/src/run-execution-data/run-execution-data.ts b/packages/workflow/src/run-execution-data/run-execution-data.ts index 9153afb3a7379..eb4e5fabb46bf 100644 --- a/packages/workflow/src/run-execution-data/run-execution-data.ts +++ b/packages/workflow/src/run-execution-data/run-execution-data.ts @@ -24,13 +24,23 @@ export type IRunExecutionData = IRunExecutionDataV1 & { [__brand]: 'Use createRunExecutionData factory instead of constructing manually'; }; +function isRunExecutionDataV1(data: IRunExecutionDataAll): data is IRunExecutionDataV1 { + return data.version === 1; +} + export function migrateRunExecutionData(data: IRunExecutionDataAll): IRunExecutionData { - switch (data.version) { + switch (data?.version) { case 0: case undefined: // Missing version means version 0 data = runExecutionDataV0ToV1(data); // Fall through to subsequent versions as they're added. } - // NOTE: it's safe to assert here because we have handled all previous versions. - return data as IRunExecutionData; + + if (!isRunExecutionDataV1(data)) { + throw new Error( + `Unsupported IRunExecutionData version: ${(data as { version?: number }).version}`, + ); + } + + return data satisfies IRunExecutionDataV1 as IRunExecutionData; } From 7299fe517bc4a4de1e35f3568d9d944eba9b3b91 Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 19:47:45 +0100 Subject: [PATCH 09/28] test fixes --- packages/@n8n/db/src/repositories/execution.repository.ts | 2 ++ packages/cli/package.json | 2 +- packages/cli/test/integration/shared/db/executions.ts | 2 +- .../cli/test/integration/workflows/workflows.controller.test.ts | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 2c702318043ec..525e96fc40205 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -212,9 +212,11 @@ export class ExecutionRepository extends Repository { const executions = await this.find(queryParams); if (options?.includeData && options?.unflattenData) { + console.log('here'); const [valid, invalid] = separate(executions, (e) => e.executionData !== null); this.reportInvalidExecutions(invalid); return valid.map((execution) => { + console.log(execution); const { executionData, metadata, ...rest } = execution; return { ...rest, diff --git a/packages/cli/package.json b/packages/cli/package.json index 2705fd255e5b4..ee4d0649d0302 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,7 @@ "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", - "test": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest", + "test": "N8N_LOG_LEVEL=debug DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest", "test:unit": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.unit.js", "test:integration": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js", "test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch", diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index efa9100829309..5f6b05b254f9a 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -69,7 +69,7 @@ export async function createExecution( } await Container.get(ExecutionDataRepository).save({ - data: data ?? '[]', + data: data ?? '', workflowData: workflow ?? {}, executionId: execution.id, }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 87868a91728e7..10dcd3b639975 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -3114,7 +3114,7 @@ describe('DELETE /workflows/:workflowId', () => { }); describe('GET /workflows/:workflowId/executions/last-successful', () => { - test('should return the last successful execution', async () => { + test.only('should return the last successful execution', async () => { const workflow = await createWorkflow({}, owner); const { createSuccessfulExecution } = await import('../shared/db/executions'); From a066e56f412cca2ab6766ea842d99e0a0b217522 Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 19:48:05 +0100 Subject: [PATCH 10/28] undo debug loggin --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index ee4d0649d0302..2705fd255e5b4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,7 @@ "start": "run-script-os", "start:default": "cd bin && ./n8n", "start:windows": "cd bin && n8n", - "test": "N8N_LOG_LEVEL=debug DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest", + "test": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest", "test:unit": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.unit.js", "test:integration": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js", "test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch", From b23178f8e6db849d63420f58d2c4fe44739bb91e Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 19:48:36 +0100 Subject: [PATCH 11/28] remove console logs --- packages/@n8n/db/src/repositories/execution.repository.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 525e96fc40205..2c702318043ec 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -212,11 +212,9 @@ export class ExecutionRepository extends Repository { const executions = await this.find(queryParams); if (options?.includeData && options?.unflattenData) { - console.log('here'); const [valid, invalid] = separate(executions, (e) => e.executionData !== null); this.reportInvalidExecutions(invalid); return valid.map((execution) => { - console.log(execution); const { executionData, metadata, ...rest } = execution; return { ...rest, From c97b305a06ec8a2b207ca9f4d61dd4882140e22e Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 20:05:33 +0100 Subject: [PATCH 12/28] remove test.only --- .../cli/test/integration/workflows/workflows.controller.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 10dcd3b639975..87868a91728e7 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -3114,7 +3114,7 @@ describe('DELETE /workflows/:workflowId', () => { }); describe('GET /workflows/:workflowId/executions/last-successful', () => { - test.only('should return the last successful execution', async () => { + test('should return the last successful execution', async () => { const workflow = await createWorkflow({}, owner); const { createSuccessfulExecution } = await import('../shared/db/executions'); From 4bee97aa89220385baaf236a8891f15895301d1c Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 20:32:08 +0100 Subject: [PATCH 13/28] test fix --- .../__tests__/execution-recovery.service.integration.test.ts | 1 - packages/cli/src/executions/execution-recovery.service.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts index 03ca6a4922749..ace7e88b4ad13 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts @@ -229,7 +229,6 @@ describe('ExecutionRecoveryService', () => { const execution = await createExecution( { status: 'success', - data: stringify(undefined), // saved execution but likely crashed while saving high-volume data }, workflow, ); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 284bc59d3c348..b156b1f79bf11 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -84,7 +84,7 @@ export class ExecutionRecoveryService { return null; } - const runExecutionData = execution.data ?? { resultData: { runData: {} } }; + const runExecutionData = execution.data || { resultData: { runData: {} } }; let lastNodeRunTimestamp: DateTime | undefined; From 0c7222d752e75ecdc9ca251492b520c94b1e292d Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Fri, 21 Nov 2025 20:51:58 +0100 Subject: [PATCH 14/28] more fixes --- .../@n8n/db/src/repositories/execution.repository.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 2c702318043ec..3e120bf566935 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -342,6 +342,12 @@ export class ExecutionRepository extends Repository { }); } + console.log(executionData); + + if (executionData.data) { + console.log('truthy'); + } + return { ...rest, ...(options?.includeData && { @@ -808,7 +814,8 @@ export class ExecutionRepository extends Repository { async stopDuringRun(execution: IExecutionResponse) { const error = new ManualExecutionCancelledError(execution.id); - execution.data ??= createEmptyRunExecutionData(); + execution.data = execution.data || createEmptyRunExecutionData(); + execution.data.resultData.error = { ...error, message: error.message, From 5bd6cc6a365f7e277847018a37f94dd15fc0ad74 Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Sun, 23 Nov 2025 13:38:13 +0100 Subject: [PATCH 15/28] remove extra console log --- .../@n8n/db/src/repositories/execution.repository.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 3e120bf566935..cb2386755fd80 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -342,19 +342,13 @@ export class ExecutionRepository extends Repository { }); } - console.log(executionData); - - if (executionData.data) { - console.log('truthy'); - } - return { ...rest, ...(options?.includeData && { data: - options?.unflattenData && executionData.data + options?.unflattenData && executionData?.data ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) - : executionData.data, + : executionData?.data, workflowData: executionData?.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), }), From 3f1a3e5dfccb1d446198c02a71b749298e71d0ad Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Sun, 23 Nov 2025 13:50:05 +0100 Subject: [PATCH 16/28] fix test data --- .../src/repositories/__tests__/execution.repository.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts index 81c2398ae7135..43b7abb38e44a 100644 --- a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts @@ -193,12 +193,12 @@ describe('ExecutionRepository', () => { const mockEntities = [ { id: '1', - executionData: { data: '[]' }, + executionData: { data: '' }, metadata: [], }, { id: '2', - executionData: { data: '[]' }, + executionData: { data: '' }, metadata: [], }, ]; From c031684f5878b02d84a949c68a176c93b061847c Mon Sep 17 00:00:00 2001 From: mfsiega <93014743+mfsiega@users.noreply.github.com> Date: Sun, 23 Nov 2025 14:08:50 +0100 Subject: [PATCH 17/28] test fix (#22192) --- packages/nodes-base/nodes/Form/test/utils.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index a6e8fb8ddc9fc..db13f95b048eb 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -1438,7 +1438,6 @@ describe('prepareFormReturnItem', () => { expect(DateTime.fromFormat).not.toHaveBeenCalled(); expect(result.json['Date Field']).toBe('2023-04-01'); - expect(DateTime.fromFormat).toHaveBeenCalledWith('2023-04-01', 'yyyy-mm-dd'); }); it('should handle multiselect fields', async () => { From acd3d2a21f4082b675bf327d9990df4bcbbdf8d2 Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Mon, 24 Nov 2025 10:10:27 +0100 Subject: [PATCH 18/28] handle serialized undefined properly --- .../src/repositories/execution.repository.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index cb2386755fd80..95c073a644358 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -28,6 +28,7 @@ import type { AnnotationVote, ExecutionStatus, ExecutionSummary, + IRunExecutionData, IRunExecutionDataAll, } from 'n8n-workflow'; import { @@ -218,9 +219,10 @@ export class ExecutionRepository extends Repository { const { executionData, metadata, ...rest } = execution; return { ...rest, - data: executionData.data - ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) - : undefined, + data: + executionData.data && executionData.data !== '[]' + ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) + : undefined, workflowData: executionData.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), } as IExecutionResponse; @@ -342,13 +344,22 @@ export class ExecutionRepository extends Repository { }); } + let data: IRunExecutionData | string | undefined; + if (options?.includeData) { + if (options?.unflattenData) { + data = + executionData?.data && executionData?.data !== '[]' + ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) + : undefined; + } else { + data = executionData?.data; + } + } + return { ...rest, ...(options?.includeData && { - data: - options?.unflattenData && executionData?.data - ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) - : executionData?.data, + data, workflowData: executionData?.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), }), From 6c2adaf5488935ea8331762344541495db4ca5f6 Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Mon, 24 Nov 2025 10:43:22 +0100 Subject: [PATCH 19/28] clean up handling of execution run data --- .../__tests__/execution.repository.test.ts | 4 +- .../src/repositories/execution.repository.ts | 103 ++++++++++-------- 2 files changed, 57 insertions(+), 50 deletions(-) diff --git a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts index 43b7abb38e44a..81c2398ae7135 100644 --- a/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/execution.repository.test.ts @@ -193,12 +193,12 @@ describe('ExecutionRepository', () => { const mockEntities = [ { id: '1', - executionData: { data: '' }, + executionData: { data: '[]' }, metadata: [], }, { id: '2', - executionData: { data: '' }, + executionData: { data: '[]' }, metadata: [], }, ]; diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 95c073a644358..2bd458ed8099a 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -198,9 +198,7 @@ export class ExecutionRepository extends Repository { }, ): Promise { if (options?.includeData) { - if (!queryParams.relations) { - queryParams.relations = []; - } + queryParams.relations ??= []; if (Array.isArray(queryParams.relations)) { queryParams.relations.push('executionData', 'metadata'); @@ -212,38 +210,29 @@ export class ExecutionRepository extends Repository { const executions = await this.find(queryParams); - if (options?.includeData && options?.unflattenData) { - const [valid, invalid] = separate(executions, (e) => e.executionData !== null); - this.reportInvalidExecutions(invalid); - return valid.map((execution) => { - const { executionData, metadata, ...rest } = execution; - return { - ...rest, - data: - executionData.data && executionData.data !== '[]' - ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) - : undefined, - workflowData: executionData.workflowData, - customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), - } as IExecutionResponse; - }); - } else if (options?.includeData) { - const [valid, invalid] = separate(executions, (e) => e.executionData !== null); - this.reportInvalidExecutions(invalid); - return valid.map((execution) => { - const { executionData, metadata, ...rest } = execution; - return { - ...rest, - data: execution.executionData.data, - workflowData: execution.executionData.workflowData, - customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), - } as IExecutionFlattedDb; - }); + const [valid, invalid] = separate(executions, (e) => e.executionData !== null); + this.reportInvalidExecutions(invalid); + + if (!options?.includeData) { + // No data to include, so we exclude it and return early. + return executions.map((execution) => { + const { executionData, ...rest } = execution; + return rest; + }) as IExecutionFlattedDb[] | IExecutionResponse[] | IExecutionBase[]; } - return executions.map((execution) => { - const { executionData, ...rest } = execution; - return rest; + return valid.map((execution) => { + const { executionData, metadata, ...rest } = execution; + const data: IRunExecutionData | string | undefined = this.handleExecutionRunData( + executionData.data, + options, + ); + return { + ...rest, + data, + workflowData: executionData.workflowData, + customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), + }; }) as IExecutionFlattedDb[] | IExecutionResponse[] | IExecutionBase[]; } @@ -344,25 +333,26 @@ export class ExecutionRepository extends Repository { }); } - let data: IRunExecutionData | string | undefined; - if (options?.includeData) { - if (options?.unflattenData) { - data = - executionData?.data && executionData?.data !== '[]' - ? migrateRunExecutionData(parse(executionData.data) as IRunExecutionDataAll) - : undefined; - } else { - data = executionData?.data; - } + if (!options?.includeData) { + // Not including run data, so return early. + const { executionData, ...rest } = execution; + return { + ...rest, + ...(options?.includeAnnotation && + serializedAnnotation && { annotation: serializedAnnotation }), + } as IExecutionFlattedDb | IExecutionResponse | IExecutionBase; } + // Include the run data. + const data: IRunExecutionData | string | undefined = this.handleExecutionRunData( + executionData.data, + options, + ); return { ...rest, - ...(options?.includeData && { - data, - workflowData: executionData?.workflowData, - customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), - }), + data, + workflowData: executionData.workflowData, + customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), ...(options?.includeAnnotation && serializedAnnotation && { annotation: serializedAnnotation }), } as IExecutionFlattedDb | IExecutionResponse | IExecutionBase; @@ -1180,4 +1170,21 @@ export class ExecutionRepository extends Repository { return concurrentExecutionsCount; } + + private handleExecutionRunData( + data: string, + options: { unflattenData?: boolean }, + ): IRunExecutionData | string | undefined { + if (options?.unflattenData) { + // Parse the serialized data. + const deserializedData: unknown = parse(data); + // If it parses to an object, migrate and return it. + if (deserializedData) { + return migrateRunExecutionData(deserializedData as IRunExecutionDataAll); + } + return undefined; + } + // Just return the string data as-is. + return data; + } } From 9fa728f06b9714d01b49594429bf3c5b9243961f Mon Sep 17 00:00:00 2001 From: Michael Siega Date: Mon, 24 Nov 2025 10:55:47 +0100 Subject: [PATCH 20/28] revert stringified representation of undefined --- .../__tests__/execution-recovery.service.integration.test.ts | 1 + packages/cli/src/executions/execution-recovery.service.ts | 2 +- packages/cli/test/integration/shared/db/executions.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts index ace7e88b4ad13..03ca6a4922749 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts @@ -229,6 +229,7 @@ describe('ExecutionRecoveryService', () => { const execution = await createExecution( { status: 'success', + data: stringify(undefined), // saved execution but likely crashed while saving high-volume data }, workflow, ); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index b156b1f79bf11..284bc59d3c348 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -84,7 +84,7 @@ export class ExecutionRecoveryService { return null; } - const runExecutionData = execution.data || { resultData: { runData: {} } }; + const runExecutionData = execution.data ?? { resultData: { runData: {} } }; let lastNodeRunTimestamp: DateTime | undefined; diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index 5f6b05b254f9a..efa9100829309 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -69,7 +69,7 @@ export async function createExecution( } await Container.get(ExecutionDataRepository).save({ - data: data ?? '', + data: data ?? '[]', workflowData: workflow ?? {}, executionId: execution.id, }); From d679d036ed4cd5e552d9048083fda644077de30b Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:29:02 +0100 Subject: [PATCH 21/28] docs: add docstring to IDestinationNode.mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify the difference between inclusive and exclusive execution modes for destination nodes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/workflow/src/interfaces.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 3d49781fadc92..e4da478871f08 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -2571,6 +2571,11 @@ export interface IWorkflowCredentials { export interface IDestinationNode { nodeName: string; + /** + * Execution mode for the destination node: + * - 'inclusive': Execute up to and including the destination node + * - 'exclusive': Execute up to but excluding the destination node + */ mode: 'inclusive' | 'exclusive'; } From a3eb84f32809b30effa27a6c22236d539baf24ee Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:29:03 +0100 Subject: [PATCH 22/28] refactor: simplify migrateRunExecutionData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unnecessary isRunExecutionDataV1 type guard - Use direct version check instead - Simplify type assertions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/run-execution-data/run-execution-data.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/workflow/src/run-execution-data/run-execution-data.ts b/packages/workflow/src/run-execution-data/run-execution-data.ts index eb4e5fabb46bf..92cac6e59d301 100644 --- a/packages/workflow/src/run-execution-data/run-execution-data.ts +++ b/packages/workflow/src/run-execution-data/run-execution-data.ts @@ -24,23 +24,19 @@ export type IRunExecutionData = IRunExecutionDataV1 & { [__brand]: 'Use createRunExecutionData factory instead of constructing manually'; }; -function isRunExecutionDataV1(data: IRunExecutionDataAll): data is IRunExecutionDataV1 { - return data.version === 1; -} - export function migrateRunExecutionData(data: IRunExecutionDataAll): IRunExecutionData { - switch (data?.version) { + switch (data.version) { case 0: case undefined: // Missing version means version 0 data = runExecutionDataV0ToV1(data); // Fall through to subsequent versions as they're added. } - if (!isRunExecutionDataV1(data)) { + if (data.version !== 1) { throw new Error( `Unsupported IRunExecutionData version: ${(data as { version?: number }).version}`, ); } - return data satisfies IRunExecutionDataV1 as IRunExecutionData; + return data as IRunExecutionData; } From fda9225beaf3793d225c4e6a43a8fe3504b32987 Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:29:04 +0100 Subject: [PATCH 23/28] refactor: clean up handleExecutionRunData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add default value for options parameter - Remove unnecessary type annotations (inferred by TypeScript) - Use non-optional chaining with default parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../db/src/repositories/execution.repository.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 2bd458ed8099a..25cab4eddcb09 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -223,10 +223,7 @@ export class ExecutionRepository extends Repository { return valid.map((execution) => { const { executionData, metadata, ...rest } = execution; - const data: IRunExecutionData | string | undefined = this.handleExecutionRunData( - executionData.data, - options, - ); + const data = this.handleExecutionRunData(executionData.data, options); return { ...rest, data, @@ -344,10 +341,7 @@ export class ExecutionRepository extends Repository { } // Include the run data. - const data: IRunExecutionData | string | undefined = this.handleExecutionRunData( - executionData.data, - options, - ); + const data = this.handleExecutionRunData(executionData.data, options); return { ...rest, data, @@ -1173,9 +1167,9 @@ export class ExecutionRepository extends Repository { private handleExecutionRunData( data: string, - options: { unflattenData?: boolean }, + options: { unflattenData?: boolean } = {}, ): IRunExecutionData | string | undefined { - if (options?.unflattenData) { + if (options.unflattenData) { // Parse the serialized data. const deserializedData: unknown = parse(data); // If it parses to an object, migrate and return it. From 1af387650641acc6906df97cb925b5309d401ddf Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:29:05 +0100 Subject: [PATCH 24/28] refactor: remove unnecessary type annotation in workflow-execution.service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript can infer the type from the ternary expression. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/cli/src/workflows/workflow-execution.service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index ff1d1527cadeb..c9e35a9aa4969 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -101,11 +101,8 @@ export class WorkflowExecutionService { ) { const { workflowData, startNodes, dirtyNodeNames, triggerToStartFrom, agentRequest } = payload; let { runData } = payload; - const destinationNode: IDestinationNode | undefined = payload.destinationNode - ? { - nodeName: payload.destinationNode, - mode: 'inclusive', - } + const destinationNode = payload.destinationNode + ? { nodeName: payload.destinationNode, mode: 'inclusive' } : undefined; const pinData = workflowData.pinData; let pinnedTrigger = this.selectPinnedActivatorStarter( From 2be7f8637a838115e0e0847d83a7a62535fa568d Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:29:05 +0100 Subject: [PATCH 25/28] chore: add TODO for destinationNode type migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark that destinationNode field needs to be updated from string to IDestinationNode to complete CAT-1265 properly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/cli/src/webhooks/test-webhook-registrations.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/webhooks/test-webhook-registrations.service.ts b/packages/cli/src/webhooks/test-webhook-registrations.service.ts index 454acad599191..eeaa137b467e7 100644 --- a/packages/cli/src/webhooks/test-webhook-registrations.service.ts +++ b/packages/cli/src/webhooks/test-webhook-registrations.service.ts @@ -8,6 +8,7 @@ import { CacheService } from '@/services/cache/cache.service'; export type TestWebhookRegistration = { pushRef?: string; workflowEntity: IWorkflowBase; + // TODO: update this type to close CAT-1265 properly destinationNode?: string; webhook: IWebhookData; }; From c3dbad5591b87d8a4a53742debff8c6225880cc9 Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:29:06 +0100 Subject: [PATCH 26/28] test: use createErrorExecutionData factory in telemetry tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor 3 test cases to use the proper factory function instead of manually constructing error execution data. This makes tests more maintainable and ensures consistent structure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../__tests__/telemetry-event-relay.test.ts | 185 ++++++++---------- 1 file changed, 85 insertions(+), 100 deletions(-) diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 17a0752108de0..84322972636aa 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -14,7 +14,7 @@ import { import { mock } from 'jest-mock-extended'; import { type BinaryDataConfig, InstanceSettings } from 'n8n-core'; import { - createRunExecutionData, + createErrorExecutionData, type INode, type INodesGraphResult, type IRun, @@ -1282,41 +1282,35 @@ describe('TelemetryEventRelay', () => { mock({ type: 'openAiApi', isManaged: false }), ); + const errorNode: INode = { + id: '1', + typeVersion: 1, + name: 'Jira', + type: 'n8n-nodes-base.jira', + parameters: {}, + position: [100, 200], + }; + + const error = new NodeApiError( + errorNode, + { + message: 'Error message', + description: 'Incorrect API key provided', + httpCode: '401', + stack: '', + }, + { + message: 'Error message', + description: 'Error description', + level: 'warning', + functionality: 'regular', + }, + ); + const runData = { status: 'error', mode: 'manual', - data: createRunExecutionData({ - startData: { - destinationNode: { nodeName: 'OpenAI', mode: 'inclusive' }, - runNodeFilter: ['OpenAI'], - }, - resultData: { - runData: {}, - lastNodeExecuted: 'OpenAI', - error: new NodeApiError( - { - id: '1', - typeVersion: 1, - name: 'Jira', - type: 'n8n-nodes-base.jira', - parameters: {}, - position: [100, 200], - }, - { - message: 'Error message', - description: 'Incorrect API key provided', - httpCode: '401', - stack: '', - }, - { - message: 'Error message', - description: 'Error description', - level: 'warning', - functionality: 'regular', - }, - ), - }, - }), + data: createErrorExecutionData(errorNode, error), } as IRun; const nodeGraph: INodesGraphResult = { @@ -1384,41 +1378,35 @@ describe('TelemetryEventRelay', () => { it('should call telemetry.track when manual node execution finished with canceled error message', async () => { sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:owner'); + const errorNode: INode = { + id: '1', + typeVersion: 1, + name: 'Jira', + type: 'n8n-nodes-base.jira', + parameters: {}, + position: [100, 200], + }; + + const error = new NodeApiError( + errorNode, + { + message: 'Error message', + description: 'Incorrect API key provided', + httpCode: '401', + stack: '', + }, + { + message: 'Error message canceled', + description: 'Error description', + level: 'warning', + functionality: 'regular', + }, + ); + const runData = { status: 'error', mode: 'manual', - data: createRunExecutionData({ - startData: { - destinationNode: { nodeName: 'OpenAI', mode: 'inclusive' }, - runNodeFilter: ['OpenAI'], - }, - resultData: { - runData: {}, - lastNodeExecuted: 'OpenAI', - error: new NodeApiError( - { - id: '1', - typeVersion: 1, - name: 'Jira', - type: 'n8n-nodes-base.jira', - parameters: {}, - position: [100, 200], - }, - { - message: 'Error message', - description: 'Incorrect API key provided', - httpCode: '401', - stack: '', - }, - { - message: 'Error message canceled', - description: 'Error description', - level: 'warning', - functionality: 'regular', - }, - ), - }, - }), + data: createErrorExecutionData(errorNode, error), } as IRun; const nodeGraph: INodesGraphResult = { @@ -1591,44 +1579,41 @@ describe('TelemetryEventRelay', () => { mock({ type: 'openAiApi', isManaged: true }), ); + const errorNode: INode = { + id: '1', + typeVersion: 1, + name: 'Jira', + type: 'n8n-nodes-base.jira', + parameters: {}, + position: [100, 200], + }; + + const error = new NodeApiError( + errorNode, + { + message: 'Error message', + description: 'Incorrect API key provided', + httpCode: '401', + stack: '', + }, + { + message: 'Error message', + description: 'Error description', + level: 'warning', + functionality: 'regular', + }, + ); + + const data = createErrorExecutionData(errorNode, error); + // Override executionData to include credentials for this test + data.executionData!.nodeExecutionStack = [ + { node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } } as any, + ]; + const runData = { status: 'error', mode: 'manual', - data: { - executionData: { - nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }], - }, - startData: { - destinationNode: { nodeName: 'OpenAI', mode: 'inclusive' }, - runNodeFilter: ['OpenAI'], - }, - resultData: { - runData: {}, - lastNodeExecuted: 'OpenAI', - error: new NodeApiError( - { - id: '1', - typeVersion: 1, - name: 'Jira', - type: 'n8n-nodes-base.jira', - parameters: {}, - position: [100, 200], - }, - { - message: 'Error message', - description: 'Incorrect API key provided', - httpCode: '401', - stack: '', - }, - { - message: 'Error message', - description: 'Error description', - level: 'warning', - functionality: 'regular', - }, - ), - }, - }, + data, } as unknown as IRun; const nodeGraph: INodesGraphResult = { From 6c96dd24c38177ebc6b49f42dc204a215b11f17a Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:29:07 +0100 Subject: [PATCH 27/28] test: add coverage for destination node exclusive/inclusive modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for both execution order versions (v0 and v1) to verify: - Exclusive mode: destination node does not execute - Inclusive mode: destination node executes Also add tests for checkReadyForExecution to verify: - Exclusive mode: destination node is not checked for issues - Inclusive mode: destination node is checked for issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../__tests__/workflow-execute.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 854ca8b6eea26..4d9a5dce0ceb5 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -292,6 +292,41 @@ describe('WorkflowExecute', () => { expect(nodeHooks).toHaveLength(0); }); + + test("don't execute destination node when mode is exclusive", async () => { + // ARRANGE + const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' }); + const node1 = createNodeData({ name: 'node1' }); + const node2 = createNodeData({ name: 'node2' }); + const workflowInstance = new DirectedGraph() + .addNodes(trigger, node1, node2) + .addConnections({ from: trigger, to: node1 }, { from: node1, to: node2 }) + .toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder } }); + + const additionalData = Helpers.WorkflowExecuteAdditionalData(createDeferredPromise()); + const runHookSpy = jest.spyOn(additionalData.hooks!, 'runHook'); + + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + // ACT + await workflowExecute.run(workflowInstance, trigger, { + nodeName: 'node2', + mode: 'exclusive', + }); + + // ASSERT + const nodeHooks = runHookSpy.mock.calls.filter( + (call) => call[0] === 'nodeExecuteBefore' || call[0] === 'nodeExecuteAfter', + ); + + expect(nodeHooks.map((hook) => ({ name: hook[0], node: hook[1][0] }))).toEqual([ + { name: 'nodeExecuteBefore', node: 'trigger' }, + { name: 'nodeExecuteAfter', node: 'trigger' }, + { name: 'nodeExecuteBefore', node: 'node1' }, + { name: 'nodeExecuteAfter', node: 'node1' }, + // node2 should NOT appear here because mode is 'exclusive' + ]); + }); }); describe('v1 hook order', () => { @@ -372,6 +407,41 @@ describe('WorkflowExecute', () => { expect(nodeHooks).toHaveLength(0); }); + + test("don't execute destination node when mode is exclusive", async () => { + // ARRANGE + const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' }); + const node1 = createNodeData({ name: 'node1' }); + const node2 = createNodeData({ name: 'node2' }); + const workflowInstance = new DirectedGraph() + .addNodes(trigger, node1, node2) + .addConnections({ from: trigger, to: node1 }, { from: node1, to: node2 }) + .toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder } }); + + const additionalData = Helpers.WorkflowExecuteAdditionalData(createDeferredPromise()); + const runHookSpy = jest.spyOn(additionalData.hooks!, 'runHook'); + + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + // ACT + await workflowExecute.run(workflowInstance, trigger, { + nodeName: 'node2', + mode: 'exclusive', + }); + + // ASSERT + const nodeHooks = runHookSpy.mock.calls.filter( + (call) => call[0] === 'nodeExecuteBefore' || call[0] === 'nodeExecuteAfter', + ); + + expect(nodeHooks.map((hook) => ({ name: hook[0], node: hook[1][0] }))).toEqual([ + { name: 'nodeExecuteBefore', node: 'trigger' }, + { name: 'nodeExecuteAfter', node: 'trigger' }, + { name: 'nodeExecuteBefore', node: 'node1' }, + { name: 'nodeExecuteAfter', node: 'node1' }, + // node2 should NOT appear here because mode is 'exclusive' + ]); + }); }); //run tests on json files from specified directory, default 'workflows' @@ -1108,6 +1178,64 @@ describe('WorkflowExecute', () => { expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(2); expect(nodeParamIssuesSpy).toHaveBeenCalled(); }); + + it('should check destination node when mode is inclusive', () => { + const trigger = mock({ name: 'trigger' }); + const node1 = mock({ name: 'node1' }); + const destination = mock({ name: 'destination' }); + + const workflow = new Workflow({ + nodes: [trigger, node1, destination], + connections: { + trigger: { main: [[{ node: 'node1', type: NodeConnectionTypes.Main, index: 0 }]] }, + node1: { main: [[{ node: 'destination', type: NodeConnectionTypes.Main, index: 0 }]] }, + }, + active: false, + nodeTypes, + }); + + nodeParamIssuesSpy.mockReturnValue(null); + nodeTypes.getByNameAndVersion.mockClear(); + nodeParamIssuesSpy.mockClear(); + + const issues = workflowExecute.checkReadyForExecution(workflow, { + destinationNode: { nodeName: 'destination', mode: 'inclusive' }, + }); + + expect(issues).toBe(null); + // Should check: trigger, node1, destination (3 nodes = 3 calls to getByNameAndVersion) + expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(3); + expect(nodeParamIssuesSpy).toHaveBeenCalledTimes(3); + }); + + it('should not check destination node when mode is exclusive', () => { + const trigger = mock({ name: 'trigger' }); + const node1 = mock({ name: 'node1' }); + const destination = mock({ name: 'destination' }); + + const workflow = new Workflow({ + nodes: [trigger, node1, destination], + connections: { + trigger: { main: [[{ node: 'node1', type: NodeConnectionTypes.Main, index: 0 }]] }, + node1: { main: [[{ node: 'destination', type: NodeConnectionTypes.Main, index: 0 }]] }, + }, + active: false, + nodeTypes, + }); + + nodeParamIssuesSpy.mockReturnValue(null); + nodeTypes.getByNameAndVersion.mockClear(); + nodeParamIssuesSpy.mockClear(); + + const issues = workflowExecute.checkReadyForExecution(workflow, { + destinationNode: { nodeName: 'destination', mode: 'exclusive' }, + }); + + expect(issues).toBe(null); + // Should check: trigger, node1 only (2 nodes = 2 calls to getByNameAndVersion) + expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(2); + expect(nodeParamIssuesSpy).toHaveBeenCalledTimes(2); + }); }); describe('runNode', () => { From 26a992dad38abd392ed2133105ca3264a5790b66 Mon Sep 17 00:00:00 2001 From: Danny Martini Date: Mon, 24 Nov 2025 15:45:37 +0100 Subject: [PATCH 28/28] fixup! refactor: remove unnecessary type annotation in workflow-execution.service --- packages/cli/src/workflows/workflow-execution.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index c9e35a9aa4969..8adde50ffdf6e 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -17,7 +17,6 @@ import type { WorkflowExecuteMode, IWorkflowExecutionDataProcess, IWorkflowBase, - IDestinationNode, } from 'n8n-workflow'; import { SubworkflowOperationError, Workflow, createRunExecutionData } from 'n8n-workflow'; @@ -102,7 +101,7 @@ export class WorkflowExecutionService { const { workflowData, startNodes, dirtyNodeNames, triggerToStartFrom, agentRequest } = payload; let { runData } = payload; const destinationNode = payload.destinationNode - ? { nodeName: payload.destinationNode, mode: 'inclusive' } + ? ({ nodeName: payload.destinationNode, mode: 'inclusive' } as const) : undefined; const pinData = workflowData.pinData; let pinnedTrigger = this.selectPinnedActivatorStarter(