fix(editor): Fix partial chat executions (#15379)

This commit is contained in:
oleg
2025-05-15 17:12:08 +02:00
committed by GitHub
parent 726438d95e
commit b6370fb2ec
8 changed files with 452 additions and 32 deletions

View File

@@ -254,12 +254,10 @@ describe('ManualExecutionService', () => {
await manualExecutionService.runManually(data, workflow, additionalData, executionId);
expect(mockRun).toHaveBeenCalledWith(
workflow,
undefined, // startNode
undefined, // destinationNode
undefined, // pinData
);
expect(mockRun.mock.calls[0][0]).toBe(workflow);
expect(mockRun.mock.calls[0][1]).toBeUndefined(); // startNode
expect(mockRun.mock.calls[0][2]).toBeUndefined(); // destinationNode
expect(mockRun.mock.calls[0][3]).toBeUndefined(); // pinData
});
it('should use execution start node when available for full execution', async () => {
@@ -297,11 +295,49 @@ describe('ManualExecutionService', () => {
expect(manualExecutionService.getExecutionStartNode).toHaveBeenCalledWith(data, workflow);
expect(mockRun.mock.calls[0][0]).toBe(workflow);
expect(mockRun.mock.calls[0][1]).toBe(startNode); // startNode
expect(mockRun.mock.calls[0][2]).toBeUndefined(); // destinationNode
expect(mockRun.mock.calls[0][3]).toBe(data.pinData); // pinData
});
it('should pass the triggerToStartFrom to workflowExecute.run for full execution', async () => {
const mockTriggerData = mock<ITaskData>();
const triggerNodeName = 'triggerNode';
const data = mock<IWorkflowExecutionDataProcess>({
executionMode: 'manual',
destinationNode: undefined,
pinData: undefined,
triggerToStartFrom: {
name: triggerNodeName,
data: mockTriggerData,
},
});
const startNode = mock<INode>({ name: 'startNode' });
const workflow = mock<Workflow>({
getNode: jest.fn().mockReturnValue(startNode),
});
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const executionId = 'test-execution-id';
jest.spyOn(manualExecutionService, 'getExecutionStartNode').mockReturnValue(startNode);
const mockRun = jest.fn().mockReturnValue('mockRunReturn');
require('n8n-core').WorkflowExecute.mockImplementationOnce(() => ({
run: mockRun,
processRunExecutionData: jest.fn(),
}));
await manualExecutionService.runManually(data, workflow, additionalData, executionId);
expect(mockRun).toHaveBeenCalledWith(
workflow,
startNode, // startNode
undefined, // destinationNode
data.pinData, // pinData
undefined, // pinData
data.triggerToStartFrom, // triggerToStartFrom
);
});
@@ -455,5 +491,110 @@ describe('ManualExecutionService', () => {
}),
);
});
it('should call runPartialWorkflow2 for V2 partial execution with runData and empty startNodes', async () => {
const mockRunData = { nodeA: [{ data: { main: [[{ json: { value: 'test' } }]] } }] };
const destinationNodeName = 'nodeB';
const data = mock<IWorkflowExecutionDataProcess>({
executionMode: 'manual',
runData: mockRunData,
startNodes: [],
partialExecutionVersion: 2,
destinationNode: destinationNodeName,
pinData: {},
dirtyNodeNames: [],
agentRequest: undefined,
});
const workflow = mock<Workflow>({
getNode: jest.fn((name) => mock<INode>({ name })),
});
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const executionId = 'test-exec-id-v2-empty-start';
const mockRunPartialWorkflow2 = jest.fn().mockReturnValue('mockPartial2Return-v2-empty');
(core.WorkflowExecute as jest.Mock).mockImplementationOnce(() => ({
runPartialWorkflow2: mockRunPartialWorkflow2,
processRunExecutionData: jest.fn(),
run: jest.fn(),
runPartialWorkflow: jest.fn(),
}));
await manualExecutionService.runManually(
data,
workflow,
additionalData,
executionId,
data.pinData,
);
expect(mockRunPartialWorkflow2).toHaveBeenCalledWith(
workflow,
mockRunData,
data.pinData,
data.dirtyNodeNames,
destinationNodeName,
data.agentRequest,
);
});
it('should call workflowExecute.run for V1 partial execution with runData and empty startNodes', async () => {
const mockRunData = { nodeA: [{ data: { main: [[{ json: { value: 'test' } }]] } }] };
const data = mock<IWorkflowExecutionDataProcess>({
executionMode: 'manual',
runData: mockRunData,
startNodes: [],
destinationNode: 'nodeC',
pinData: { nodeX: [{ json: {} }] },
triggerToStartFrom: undefined,
});
const determinedStartNode = mock<INode>({ name: 'manualTrigger' });
const destinationNodeMock = mock<INode>({ name: data.destinationNode });
const workflow = mock<Workflow>({
getNode: jest.fn((name) => {
if (name === data.destinationNode) {
return destinationNodeMock;
}
if (name === determinedStartNode.name) {
return determinedStartNode;
}
return null;
}),
getTriggerNodes: jest.fn().mockReturnValue([determinedStartNode]),
});
jest
.spyOn(manualExecutionService, 'getExecutionStartNode')
.mockReturnValue(determinedStartNode);
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const executionId = 'test-exec-id-v1-empty-start';
const mockRun = jest.fn().mockReturnValue('mockRunReturn-v1-empty');
(core.WorkflowExecute as jest.Mock).mockImplementationOnce(() => ({
run: mockRun,
processRunExecutionData: jest.fn(),
runPartialWorkflow: jest.fn(),
runPartialWorkflow2: jest.fn(),
}));
await manualExecutionService.runManually(
data,
workflow,
additionalData,
executionId,
data.pinData,
);
expect(manualExecutionService.getExecutionStartNode).toHaveBeenCalledWith(data, workflow);
expect(mockRun).toHaveBeenCalledWith(
workflow,
determinedStartNode,
data.destinationNode,
data.pinData,
data.triggerToStartFrom,
);
});
});
});

