mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
911 lines
28 KiB
TypeScript
911 lines
28 KiB
TypeScript
import { Logger } from '@n8n/backend-common';
|
||
import { EventService, InstanceWriteAccessService } from '@n8n/backend-services';
|
||
import { GlobalConfig, WorkflowsConfig } from '@n8n/config';
|
||
import type {
|
||
Project,
|
||
User,
|
||
CreateExecutionPayload,
|
||
WorkflowEntity,
|
||
PollLeaseFence,
|
||
} from '@n8n/db';
|
||
import { WorkflowRepository } from '@n8n/db';
|
||
import { Service } from '@n8n/di';
|
||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||
import type { IDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||
import type { Response } from 'express';
|
||
import {
|
||
DirectedGraph,
|
||
ErrorReporter,
|
||
ExecutionContextService,
|
||
anyReachableRootHasRunData,
|
||
} from 'n8n-core';
|
||
import type {
|
||
IExecuteData,
|
||
IExecuteResponsePromiseData,
|
||
INode,
|
||
INodeExecutionData,
|
||
IPinData,
|
||
IRunExecutionData,
|
||
IWorkflowExecuteAdditionalData,
|
||
WorkflowExecuteMode,
|
||
IWorkflowExecutionDataProcess,
|
||
IWorkflowBase,
|
||
PollCursor,
|
||
} from 'n8n-workflow';
|
||
import {
|
||
OperationalError,
|
||
SubworkflowOperationError,
|
||
UnexpectedError,
|
||
Workflow,
|
||
createRunExecutionData,
|
||
} from 'n8n-workflow';
|
||
|
||
import { ExecutionAlreadyResumingError } from '@/errors/execution-already-resuming.error';
|
||
import { PreExecuteBlockedError } from '@/errors/pre-execute-blocked.error';
|
||
import { ForbiddenError } from '@n8n/errors';
|
||
import { ExecutionCrashService } from '@/executions/execution-crash.service';
|
||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||
import { FailedRunFactory } from '@/executions/failed-run-factory';
|
||
import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
|
||
import type { IWorkflowErrorData } from '@/interfaces';
|
||
import { NodeTypes } from '@/node-types';
|
||
import { OwnershipService } from '@/services/ownership.service';
|
||
import { TestWebhooks } from '@/webhooks/test-webhooks';
|
||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||
import { WorkflowRunner } from '@/workflow-runner';
|
||
import { PollCursorService } from '@/workflows/triggers/poll-cursor.service';
|
||
import { getWorkflowProjectDetailsSafe } from '@/workflows/utils';
|
||
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
|
||
import type { WorkflowRequest } from '@/workflows/workflow.request';
|
||
|
||
@Service()
|
||
export class WorkflowExecutionService {
|
||
constructor(
|
||
private readonly logger: Logger,
|
||
private readonly errorReporter: ErrorReporter,
|
||
private readonly executionPersistence: ExecutionPersistence,
|
||
private readonly workflowRepository: WorkflowRepository,
|
||
private readonly nodeTypes: NodeTypes,
|
||
private readonly testWebhooks: TestWebhooks,
|
||
private readonly workflowRunner: WorkflowRunner,
|
||
private readonly globalConfig: GlobalConfig,
|
||
private readonly subworkflowPolicyChecker: SubworkflowPolicyChecker,
|
||
private readonly failedRunFactory: FailedRunFactory,
|
||
private readonly eventService: EventService,
|
||
private readonly ownershipService: OwnershipService,
|
||
private readonly executionContextService: ExecutionContextService,
|
||
private readonly workflowsConfig: WorkflowsConfig,
|
||
private readonly workflowPublishedDataService: WorkflowPublishedDataService,
|
||
private readonly pollCursorService: PollCursorService,
|
||
private readonly executionCrashService: ExecutionCrashService,
|
||
private readonly instanceWriteAccess: InstanceWriteAccessService,
|
||
) {}
|
||
|
||
async runWorkflow(
|
||
workflowData: IWorkflowBase,
|
||
node: INode,
|
||
data: INodeExecutionData[][],
|
||
additionalData: IWorkflowExecuteAdditionalData,
|
||
mode: WorkflowExecuteMode,
|
||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||
deduplicationKey?: string,
|
||
callerAwaitsOutcome: IWorkflowExecutionDataProcess['callerAwaitsOutcome'] = 'none',
|
||
) {
|
||
const nodeExecutionStack: IExecuteData[] = [
|
||
{
|
||
node,
|
||
data: {
|
||
main: data,
|
||
},
|
||
source: null,
|
||
},
|
||
];
|
||
|
||
const executionData = createRunExecutionData({
|
||
executionData: {
|
||
nodeExecutionStack,
|
||
},
|
||
});
|
||
|
||
const { projectId, projectName } = await getWorkflowProjectDetailsSafe(
|
||
this.ownershipService,
|
||
workflowData.id,
|
||
);
|
||
|
||
// Start the workflow
|
||
const runData: IWorkflowExecutionDataProcess = {
|
||
userId: additionalData.userId,
|
||
executionMode: mode,
|
||
executionData,
|
||
workflowData,
|
||
deduplicationKey,
|
||
projectId,
|
||
projectName,
|
||
callerAwaitsOutcome,
|
||
};
|
||
|
||
return await this.workflowRunner.run(runData, true, undefined, undefined, responsePromise);
|
||
}
|
||
|
||
/**
|
||
* Starts an execution for polled items, committing its row in the same transaction
|
||
* as the poll's cursor advance so neither can exist without the other.
|
||
*/
|
||
async runPolledWorkflow(
|
||
workflowData: IWorkflowBase,
|
||
node: INode,
|
||
data: INodeExecutionData[][],
|
||
additionalData: IWorkflowExecuteAdditionalData,
|
||
mode: WorkflowExecuteMode,
|
||
cursor: PollCursor,
|
||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||
fence?: PollLeaseFence,
|
||
callerAwaitsOutcome: IWorkflowExecutionDataProcess['callerAwaitsOutcome'] = 'none',
|
||
): Promise<string | undefined> {
|
||
const nodeExecutionStack: IExecuteData[] = [
|
||
{
|
||
node,
|
||
data: {
|
||
main: data,
|
||
},
|
||
source: null,
|
||
},
|
||
];
|
||
|
||
const executionData = createRunExecutionData({
|
||
executionData: {
|
||
nodeExecutionStack,
|
||
},
|
||
});
|
||
|
||
const { projectId, projectName } = await getWorkflowProjectDetailsSafe(
|
||
this.ownershipService,
|
||
workflowData.id,
|
||
);
|
||
|
||
const runData: IWorkflowExecutionDataProcess = {
|
||
userId: additionalData.userId,
|
||
executionMode: mode,
|
||
executionData,
|
||
workflowData,
|
||
projectId,
|
||
projectName,
|
||
callerAwaitsOutcome,
|
||
};
|
||
|
||
// Mask the trigger items before the payload is committed, so the persisted row
|
||
// never holds raw header data. `run` below establishes the context again, which
|
||
// early-exits once it is in place.
|
||
const establishContextError = await this.workflowRunner.establishContextForPersistence(runData);
|
||
|
||
if (establishContextError) {
|
||
this.errorReporter.error(establishContextError, { shouldBeLogged: false });
|
||
this.logger.error('Failed to prepare a polled execution, so its cursor was not committed', {
|
||
workflowId: workflowData.id,
|
||
nodeName: node.name,
|
||
error: establishContextError,
|
||
});
|
||
|
||
responsePromise?.reject(ensureError(establishContextError));
|
||
|
||
return undefined;
|
||
}
|
||
|
||
try {
|
||
await this.workflowRunner.prepareNewExecution(runData, true);
|
||
} catch (error) {
|
||
if (error instanceof PreExecuteBlockedError) {
|
||
this.logger.error('Blocked a polled execution before its row was committed', {
|
||
workflowId: workflowData.id,
|
||
nodeName: node.name,
|
||
error: error.cause,
|
||
});
|
||
|
||
responsePromise?.reject(error.cause);
|
||
|
||
return undefined;
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
|
||
const payload: CreateExecutionPayload = {
|
||
data: executionData,
|
||
mode,
|
||
finished: false,
|
||
workflowData,
|
||
status: 'new',
|
||
workflowId: workflowData.id,
|
||
retryOf: runData.retryOf ?? undefined,
|
||
tracingContext: runData.tracingContext ?? null,
|
||
};
|
||
|
||
const commitResult = await this.pollCursorService.commitWithExecution({
|
||
workflowId: workflowData.id,
|
||
nodeId: node.id,
|
||
cursor,
|
||
payload,
|
||
fence,
|
||
});
|
||
|
||
if (commitResult === null) {
|
||
this.logger.debug('Poll cursor commit skipped: the poll no longer holds its lease', {
|
||
workflowId: workflowData.id,
|
||
nodeId: node.id,
|
||
nodeName: node.name,
|
||
});
|
||
responsePromise?.reject(
|
||
new OperationalError('Poll cursor commit skipped: the poll no longer holds its lease'),
|
||
);
|
||
return undefined;
|
||
}
|
||
|
||
const { executionId } = commitResult;
|
||
|
||
// The row was committed at `new`; `expectedStatus` claims it and moves it to
|
||
// running, so a concurrent starter cannot run it a second time.
|
||
try {
|
||
await this.workflowRunner.run(
|
||
runData,
|
||
false,
|
||
undefined,
|
||
{ executionId, expectedStatus: 'new' },
|
||
responsePromise,
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof ExecutionAlreadyResumingError) {
|
||
this.logger.debug('Polled execution was already claimed, leaving it to its owner', {
|
||
executionId,
|
||
});
|
||
} else {
|
||
await this.crashFailedPolledExecution(executionId, error, responsePromise);
|
||
}
|
||
}
|
||
|
||
return executionId;
|
||
}
|
||
|
||
/**
|
||
* Starts a polled execution on engine v2, then advances the cursor.
|
||
*
|
||
* The v2 path keeps no control-plane execution row, so the cursor cannot
|
||
* commit in the same transaction as the run the way {@link runPolledWorkflow}
|
||
* does. It commits after the data plane confirms the run started instead: a
|
||
* crash between the two calls can duplicate a poll, never lose one.
|
||
* TODO(CAT-4078): add a dedup key once `StartExecutionRequest` supports one.
|
||
*/
|
||
async runPolledWorkflowV2(
|
||
workflowData: IWorkflowBase,
|
||
node: INode,
|
||
data: INodeExecutionData[][],
|
||
additionalData: IWorkflowExecuteAdditionalData,
|
||
mode: WorkflowExecuteMode,
|
||
cursor: PollCursor,
|
||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||
fence?: PollLeaseFence,
|
||
): Promise<string> {
|
||
const executionId = await this.runWorkflow(
|
||
workflowData,
|
||
node,
|
||
data,
|
||
additionalData,
|
||
mode,
|
||
responsePromise,
|
||
);
|
||
|
||
const committed = await this.pollCursorService.commitCursorOnly({
|
||
workflowId: workflowData.id,
|
||
nodeId: node.id,
|
||
cursor,
|
||
fence,
|
||
});
|
||
|
||
if (!committed) {
|
||
// The run already started; the lease loss only means a concurrent
|
||
// poller may repeat the same window, not that anything was lost.
|
||
this.logger.warn(
|
||
'Poll cursor commit skipped after its execution already started: the poll no longer holds its lease',
|
||
{ workflowId: workflowData.id, nodeId: node.id, nodeName: node.name, executionId },
|
||
);
|
||
}
|
||
|
||
return executionId;
|
||
}
|
||
|
||
/**
|
||
* Marks a committed row that failed to start as crashed, so it does not sit at
|
||
* `new` indefinitely.
|
||
*/
|
||
private async crashFailedPolledExecution(
|
||
executionId: string,
|
||
error: unknown,
|
||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||
): Promise<void> {
|
||
this.errorReporter.error(error, { executionId, shouldBeLogged: false });
|
||
this.logger.error('Failed to start the execution committed for a poll', {
|
||
executionId,
|
||
error,
|
||
});
|
||
|
||
responsePromise?.reject(ensureError(error));
|
||
|
||
await this.executionCrashService.markAsCrashed(executionId, 'start-failure');
|
||
}
|
||
|
||
private isDestinationNodeATrigger(destinationNode: string, workflow: IWorkflowBase) {
|
||
const node = workflow.nodes.find((n) => n.name === destinationNode);
|
||
|
||
if (node === undefined) {
|
||
return false;
|
||
}
|
||
|
||
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||
|
||
return nodeType.description.group.includes('trigger');
|
||
}
|
||
|
||
async executeManually(
|
||
workflowData: IWorkflowBase,
|
||
payload: WorkflowRequest.ManualRunPayload,
|
||
user: User,
|
||
pushRef?: string,
|
||
n8nAuthCookie?: string,
|
||
): Promise<{ executionId: string } | { waitingForWebhook: boolean }> {
|
||
this.assertManualExecutionAllowed();
|
||
|
||
// Check whether this workflow is active.
|
||
const workflowIsActive = await this.workflowRepository.isActive(workflowData.id);
|
||
|
||
// For manual testing always set to not active
|
||
workflowData.active = false;
|
||
workflowData.activeVersionId = null;
|
||
|
||
// The UI can send runData alongside triggerToStartFrom, but a trigger
|
||
// means a full run, so stale runData must not demote it to a partial
|
||
// execution. ManualRunDto already strips it for endpoint traffic; this
|
||
// keeps the same precedence for direct callers.
|
||
// TODO: Remove once the FE stops sending it (CAT-1808)
|
||
if ('triggerToStartFrom' in payload && payload.triggerToStartFrom !== undefined) {
|
||
Reflect.deleteProperty(payload, 'runData');
|
||
}
|
||
|
||
let data: IWorkflowExecutionDataProcess | undefined;
|
||
|
||
// Case 1: Partial execution to a destination node, and we have enough runData to start the execution.
|
||
if (isPartialExecution(payload)) {
|
||
if (this.partialExecutionFulfilsPreconditions(workflowData, payload)) {
|
||
data = {
|
||
destinationNode: payload.destinationNode,
|
||
executionMode: 'manual',
|
||
runData: payload.runData,
|
||
pinData: workflowData.pinData,
|
||
pushRef,
|
||
workflowData,
|
||
userId: user.id,
|
||
dirtyNodeNames: payload.dirtyNodeNames,
|
||
agentRequest: payload.agentRequest,
|
||
};
|
||
} else {
|
||
payload = upgradeToFullManualExecutionFromUnknownTrigger(payload);
|
||
}
|
||
}
|
||
|
||
// Case 2: Full execution from a known trigger.
|
||
if (isFullExecutionFromKnownTrigger(payload)) {
|
||
// We must always register the webhook - even when the Chat Trigger has
|
||
// pinned data – because the chat SDK will POST the message to it.
|
||
if (
|
||
(payload.chatSessionId || triggerHasNoPinnedData(workflowData, payload)) &&
|
||
(await this.testWebhooks.needsWebhook({
|
||
userId: user.id,
|
||
workflowEntity: workflowData,
|
||
additionalData: await WorkflowExecuteAdditionalData.getBase({
|
||
userId: user.id,
|
||
workflowId: workflowData.id,
|
||
}),
|
||
pushRef,
|
||
triggerToStartFrom: payload.triggerToStartFrom,
|
||
destinationNode: payload.destinationNode,
|
||
chatSessionId: payload.chatSessionId,
|
||
workflowIsActive,
|
||
n8nAuthCookie,
|
||
}))
|
||
) {
|
||
return { waitingForWebhook: true };
|
||
}
|
||
|
||
data = {
|
||
executionMode: 'manual',
|
||
pinData: workflowData.pinData,
|
||
pushRef,
|
||
workflowData,
|
||
userId: user.id,
|
||
triggerToStartFrom: payload.triggerToStartFrom,
|
||
agentRequest: payload.agentRequest,
|
||
destinationNode: payload.destinationNode,
|
||
};
|
||
}
|
||
|
||
// Case 3: Full execution from an unknown trigger.
|
||
if (isFullExecutionFromUnknownTrigger(payload)) {
|
||
const pinnedTrigger = this.selectPinnedTrigger(
|
||
workflowData,
|
||
payload.destinationNode.nodeName,
|
||
workflowData.pinData ?? {},
|
||
);
|
||
|
||
if (
|
||
pinnedTrigger === undefined &&
|
||
(await this.testWebhooks.needsWebhook({
|
||
userId: user.id,
|
||
workflowEntity: workflowData,
|
||
additionalData: await WorkflowExecuteAdditionalData.getBase({
|
||
userId: user.id,
|
||
workflowId: workflowData.id,
|
||
}),
|
||
pushRef,
|
||
destinationNode: payload.destinationNode,
|
||
workflowIsActive,
|
||
n8nAuthCookie,
|
||
}))
|
||
) {
|
||
return { waitingForWebhook: true };
|
||
}
|
||
|
||
data = {
|
||
executionMode: 'manual',
|
||
pinData: workflowData.pinData,
|
||
pushRef,
|
||
workflowData,
|
||
userId: user.id,
|
||
agentRequest: payload.agentRequest,
|
||
destinationNode: payload.destinationNode,
|
||
triggerToStartFrom: pinnedTrigger ? { name: pinnedTrigger.name } : undefined,
|
||
};
|
||
}
|
||
|
||
if (data) {
|
||
const { projectId, projectName } = await getWorkflowProjectDetailsSafe(
|
||
this.ownershipService,
|
||
workflowData.id,
|
||
);
|
||
data.projectId = projectId;
|
||
data.projectName = projectName;
|
||
|
||
data.encryptedRunnerIdentity = n8nAuthCookie
|
||
? await this.executionContextService.buildManualExecutionCredentials(n8nAuthCookie)
|
||
: undefined;
|
||
|
||
const offloadingManualExecutionsInQueueMode =
|
||
this.globalConfig.executions.mode === 'queue' &&
|
||
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS === 'true';
|
||
|
||
/**
|
||
* Historically, manual executions in scaling mode ran in the main process,
|
||
* so some execution details were never persisted in the database.
|
||
*
|
||
* Currently, manual executions in scaling mode are offloaded to workers,
|
||
* so we persist all details to give workers full access to them.
|
||
*/
|
||
if (data.executionMode === 'manual' && offloadingManualExecutionsInQueueMode) {
|
||
data.executionData = createRunExecutionData({
|
||
startData: {
|
||
startNodes: data.startNodes,
|
||
destinationNode: data.destinationNode,
|
||
},
|
||
resultData: {
|
||
pinData: data.pinData,
|
||
// Set this to null so `createRunExecutionData` doesn't initialize it.
|
||
// Otherwise this would be treated as a partial execution.
|
||
runData: data.runData ?? null,
|
||
},
|
||
manualData: {
|
||
userId: data.userId,
|
||
dirtyNodeNames: data.dirtyNodeNames,
|
||
triggerToStartFrom: data.triggerToStartFrom,
|
||
// A run of a single tool node needs this to reach the worker, or the
|
||
// tool runs on empty arguments.
|
||
agentRequest: data.agentRequest,
|
||
source: data.source,
|
||
},
|
||
// Set this to null so `createRunExecutionData` doesn't initialize it.
|
||
// Otherwise this would be treated as a resumed execution after waiting.
|
||
executionData: null,
|
||
});
|
||
}
|
||
|
||
const executionId = await this.workflowRunner.run(data);
|
||
return { executionId };
|
||
}
|
||
|
||
throw new UnexpectedError('`executeManually` was called with an unexpected payload', {
|
||
extra: { payload },
|
||
});
|
||
}
|
||
|
||
async executeChatWorkflow(
|
||
user: User,
|
||
workflowData: IWorkflowBase,
|
||
executionData: IRunExecutionData,
|
||
httpResponse?: Response,
|
||
streamingEnabled?: boolean,
|
||
executionMode: WorkflowExecuteMode = 'chat',
|
||
pushRef?: string,
|
||
) {
|
||
const { projectId, projectName } = await getWorkflowProjectDetailsSafe(
|
||
this.ownershipService,
|
||
workflowData.id,
|
||
);
|
||
|
||
const data: IWorkflowExecutionDataProcess = {
|
||
userId: user.id,
|
||
executionMode,
|
||
workflowData,
|
||
executionData,
|
||
streamingEnabled,
|
||
httpResponse,
|
||
pushRef,
|
||
projectId,
|
||
projectName,
|
||
};
|
||
|
||
const executionId = await this.workflowRunner.run(data, undefined, true);
|
||
|
||
this.eventService.emit('workflow-executed', {
|
||
user: { id: user.id },
|
||
workflowId: workflowData.id,
|
||
workflowName: workflowData.name,
|
||
executionId,
|
||
projectId,
|
||
projectName,
|
||
source: 'chat',
|
||
});
|
||
|
||
return {
|
||
executionId,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Loads the workflow to run as an error workflow, with its production
|
||
* nodes/connections applied. Returns `null` (after logging) when the workflow
|
||
* cannot be found or has no published/active version.
|
||
*/
|
||
private async loadErrorWorkflowData(
|
||
workflowId: string,
|
||
workflowErrorData: IWorkflowErrorData,
|
||
): Promise<WorkflowEntity | null> {
|
||
const notActiveError = `Calling Error Workflow for "${workflowErrorData.workflow.id}". Workflow "${workflowId}" is not active and cannot be executed`;
|
||
|
||
// Load the workflow with its production version in a single query, using
|
||
// the published nodes/connections for execution rather than the draft.
|
||
if (this.workflowsConfig.useWorkflowPublicationService) {
|
||
// Behind the flag, the published version comes from the
|
||
// workflow_published_version mapping. A null result means the workflow
|
||
// has no published version (not found or not published) — not active.
|
||
const publishedData =
|
||
await this.workflowPublishedDataService.getPublishedWorkflowData(workflowId);
|
||
if (publishedData === null) {
|
||
this.logger.error(notActiveError, { workflowId });
|
||
return null;
|
||
}
|
||
const workflowData = publishedData.workflow;
|
||
workflowData.nodes = publishedData.publishedVersion.nodes;
|
||
workflowData.connections = publishedData.publishedVersion.connections;
|
||
return workflowData;
|
||
}
|
||
|
||
const loaded = await this.workflowRepository.get(
|
||
{ id: workflowId },
|
||
{ relations: ['activeVersion'] },
|
||
);
|
||
if (loaded === null) {
|
||
this.logger.error(
|
||
`Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find workflow "${workflowId}"`,
|
||
{ workflowId },
|
||
);
|
||
return null;
|
||
}
|
||
if (loaded.activeVersion === null) {
|
||
this.logger.error(notActiveError, { workflowId });
|
||
return null;
|
||
}
|
||
loaded.nodes = loaded.activeVersion.nodes;
|
||
loaded.connections = loaded.activeVersion.connections;
|
||
return loaded;
|
||
}
|
||
|
||
/** Executes an error workflow */
|
||
async executeErrorWorkflow(
|
||
workflowId: string,
|
||
workflowErrorData: IWorkflowErrorData,
|
||
runningProject: Project,
|
||
): Promise<void> {
|
||
// Wrap everything in try/catch to make sure that no errors bubble up and all get caught here
|
||
try {
|
||
const executionMode = 'error';
|
||
|
||
const workflowData = await this.loadErrorWorkflowData(workflowId, workflowErrorData);
|
||
if (workflowData === null) return;
|
||
|
||
const workflowInstance = new Workflow({
|
||
id: workflowId,
|
||
name: workflowData.name,
|
||
nodeTypes: this.nodeTypes,
|
||
nodes: workflowData.nodes,
|
||
connections: workflowData.connections,
|
||
active: true,
|
||
staticData: workflowData.staticData,
|
||
settings: workflowData.settings,
|
||
});
|
||
|
||
try {
|
||
const failedNode = workflowErrorData.execution?.lastNodeExecuted
|
||
? workflowInstance.getNode(workflowErrorData.execution?.lastNodeExecuted)
|
||
: undefined;
|
||
await this.subworkflowPolicyChecker.check(
|
||
workflowInstance,
|
||
workflowErrorData.workflow.id!,
|
||
failedNode ?? undefined,
|
||
);
|
||
} catch (error) {
|
||
const initialNode = workflowInstance.getStartNode();
|
||
if (initialNode) {
|
||
const errorWorkflowPermissionError = new SubworkflowOperationError(
|
||
`Another workflow: (ID ${workflowErrorData.workflow.id}) tried to invoke this workflow to handle errors.`,
|
||
"Unfortunately current permissions do not allow this. Please check that this workflow's settings allow it to be called by others",
|
||
);
|
||
|
||
// Create a fake execution and save it to DB.
|
||
const fakeExecution = this.failedRunFactory.generateFailedExecutionFromError(
|
||
'error',
|
||
errorWorkflowPermissionError,
|
||
initialNode,
|
||
);
|
||
|
||
const fullExecutionData: CreateExecutionPayload = {
|
||
data: fakeExecution.data,
|
||
mode: fakeExecution.mode,
|
||
finished: false,
|
||
stoppedAt: new Date(),
|
||
workflowData,
|
||
waitTill: null,
|
||
status: fakeExecution.status,
|
||
workflowId: workflowData.id,
|
||
};
|
||
|
||
await this.executionPersistence.create(fullExecutionData);
|
||
}
|
||
this.logger.info('Error workflow execution blocked due to subworkflow settings', {
|
||
erroredWorkflowId: workflowErrorData.workflow.id,
|
||
errorWorkflowId: workflowId,
|
||
});
|
||
return;
|
||
}
|
||
|
||
let node: INode;
|
||
let workflowStartNode: INode | undefined;
|
||
const { errorTriggerType } = this.globalConfig.nodes;
|
||
for (const nodeName of Object.keys(workflowInstance.nodes)) {
|
||
node = workflowInstance.nodes[nodeName];
|
||
if (node.type === errorTriggerType) {
|
||
workflowStartNode = node;
|
||
}
|
||
}
|
||
|
||
if (workflowStartNode === undefined) {
|
||
this.logger.error(
|
||
`Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find "${errorTriggerType}" in workflow "${workflowId}"`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const parentExecution =
|
||
workflowErrorData.execution?.id && workflowErrorData.workflow?.id
|
||
? {
|
||
executionId: workflowErrorData.execution.id,
|
||
workflowId: workflowErrorData.workflow.id,
|
||
executionContext: workflowErrorData.execution.executionContext,
|
||
shouldResume: false, // Error workflows must not resume the failed parent workflow
|
||
}
|
||
: undefined;
|
||
|
||
// Can execute without webhook so go on
|
||
// Initialize the data of the webhook node
|
||
const nodeExecutionStack: IExecuteData[] = [];
|
||
nodeExecutionStack.push({
|
||
node: workflowStartNode,
|
||
data: {
|
||
main: [
|
||
[
|
||
{
|
||
json: workflowErrorData,
|
||
},
|
||
],
|
||
],
|
||
},
|
||
source: null,
|
||
...(parentExecution && {
|
||
metadata: {
|
||
parentExecution,
|
||
},
|
||
}),
|
||
});
|
||
|
||
const runExecutionData = createRunExecutionData({
|
||
executionData: {
|
||
nodeExecutionStack,
|
||
},
|
||
parentExecution,
|
||
});
|
||
|
||
const runData: IWorkflowExecutionDataProcess = {
|
||
executionMode,
|
||
executionData: runExecutionData,
|
||
workflowData,
|
||
projectId: runningProject.id,
|
||
projectName: runningProject.name,
|
||
};
|
||
|
||
const executionId = await this.workflowRunner.run(runData);
|
||
|
||
this.eventService.emit('workflow-executed', {
|
||
workflowId,
|
||
workflowName: workflowData.name,
|
||
executionId,
|
||
projectId: runningProject.id,
|
||
projectName: runningProject.name,
|
||
source: 'error',
|
||
});
|
||
} catch (error) {
|
||
this.errorReporter.error(error);
|
||
this.logger.error(
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||
`Calling Error Workflow for "${workflowErrorData.workflow.id}": "${error.message}"`,
|
||
{ workflowId: workflowErrorData.workflow.id },
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Select the pinned trigger node to use as starter for a manual execution.
|
||
*
|
||
* Finds all pinned trigger nodes in the workflow, then returns the first pinned trigger
|
||
* that is a parent of the destination node. Webhook triggers are prioritized over other
|
||
* trigger types in the sorting order.
|
||
*
|
||
* @param workflow The workflow containing the nodes and connections
|
||
* @param destinationNode The name of the node to find a pinned trigger for
|
||
* @param pinData Pin data mapping node names to their pinned data
|
||
* @returns The pinned trigger node if found, undefined otherwise
|
||
*/
|
||
selectPinnedTrigger(
|
||
workflow: IWorkflowBase,
|
||
destinationNode: string,
|
||
pinData: IPinData,
|
||
): INode | undefined {
|
||
const allPinnedTriggers = this.findAllPinnedTriggers(workflow, pinData);
|
||
|
||
if (allPinnedTriggers.length === 0) return undefined;
|
||
|
||
const destinationParents = new Set(
|
||
new Workflow({
|
||
nodes: workflow.nodes,
|
||
connections: workflow.connections,
|
||
active: workflow.activeVersionId !== null,
|
||
nodeTypes: this.nodeTypes,
|
||
}).getParentNodes(destinationNode),
|
||
);
|
||
|
||
const trigger = allPinnedTriggers.find((a) => destinationParents.has(a.name));
|
||
|
||
return trigger;
|
||
}
|
||
|
||
private findAllPinnedTriggers(workflow: IWorkflowBase, pinData?: IPinData) {
|
||
return workflow.nodes
|
||
.filter(
|
||
(node) =>
|
||
!node.disabled &&
|
||
pinData?.[node.name] &&
|
||
['trigger', 'webhook'].some((suffix) => node.type.toLowerCase().endsWith(suffix)) &&
|
||
node.type !== 'n8n-nodes-base.respondToWebhook',
|
||
)
|
||
.sort((a) => (a.type.endsWith('webhook') ? -1 : 1));
|
||
}
|
||
|
||
/**
|
||
* Checks if there is enough run data to run this as a partial execution and
|
||
* that we're not having the edge case that the destination node itself is a
|
||
* trigger.
|
||
*/
|
||
private partialExecutionFulfilsPreconditions(
|
||
workflowData: IWorkflowBase,
|
||
payload: WorkflowRequest.PartialManualExecutionToDestinationPayload,
|
||
): boolean {
|
||
// If the destination is a trigger node, we treat it as a full execution.
|
||
if (this.isDestinationNodeATrigger(payload.destinationNode.nodeName, workflowData)) {
|
||
return false;
|
||
}
|
||
|
||
// If we have enough run data to reach the destination from a trigger it's a partial execution.
|
||
// Otherwise it's a full execution.
|
||
return anyReachableRootHasRunData(
|
||
DirectedGraph.fromNodesAndConnections(workflowData.nodes, workflowData.connections),
|
||
payload.destinationNode.nodeName,
|
||
payload.runData,
|
||
);
|
||
}
|
||
|
||
/** Production triggers still run on a protected instance. Block only user-started manual runs. */
|
||
private assertManualExecutionAllowed() {
|
||
if (this.instanceWriteAccess.isReadOnly()) {
|
||
throw new ForbiddenError(
|
||
'Cannot run workflows manually on a protected instance. This instance is in read-only mode.',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Type guard to check if payload is a PartialManualExecutionToDestinationPayload.
|
||
*
|
||
* A partial execution payload has both `destinationNode` and `runData`.
|
||
* This indicates execution to a specific node using existing run data.
|
||
*/
|
||
function isPartialExecution(
|
||
payload: WorkflowRequest.ManualRunPayload,
|
||
): payload is WorkflowRequest.PartialManualExecutionToDestinationPayload {
|
||
return (
|
||
'runData' in payload && payload.runData !== undefined && payload.destinationNode !== undefined
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Type guard to check if payload is a FullManualExecutionFromKnownTriggerPayload.
|
||
*
|
||
* A known trigger payload has `triggerToStartFrom` specified but no `runData`.
|
||
* This indicates the user has selected a specific trigger node to start the execution from.
|
||
*/
|
||
function isFullExecutionFromKnownTrigger(
|
||
payload: WorkflowRequest.ManualRunPayload,
|
||
): payload is WorkflowRequest.FullManualExecutionFromKnownTriggerPayload {
|
||
return 'triggerToStartFrom' in payload && payload.triggerToStartFrom !== undefined;
|
||
}
|
||
|
||
/**
|
||
* Type guard to check if payload is a FullManualExecutionFromUnknownTriggerPayload.
|
||
*
|
||
* An unknown trigger payload has a `destinationNode` to work back from,
|
||
* but neither `triggerToStartFrom` nor `runData`.
|
||
* The trigger will need to be determined.
|
||
*/
|
||
function isFullExecutionFromUnknownTrigger(
|
||
payload: WorkflowRequest.ManualRunPayload,
|
||
): payload is WorkflowRequest.FullManualExecutionFromUnknownTriggerPayload {
|
||
if ('triggerToStartFrom' in payload && payload.triggerToStartFrom !== undefined) {
|
||
return false;
|
||
}
|
||
if ('runData' in payload && payload.runData !== undefined) {
|
||
return false;
|
||
}
|
||
return payload.destinationNode !== undefined;
|
||
}
|
||
|
||
function triggerHasNoPinnedData(
|
||
workflowData: IWorkflowBase,
|
||
payload: WorkflowRequest.FullManualExecutionFromKnownTriggerPayload,
|
||
) {
|
||
return workflowData.pinData?.[payload.triggerToStartFrom.name] === undefined;
|
||
}
|
||
|
||
function upgradeToFullManualExecutionFromUnknownTrigger(
|
||
payload: WorkflowRequest.PartialManualExecutionToDestinationPayload,
|
||
): WorkflowRequest.FullManualExecutionFromUnknownTriggerPayload {
|
||
// If the payload has runData or executionData, remove them to convert to full execution.
|
||
return {
|
||
destinationNode: payload.destinationNode,
|
||
agentRequest: payload.agentRequest,
|
||
};
|
||
}
|