mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-26 05:04:05 -08:00
fix: Chat triggers don't work with the new partial execution flow (#11952)
This commit is contained in:
parent
0e26f58ae6
commit
2b6a72f128
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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()', () => {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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' };
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in a new issue