View File

@@ -11,9 +11,12 @@ import {
} from 'n8n-core';
import { MANUAL_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type {
IExecuteData,
IPinData,
IRun,
IRunExecutionData,
IWaitingForExecution,
IWaitingForExecutionSource,
IWorkflowExecuteAdditionalData,
IWorkflowExecutionDataProcess,
Workflow,
@@ -59,7 +62,7 @@ export class ManualExecutionService {
executionId: string,
pinData?: IPinData,
): PCancelable<IRun> {
if (data.triggerToStartFrom?.data && data.startNodes) {
if (data.triggerToStartFrom?.data && data.startNodes?.length) {
this.logger.debug(
`Execution ID ${executionId} had triggerToStartFrom. Starting from that trigger.`,
{ executionId },
@@ -71,13 +74,22 @@ export class ManualExecutionService {
});
const runData = { [data.triggerToStartFrom.name]: [data.triggerToStartFrom.data] };
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
recreateNodeExecutionStack(
let nodeExecutionStack: IExecuteData[] = [];
let waitingExecution: IWaitingForExecution = {};
let waitingExecutionSource: IWaitingForExecutionSource = {};
if (data.destinationNode !== data.triggerToStartFrom.name) {
const recreatedStack = recreateNodeExecutionStack(
filterDisabledNodes(DirectedGraph.fromWorkflow(workflow)),
new Set(startNodes),
runData,
data.pinData ?? {},
);
nodeExecutionStack = recreatedStack.nodeExecutionStack;
waitingExecution = recreatedStack.waitingExecution;
waitingExecutionSource = recreatedStack.waitingExecutionSource;
}
const executionData: IRunExecutionData = {
resultData: { runData, pinData },
executionData: {
@@ -101,8 +113,7 @@ export class ManualExecutionService {
return workflowExecute.processRunExecutionData(workflow);
} else if (
data.runData === undefined ||
data.startNodes === undefined ||
data.startNodes.length === 0
(data.partialExecutionVersion !== 2 && (!data.startNodes || data.startNodes.length === 0))
) {
// Full Execution
// TODO: When the old partial execution logic is removed this block can
@@ -143,7 +154,13 @@ export class ManualExecutionService {
// Can execute without webhook so go on
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
return workflowExecute.run(workflow, startNode, data.destinationNode, data.pinData);
return workflowExecute.run(
workflow,
startNode,
data.destinationNode,
data.pinData,
data.triggerToStartFrom,
);
} else {
// Partial Execution
this.logger.debug(`Execution ID ${executionId} is a partial execution.`, { executionId });
@@ -163,7 +180,7 @@ export class ManualExecutionService {
return workflowExecute.runPartialWorkflow(
workflow,
data.runData,
data.startNodes,
data.startNodes ?? [],
data.destinationNode,
data.pinData,
);