fix: Chat triggers don't work with the new partial execution flow (#11952)

This commit is contained in:
Danny Martini 2024-12-04 15:33:46 +01:00 committed by GitHub
parent 0e26f58ae6
commit 2b6a72f128
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 360 additions and 80 deletions

View file

@ -1,4 +1,23 @@
import { WorkflowHooks, type ExecutionError, type IWorkflowExecuteHooks } from 'n8n-workflow'; import { mock } from 'jest-mock-extended';
import { DirectedGraph, WorkflowExecute } from 'n8n-core';
import * as core from 'n8n-core';
import type {
IExecuteData,
INode,
IRun,
ITaskData,
IWaitingForExecution,
IWaitingForExecutionSource,
IWorkflowExecutionDataProcess,
StartNodeData,
} from 'n8n-workflow';
import {
Workflow,
WorkflowHooks,
type ExecutionError,
type IWorkflowExecuteHooks,
} from 'n8n-workflow';
import PCancelable from 'p-cancelable';
import Container from 'typedi'; import Container from 'typedi';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
@ -6,6 +25,7 @@ import config from '@/config';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error'; import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { PermissionChecker } from '@/user-management/permission-checker';
import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowRunner } from '@/workflow-runner';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import { createExecution } from '@test-integration/db/executions'; import { createExecution } from '@test-integration/db/executions';
@ -43,61 +63,138 @@ afterAll(() => {
beforeEach(async () => { beforeEach(async () => {
await testDb.truncate(['Workflow', 'SharedWorkflow']); await testDb.truncate(['Workflow', 'SharedWorkflow']);
jest.clearAllMocks();
}); });
test('processError should return early in Bull stalled edge case', async () => { describe('processError', () => {
const workflow = await createWorkflow({}, owner); test('processError should return early in Bull stalled edge case', async () => {
const execution = await createExecution( const workflow = await createWorkflow({}, owner);
{ const execution = await createExecution(
status: 'success', {
finished: true, status: 'success',
}, finished: true,
workflow, },
); workflow,
config.set('executions.mode', 'queue'); );
await runner.processError( config.set('executions.mode', 'queue');
new Error('test') as ExecutionError, await runner.processError(
new Date(), new Error('test') as ExecutionError,
'webhook', new Date(),
execution.id, 'webhook',
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), execution.id,
); new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0); );
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should return early if the error is `ExecutionNotFoundError`', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'success', finished: true }, workflow);
await runner.processError(
new ExecutionNotFoundError(execution.id),
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should process error', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(
{
status: 'success',
finished: true,
},
workflow,
);
await Container.get(ActiveExecutions).add(
{ executionMode: 'webhook', workflowData: workflow },
execution.id,
);
config.set('executions.mode', 'regular');
await runner.processError(
new Error('test') as ExecutionError,
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1);
});
}); });
test('processError should return early if the error is `ExecutionNotFoundError`', async () => { describe('run', () => {
const workflow = await createWorkflow({}, owner); it('uses recreateNodeExecutionStack to create a partial execution if a triggerToStartFrom with data is sent', async () => {
const execution = await createExecution({ status: 'success', finished: true }, workflow); // ARRANGE
await runner.processError( const activeExecutions = Container.get(ActiveExecutions);
new ExecutionNotFoundError(execution.id), jest.spyOn(activeExecutions, 'add').mockResolvedValue('1');
new Date(), jest.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValueOnce();
'webhook', const permissionChecker = Container.get(PermissionChecker);
execution.id, jest.spyOn(permissionChecker, 'check').mockResolvedValueOnce();
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should process error', async () => { jest.spyOn(WorkflowExecute.prototype, 'processRunExecutionData').mockReturnValueOnce(
const workflow = await createWorkflow({}, owner); new PCancelable(() => {
const execution = await createExecution( return mock<IRun>();
{ }),
status: 'success', );
finished: true,
}, jest.spyOn(Workflow.prototype, 'getNode').mockReturnValueOnce(mock<INode>());
workflow, jest.spyOn(DirectedGraph, 'fromWorkflow').mockReturnValueOnce(new DirectedGraph());
); const recreateNodeExecutionStackSpy = jest
await Container.get(ActiveExecutions).add( .spyOn(core, 'recreateNodeExecutionStack')
{ executionMode: 'webhook', workflowData: workflow }, .mockReturnValueOnce({
execution.id, nodeExecutionStack: mock<IExecuteData[]>(),
); waitingExecution: mock<IWaitingForExecution>(),
config.set('executions.mode', 'regular'); waitingExecutionSource: mock<IWaitingForExecutionSource>(),
await runner.processError( });
new Error('test') as ExecutionError,
new Date(), const data = mock<IWorkflowExecutionDataProcess>({
'webhook', triggerToStartFrom: { name: 'trigger', data: mock<ITaskData>() },
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow), workflowData: { nodes: [] },
); executionData: undefined,
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1); startNodes: [mock<StartNodeData>()],
destinationNode: undefined,
});
// ACT
await runner.run(data);
// ASSERT
expect(recreateNodeExecutionStackSpy).toHaveBeenCalled();
});
it('does not use recreateNodeExecutionStack to create a partial execution if a triggerToStartFrom without data is sent', async () => {
// ARRANGE
const activeExecutions = Container.get(ActiveExecutions);
jest.spyOn(activeExecutions, 'add').mockResolvedValue('1');
jest.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValueOnce();
const permissionChecker = Container.get(PermissionChecker);
jest.spyOn(permissionChecker, 'check').mockResolvedValueOnce();
jest.spyOn(WorkflowExecute.prototype, 'processRunExecutionData').mockReturnValueOnce(
new PCancelable(() => {
return mock<IRun>();
}),
);
const recreateNodeExecutionStackSpy = jest.spyOn(core, 'recreateNodeExecutionStack');
const data = mock<IWorkflowExecutionDataProcess>({
triggerToStartFrom: { name: 'trigger', data: undefined },
workflowData: { nodes: [] },
executionData: undefined,
startNodes: [mock<StartNodeData>()],
destinationNode: undefined,
});
// ACT
await runner.run(data);
// ASSERT
expect(recreateNodeExecutionStackSpy).not.toHaveBeenCalled();
});
}); });

View file

@ -1,6 +1,11 @@
import type * as express from 'express'; import type * as express from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IWebhookData, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow'; import type { ITaskData } from 'n8n-workflow';
import {
type IWebhookData,
type IWorkflowExecuteAdditionalData,
type Workflow,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { generateNanoId } from '@/databases/utils/generators'; import { generateNanoId } from '@/databases/utils/generators';
@ -43,12 +48,16 @@ describe('TestWebhooks', () => {
jest.useFakeTimers(); jest.useFakeTimers();
}); });
beforeEach(() => {
jest.clearAllMocks();
});
describe('needsWebhook()', () => { describe('needsWebhook()', () => {
const args: Parameters<typeof testWebhooks.needsWebhook> = [ const args: Parameters<typeof testWebhooks.needsWebhook>[0] = {
userId, userId,
workflowEntity, workflowEntity,
mock<IWorkflowExecuteAdditionalData>(), additionalData: mock<IWorkflowExecuteAdditionalData>(),
]; };
test('if webhook is needed, should register then create webhook and return true', async () => { test('if webhook is needed, should register then create webhook and return true', async () => {
const workflow = mock<Workflow>(); const workflow = mock<Workflow>();
@ -56,7 +65,7 @@ describe('TestWebhooks', () => {
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow); jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const needsWebhook = await testWebhooks.needsWebhook(...args); const needsWebhook = await testWebhooks.needsWebhook(args);
const [registerOrder] = registrations.register.mock.invocationCallOrder; const [registerOrder] = registrations.register.mock.invocationCallOrder;
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder; const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
@ -72,7 +81,7 @@ describe('TestWebhooks', () => {
jest.spyOn(registrations, 'register').mockRejectedValueOnce(new Error(msg)); jest.spyOn(registrations, 'register').mockRejectedValueOnce(new Error(msg));
registrations.getAllRegistrations.mockResolvedValue([]); registrations.getAllRegistrations.mockResolvedValue([]);
const needsWebhook = testWebhooks.needsWebhook(...args); const needsWebhook = testWebhooks.needsWebhook(args);
await expect(needsWebhook).rejects.toThrowError(msg); await expect(needsWebhook).rejects.toThrowError(msg);
}); });
@ -81,10 +90,55 @@ describe('TestWebhooks', () => {
webhook.webhookDescription.restartWebhook = true; webhook.webhookDescription.restartWebhook = true;
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]); jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const result = await testWebhooks.needsWebhook(...args); const result = await testWebhooks.needsWebhook(args);
expect(result).toBe(false); expect(result).toBe(false);
}); });
test('returns false if a triggerToStartFrom with triggerData is given', async () => {
const workflow = mock<Workflow>();
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const needsWebhook = await testWebhooks.needsWebhook({
...args,
triggerToStartFrom: {
name: 'trigger',
data: mock<ITaskData>(),
},
});
expect(needsWebhook).toBe(false);
});
test('returns true, registers and then creates webhook if triggerToStartFrom is given with no triggerData', async () => {
// ARRANGE
const workflow = mock<Workflow>();
const webhook2 = mock<IWebhookData>({
node: 'trigger',
httpMethod,
path,
workflowId: workflowEntity.id,
userId,
});
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook, webhook2]);
// ACT
const needsWebhook = await testWebhooks.needsWebhook({
...args,
triggerToStartFrom: { name: 'trigger' },
});
// ASSERT
const [registerOrder] = registrations.register.mock.invocationCallOrder;
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
expect(registerOrder).toBeLessThan(createOrder);
expect(registrations.register.mock.calls[0][0].webhook.node).toBe(webhook2.node);
expect(workflow.createWebhookIfNotExists.mock.calls[0][0].node).toBe(webhook2.node);
expect(needsWebhook).toBe(true);
});
}); });
describe('executeWebhook()', () => { describe('executeWebhook()', () => {

View file

@ -23,6 +23,7 @@ import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrati
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowRequest } from '@/workflows/workflow.request';
import type { import type {
IWebhookResponseCallbackData, IWebhookResponseCallbackData,
@ -218,25 +219,48 @@ export class TestWebhooks implements IWebhookManager {
* Return whether activating a workflow requires listening for webhook calls. * Return whether activating a workflow requires listening for webhook calls.
* For every webhook call to listen for, also activate the webhook. * For every webhook call to listen for, also activate the webhook.
*/ */
async needsWebhook( async needsWebhook(options: {
userId: string, userId: string;
workflowEntity: IWorkflowDb, workflowEntity: IWorkflowDb;
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData;
runData?: IRunData, runData?: IRunData;
pushRef?: string, pushRef?: string;
destinationNode?: string, destinationNode?: string;
) { triggerToStartFrom?: WorkflowRequest.ManualRunPayload['triggerToStartFrom'];
}) {
const {
userId,
workflowEntity,
additionalData,
runData,
pushRef,
destinationNode,
triggerToStartFrom,
} = options;
if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity); if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity);
const workflow = this.toWorkflow(workflowEntity); const workflow = this.toWorkflow(workflowEntity);
const webhooks = WebhookHelpers.getWorkflowWebhooks( let webhooks = WebhookHelpers.getWorkflowWebhooks(
workflow, workflow,
additionalData, additionalData,
destinationNode, destinationNode,
true, true,
); );
// If we have a preferred trigger with data, we don't have to listen for a
// webhook.
if (triggerToStartFrom?.data) {
return false;
}
// If we have a preferred trigger without data we only want to listen for
// that trigger, not the other ones.
if (triggerToStartFrom) {
webhooks = webhooks.filter((w) => w.node === triggerToStartFrom.name);
}
if (!webhooks.some((w) => w.webhookDescription.restartWebhook !== true)) { if (!webhooks.some((w) => w.webhookDescription.restartWebhook !== true)) {
return false; // no webhooks found to start a workflow return false; // no webhooks found to start a workflow
} }

View file

@ -2,7 +2,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { InstanceSettings, WorkflowExecute } from 'n8n-core'; import * as a from 'assert/strict';
import {
DirectedGraph,
InstanceSettings,
WorkflowExecute,
filterDisabledNodes,
recreateNodeExecutionStack,
} from 'n8n-core';
import type { import type {
ExecutionError, ExecutionError,
IDeferredPromise, IDeferredPromise,
@ -12,6 +19,7 @@ import type {
WorkflowExecuteMode, WorkflowExecuteMode,
WorkflowHooks, WorkflowHooks,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
IRunExecutionData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
ErrorReporterProxy as ErrorReporter, ErrorReporterProxy as ErrorReporter,
@ -203,6 +211,7 @@ export class WorkflowRunner {
} }
/** Run the workflow in current process */ /** Run the workflow in current process */
// eslint-disable-next-line complexity
private async runMainProcess( private async runMainProcess(
executionId: string, executionId: string,
data: IWorkflowExecutionDataProcess, data: IWorkflowExecutionDataProcess,
@ -286,12 +295,50 @@ export class WorkflowRunner {
data.executionData, data.executionData,
); );
workflowExecution = workflowExecute.processRunExecutionData(workflow); workflowExecution = workflowExecute.processRunExecutionData(workflow);
} else if (data.triggerToStartFrom?.data && data.startNodes && !data.destinationNode) {
this.logger.debug(
`Execution ID ${executionId} had triggerToStartFrom. Starting from that trigger.`,
{ executionId },
);
const startNodes = data.startNodes.map((data) => {
const node = workflow.getNode(data.name);
a.ok(node, `Could not find a node named "${data.name}" in the workflow.`);
return node;
});
const runData = { [data.triggerToStartFrom.name]: [data.triggerToStartFrom.data] };
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
recreateNodeExecutionStack(
filterDisabledNodes(DirectedGraph.fromWorkflow(workflow)),
new Set(startNodes),
runData,
data.pinData ?? {},
);
const executionData: IRunExecutionData = {
resultData: { runData, pinData },
executionData: {
contextData: {},
metadata: {},
nodeExecutionStack,
waitingExecution,
waitingExecutionSource,
},
};
const workflowExecute = new WorkflowExecute(additionalData, 'manual', executionData);
workflowExecution = workflowExecute.processRunExecutionData(workflow);
} else if ( } else if (
data.runData === undefined || data.runData === undefined ||
data.startNodes === undefined || data.startNodes === undefined ||
data.startNodes.length === 0 data.startNodes.length === 0
) { ) {
// Full Execution // Full Execution
// TODO: When the old partial execution logic is removed this block can
// be removed and the previous one can be merged into
// `workflowExecute.runPartialWorkflow2`.
// Partial executions then require either a destination node from which
// everything else can be derived, or a triggerToStartFrom with
// triggerData.
this.logger.debug(`Execution ID ${executionId} will run executing all nodes.`, { this.logger.debug(`Execution ID ${executionId} will run executing all nodes.`, {
executionId, executionId,
}); });

View file

@ -95,6 +95,7 @@ export class WorkflowExecutionService {
startNodes, startNodes,
destinationNode, destinationNode,
dirtyNodeNames, dirtyNodeNames,
triggerToStartFrom,
}: WorkflowRequest.ManualRunPayload, }: WorkflowRequest.ManualRunPayload,
user: User, user: User,
pushRef?: string, pushRef?: string,
@ -117,14 +118,15 @@ export class WorkflowExecutionService {
) { ) {
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id); const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
const needsWebhook = await this.testWebhooks.needsWebhook( const needsWebhook = await this.testWebhooks.needsWebhook({
user.id, userId: user.id,
workflowData, workflowEntity: workflowData,
additionalData, additionalData,
runData, runData,
pushRef, pushRef,
destinationNode, destinationNode,
); triggerToStartFrom,
});
if (needsWebhook) return { waitingForWebhook: true }; if (needsWebhook) return { waitingForWebhook: true };
} }
@ -144,6 +146,7 @@ export class WorkflowExecutionService {
userId: user.id, userId: user.id,
partialExecutionVersion: partialExecutionVersion ?? '0', partialExecutionVersion: partialExecutionVersion ?? '0',
dirtyNodeNames, dirtyNodeNames,
triggerToStartFrom,
}; };
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];

View file

@ -1,4 +1,11 @@
import type { INode, IConnections, IWorkflowSettings, IRunData, StartNodeData } from 'n8n-workflow'; import type {
INode,
IConnections,
IWorkflowSettings,
IRunData,
StartNodeData,
ITaskData,
} from 'n8n-workflow';
import type { IWorkflowDb } from '@/interfaces'; import type { IWorkflowDb } from '@/interfaces';
import type { AuthenticatedRequest, ListQuery } from '@/requests'; import type { AuthenticatedRequest, ListQuery } from '@/requests';
@ -23,6 +30,10 @@ export declare namespace WorkflowRequest {
startNodes?: StartNodeData[]; startNodes?: StartNodeData[];
destinationNode?: string; destinationNode?: string;
dirtyNodeNames?: string[]; dirtyNodeNames?: string[];
triggerToStartFrom?: {
name: string;
data?: ITaskData;
};
}; };
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>; type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;

View file

@ -21,3 +21,4 @@ export { BinaryData } from './BinaryData/types';
export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils'; export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
export * from './ExecutionMetadata'; export * from './ExecutionMetadata';
export * from './node-execution-context'; export * from './node-execution-context';
export * from './PartialExecutionUtils';

View file

@ -46,6 +46,7 @@ import type {
StartNodeData, StartNodeData,
IPersonalizationSurveyAnswersV4, IPersonalizationSurveyAnswersV4,
AnnotationVote, AnnotationVote,
ITaskData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { import type {
@ -201,6 +202,10 @@ export interface IStartRunData {
destinationNode?: string; destinationNode?: string;
runData?: IRunData; runData?: IRunData;
dirtyNodeNames?: string[]; dirtyNodeNames?: string[];
triggerToStartFrom?: {
name: string;
data?: ITaskData;
};
} }
export interface ITableData { export interface ITableData {

View file

@ -8,6 +8,7 @@ import {
type IRunData, type IRunData,
type Workflow, type Workflow,
type IExecuteData, type IExecuteData,
type ITaskData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { useRootStore } from '@/stores/root.store'; import { useRootStore } from '@/stores/root.store';
@ -20,6 +21,7 @@ import { useToast } from './useToast';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useLocalStorage } from '@vueuse/core'; import { useLocalStorage } from '@vueuse/core';
import { ref } from 'vue'; import { ref } from 'vue';
import { mock } from 'vitest-mock-extended';
vi.mock('@/stores/workflows.store', () => ({ vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue({ useWorkflowsStore: vi.fn().mockReturnValue({
@ -325,6 +327,34 @@ describe('useRunWorkflow({ router })', () => {
); );
}); });
it('should send triggerToStartFrom if triggerNode and nodeData are passed in', async () => {
// ARRANGE
const composable = useRunWorkflow({ router });
const triggerNode = 'Chat Trigger';
const nodeData = mock<ITaskData>();
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(
mock<Workflow>({ getChildNodes: vi.fn().mockReturnValue([]) }),
);
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(
mock<IWorkflowData>({ nodes: [] }),
);
const { runWorkflow } = composable;
// ACT
await runWorkflow({ triggerNode, nodeData });
// ASSERT
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
triggerToStartFrom: {
name: triggerNode,
data: nodeData,
},
}),
);
});
it('does not use the original run data if `PartialExecution.version` is set to 0', async () => { it('does not use the original run data if `PartialExecution.version` is set to 0', async () => {
// ARRANGE // ARRANGE
const mockExecutionResponse = { executionId: '123' }; const mockExecutionResponse = { executionId: '123' };

View file

@ -150,6 +150,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
let { runData: newRunData } = consolidatedData; let { runData: newRunData } = consolidatedData;
let executedNode: string | undefined; let executedNode: string | undefined;
let triggerToStartFrom: IStartRunData['triggerToStartFrom'];
if ( if (
startNodeNames.length === 0 && startNodeNames.length === 0 &&
'destinationNode' in options && 'destinationNode' in options &&
@ -157,14 +158,16 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
) { ) {
executedNode = options.destinationNode; executedNode = options.destinationNode;
startNodeNames.push(options.destinationNode); startNodeNames.push(options.destinationNode);
} else if ('triggerNode' in options && 'nodeData' in options) { } else if (options.triggerNode && options.nodeData) {
startNodeNames.push( startNodeNames.push(
...workflow.getChildNodes(options.triggerNode as string, NodeConnectionType.Main, 1), ...workflow.getChildNodes(options.triggerNode, NodeConnectionType.Main, 1),
); );
newRunData = { newRunData = { [options.triggerNode]: [options.nodeData] };
[options.triggerNode as string]: [options.nodeData],
} as IRunData;
executedNode = options.triggerNode; executedNode = options.triggerNode;
triggerToStartFrom = {
name: options.triggerNode,
data: options.nodeData,
};
} }
// If the destination node is specified, check if it is a chat node or has a chat parent // If the destination node is specified, check if it is a chat node or has a chat parent
@ -258,6 +261,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// data to use and what to ignore. // data to use and what to ignore.
runData: partialExecutionVersion.value === 1 ? (runData ?? undefined) : newRunData, runData: partialExecutionVersion.value === 1 ? (runData ?? undefined) : newRunData,
startNodes, startNodes,
triggerToStartFrom,
}; };
if ('destinationNode' in options) { if ('destinationNode' in options) {
startRunData.destinationNode = options.destinationNode; startRunData.destinationNode = options.destinationNode;

View file

@ -2284,6 +2284,10 @@ export interface IWorkflowExecutionDataProcess {
*/ */
partialExecutionVersion?: string; partialExecutionVersion?: string;
dirtyNodeNames?: string[]; dirtyNodeNames?: string[];
triggerToStartFrom?: {
name: string;
data?: ITaskData;
};
} }
export interface ExecuteWorkflowOptions { export interface ExecuteWorkflowOptions {