chore(core): Propagate execution context (#21880)

This commit is contained in:
Andreas Fitzek 2025-11-17 10:23:39 +01:00 committed by GitHub
parent d9e2dc2166
commit ec5e17ff4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1696 additions and 32 deletions

View File

@ -50,6 +50,7 @@ export function executeErrorWorkflow(
lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!,
mode,
retryOf,
executionContext: fullRunData.data.executionData?.runtimeData,
},
workflow: {
id: workflowId,

View File

@ -15,6 +15,7 @@ import type {
ExecutionStatus,
ExecutionSummary,
IWorkflowExecutionDataProcess,
IExecutionContext,
} from 'n8n-workflow';
import type PCancelable from 'p-cancelable';
@ -138,6 +139,7 @@ export interface IWorkflowErrorData {
error: ExecutionError;
lastNodeExecuted: string;
mode: WorkflowExecuteMode;
executionContext?: IExecutionContext;
};
trigger?: {
error: ExecutionError;

View File

@ -352,6 +352,7 @@ export class WorkflowExecutionService {
? {
executionId: workflowErrorData.execution.id,
workflowId: workflowErrorData.workflow.id,
executionContext: workflowErrorData.execution.executionContext,
}
: undefined;

View File

@ -0,0 +1,443 @@
/**
* Integration tests for execution context propagation across workflows.
* These tests verify that execution context is properly inherited by sub-workflows,
* error workflows, and preserved during workflow resume scenarios.
*/
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
import { ExecutionRepository, type IWorkflowDb } from '@n8n/db';
import { Container } from '@n8n/di';
import { readFileSync } from 'fs';
import { UnrecognizedNodeTypeError } from 'n8n-core';
import type { IExecutionContext, INodeType, INodeTypeData, NodeLoadingDetails } from 'n8n-workflow';
import path from 'path';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { createOwner } from './shared/db/users';
import * as utils from './shared/utils';
import {
createSubWorkflowFixture,
createParentWorkflowFixture,
createMiddleWorkflowFixture,
createSimpleWorkflowFixture,
} from './shared/workflow-fixtures';
import {
validateRootContext,
validateChildContextInheritance,
validateContextInheritanceChain,
validateBasicContextStructure,
} from './shared/execution-context-helpers';
// ============================================================
// Helper to load nodes from dist folder
// ============================================================
const BASE_DIR = path.resolve(__dirname, '../../..');
function loadNodesFromDist(nodeNames: string[]): INodeTypeData {
const nodeTypes: INodeTypeData = {};
const knownNodes = JSON.parse(
readFileSync(path.join(BASE_DIR, 'nodes-base/dist/known/nodes.json'), 'utf-8'),
) as Record<string, NodeLoadingDetails>;
for (const nodeName of nodeNames) {
const loadInfo = knownNodes[nodeName.replace('n8n-nodes-base.', '')];
if (!loadInfo) {
throw new UnrecognizedNodeTypeError('n8n-nodes-base', nodeName);
}
// Load from dist .js files (sourcePath already includes 'dist/')
const nodeDistPath = path.join(BASE_DIR, 'nodes-base', loadInfo.sourcePath);
const node = new (require(nodeDistPath)[loadInfo.className])() as INodeType;
nodeTypes[nodeName] = {
sourcePath: '',
type: node,
};
}
return nodeTypes;
}
// Fixtures are now imported from './shared/workflow-fixtures'
describe('Execution Context Propagation Integration Tests', () => {
let owner: any;
let workflowExecutionService: WorkflowExecutionService;
let executionRepository: ExecutionRepository;
beforeAll(async () => {
await testDb.init();
owner = await createOwner();
// Load required node types from dist folder
const nodeTypes = loadNodesFromDist([
'n8n-nodes-base.manualTrigger',
'n8n-nodes-base.executeWorkflow',
'n8n-nodes-base.executeWorkflowTrigger',
]);
await utils.initNodeTypes(nodeTypes);
await utils.initBinaryDataService();
workflowExecutionService = Container.get(WorkflowExecutionService);
executionRepository = Container.get(ExecutionRepository);
});
afterEach(async () => {
await testDb.truncate(['ExecutionEntity', 'WorkflowEntity']);
});
afterAll(async () => {
await testDb.terminate();
});
// ============================================================
// Helper Functions
// ============================================================
/**
* Wait for an execution to complete by polling the database
*/
async function waitForExecution(executionId: string, timeout = 10000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
const execution = await executionRepository.findOneBy({ id: executionId });
if (execution?.finished) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Execution ${executionId} did not complete within ${timeout}ms`);
}
/**
* Get execution data including the full execution context from the database
*/
async function getExecutionWithData(executionId: string) {
// Use ExecutionRepository's method to properly deserialize execution data
const executionWithData = await executionRepository.findSingleExecution(executionId, {
includeData: true,
unflattenData: true,
});
if (!executionWithData) {
throw new Error(`Execution ${executionId} not found`);
}
return executionWithData;
}
// ============================================================
// Tests
// ============================================================
describe('Sub-workflow context propagation', () => {
it('should propagate context from parent to child workflow', async () => {
// ============================================================
// SETUP: Create Child Workflow
// ============================================================
const childWorkflow = await createWorkflow(
{
name: 'Child Workflow',
...createSubWorkflowFixture(),
} as any as IWorkflowDb,
owner,
);
// ============================================================
// SETUP: Create Parent Workflow
// ============================================================
const parentWorkflow = await createWorkflow(
{
name: 'Parent Workflow',
...createParentWorkflowFixture(childWorkflow.id),
} as any as IWorkflowDb,
owner,
);
// ============================================================
// EXECUTE: Run Parent Workflow
// ============================================================
const result = await workflowExecutionService.executeManually(
{
workflowData: parentWorkflow,
startNodes: [
{
name: 'Trigger',
sourceData: null,
},
],
destinationNode: undefined,
},
owner,
);
expect(result).toBeDefined();
expect(result.executionId).toBeDefined();
// Wait for parent execution to complete
await waitForExecution(result.executionId!);
// ============================================================
// VERIFY: Fetch Parent Execution and Context
// ============================================================
const parentExecution = await getExecutionWithData(result.executionId!);
const parentContext = parentExecution.data.executionData?.runtimeData as IExecutionContext;
// Validate parent is a root context with manual execution source
validateRootContext(parentContext, 'manual');
// ============================================================
// VERIFY: Find Child Execution
// ============================================================
// Child workflow should have been executed, find it by workflow ID
const childExecutions = await executionRepository.find({
where: { workflowId: childWorkflow.id },
order: { createdAt: 'DESC' },
});
expect(childExecutions.length).toBeGreaterThan(0);
// ============================================================
// VERIFY: Child Execution Context
// ============================================================
const childExecution = await getExecutionWithData(childExecutions[0].id);
const childContext = childExecution.data.executionData?.runtimeData as IExecutionContext;
// Validate child context properly inherits from parent
validateChildContextInheritance(childContext, parentContext, result.executionId!);
// Verify execution finished successfully
expect(parentExecution.status).toBe('success');
expect(childExecution.status).toBe('success');
});
});
describe('Nested sub-workflow propagation', () => {
it('should propagate context through multiple workflow levels', async () => {
// ============================================================
// SETUP: Create Grandchild Workflow (Workflow C)
// ============================================================
const grandchildWorkflow = await createWorkflow(
{
name: 'Grandchild Workflow',
...createSubWorkflowFixture(),
} as any as IWorkflowDb,
owner,
);
// ============================================================
// SETUP: Create Parent Workflow (Workflow B) - calls Grandchild
// ============================================================
const parentWorkflow = await createWorkflow(
{
name: 'Parent Workflow (B)',
...createMiddleWorkflowFixture(grandchildWorkflow.id),
} as any as IWorkflowDb,
owner,
);
// ============================================================
// SETUP: Create Grandparent Workflow (Workflow A) - calls Parent
// ============================================================
const grandparentWorkflow = await createWorkflow(
{
name: 'Grandparent Workflow (A)',
...createParentWorkflowFixture(parentWorkflow.id),
} as any as IWorkflowDb,
owner,
);
// ============================================================
// EXECUTE: Run Grandparent Workflow
// ============================================================
const result = await workflowExecutionService.executeManually(
{
workflowData: grandparentWorkflow,
startNodes: [
{
name: 'Trigger',
sourceData: null,
},
],
destinationNode: undefined,
},
owner,
);
expect(result).toBeDefined();
expect(result.executionId).toBeDefined();
// Wait for grandparent execution to complete
await waitForExecution(result.executionId!);
// ============================================================
// VERIFY: Fetch All Execution Contexts
// ============================================================
const grandparentExecution = await getExecutionWithData(result.executionId!);
const grandparentContext = grandparentExecution.data.executionData
?.runtimeData as IExecutionContext;
const parentExecutions = await executionRepository.find({
where: { workflowId: parentWorkflow.id },
order: { createdAt: 'DESC' },
});
expect(parentExecutions.length).toBeGreaterThan(0);
const parentExecution = await getExecutionWithData(parentExecutions[0].id);
const parentContext = parentExecution.data.executionData?.runtimeData as IExecutionContext;
const grandchildExecutions = await executionRepository.find({
where: { workflowId: grandchildWorkflow.id },
order: { createdAt: 'DESC' },
});
expect(grandchildExecutions.length).toBeGreaterThan(0);
const grandchildExecution = await getExecutionWithData(grandchildExecutions[0].id);
const grandchildContext = grandchildExecution.data.executionData
?.runtimeData as IExecutionContext;
// ============================================================
// VERIFY: Complete Context Inheritance Chain
// ============================================================
validateContextInheritanceChain([
{
context: grandparentContext,
parentExecutionId: undefined,
},
{
context: parentContext,
parentExecutionId: result.executionId,
},
{
context: grandchildContext,
parentExecutionId: parentExecutions[0].id,
},
]);
// ============================================================
// VERIFY: All Executions Finished Successfully
// ============================================================
expect(grandparentExecution.status).toBe('success');
expect(parentExecution.status).toBe('success');
expect(grandchildExecution.status).toBe('success');
});
});
describe('Context isolation', () => {
it('should not leak context between independent workflows', async () => {
// ============================================================
// SETUP: Create Two Independent Workflows
// ============================================================
const workflow1 = await createWorkflow(
{
name: 'Independent Workflow 1',
...createSimpleWorkflowFixture(),
} as any as IWorkflowDb,
owner,
);
const workflow2 = await createWorkflow(
{
name: 'Independent Workflow 2',
...createSimpleWorkflowFixture(),
} as any as IWorkflowDb,
owner,
);
// ============================================================
// EXECUTE: Run Both Workflows Independently
// ============================================================
const result1 = await workflowExecutionService.executeManually(
{
workflowData: workflow1,
startNodes: [
{
name: 'Trigger',
sourceData: null,
},
],
destinationNode: undefined,
},
owner,
);
expect(result1).toBeDefined();
expect(result1.executionId).toBeDefined();
// Wait for first execution to complete
await waitForExecution(result1.executionId!);
const result2 = await workflowExecutionService.executeManually(
{
workflowData: workflow2,
startNodes: [
{
name: 'Trigger',
sourceData: null,
},
],
destinationNode: undefined,
},
owner,
);
expect(result2).toBeDefined();
expect(result2.executionId).toBeDefined();
// Wait for second execution to complete
await waitForExecution(result2.executionId!);
// ============================================================
// VERIFY: Fetch Both Execution Contexts
// ============================================================
const execution1 = await getExecutionWithData(result1.executionId!);
const context1 = execution1.data.executionData?.runtimeData as IExecutionContext;
const execution2 = await getExecutionWithData(result2.executionId!);
const context2 = execution2.data.executionData?.runtimeData as IExecutionContext;
// ============================================================
// VERIFY: Both Contexts Are Root Contexts
// ============================================================
validateRootContext(context1, 'manual');
validateRootContext(context2, 'manual');
// ============================================================
// VERIFY: Complete Context Isolation
// ============================================================
// Both should have valid basic structure
validateBasicContextStructure(context1);
validateBasicContextStructure(context2);
// Both should be root contexts with no parent
expect(context1.parentExecutionId).toBeUndefined();
expect(context2.parentExecutionId).toBeUndefined();
// Both should have manual execution source
expect(context1.source).toBe('manual');
expect(context2.source).toBe('manual');
// Both should have different establishedAt timestamps (since executed sequentially)
expect(context1.establishedAt).not.toBe(context2.establishedAt);
expect(context2.establishedAt).toBeGreaterThanOrEqual(context1.establishedAt);
// Verify no credential sharing between independent workflows
// Both should either have different credentials or no credentials at all
if (context1.credentials && context2.credentials) {
// If both have credentials, they should not be the same reference
expect(context1.credentials).not.toBe(context2.credentials);
}
// ============================================================
// VERIFY: Both Executions Finished Successfully
// ============================================================
expect(execution1.status).toBe('success');
expect(execution2.status).toBe('success');
});
});
});

View File

@ -0,0 +1,200 @@
/**
* Reusable validation helper functions for execution context assertions.
* These functions provide consistent and composable validation patterns
* for testing execution context propagation across workflows.
*/
import type { IExecutionContext } from 'n8n-workflow';
/**
* Validates the basic structure and required fields of an execution context.
*
* @param context - The execution context to validate
* @param expectedVersion - Expected context version (default: 1)
*/
export function validateBasicContextStructure(
context: IExecutionContext,
expectedVersion = 1,
): void {
expect(context).toBeDefined();
expect(context.version).toBe(expectedVersion);
expect(context.establishedAt).toBeDefined();
expect(typeof context.establishedAt).toBe('number');
expect(context.establishedAt).toBeGreaterThan(0);
}
/**
* Validates that a context has the expected source mode.
*
* @param context - The execution context to validate
* @param expectedSource - Expected execution source ('manual', 'trigger', 'integrated', 'internal')
*/
export function validateContextSource(context: IExecutionContext, expectedSource: string): void {
expect(context.source).toBeDefined();
expect(context.source).toBe(expectedSource);
}
/**
* Validates that a context is a root context (no parent execution).
* Root contexts are typically created by manual or scheduled executions.
*
* @param context - The execution context to validate
* @param expectedSource - Expected execution source for the root context
*/
export function validateRootContext(context: IExecutionContext, expectedSource: string): void {
validateBasicContextStructure(context);
validateContextSource(context, expectedSource);
expect(context.parentExecutionId).toBeUndefined();
}
/**
* Validates that a context is a child context with a parent execution ID.
*
* @param context - The child execution context to validate
* @param expectedParentExecutionId - Expected parent execution ID
*/
export function validateChildContextParentage(
context: IExecutionContext,
expectedParentExecutionId: string,
): void {
expect(context).toBeDefined();
expect(context.parentExecutionId).toBe(expectedParentExecutionId);
}
/**
* Validates that a child context properly inherits credentials from its parent.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
*/
export function validateCredentialInheritance(
childContext: IExecutionContext,
parentContext: IExecutionContext,
): void {
if (parentContext.credentials) {
expect(childContext.credentials).toBe(parentContext.credentials);
}
}
/**
* Validates that a child context has a fresh (equal or later) establishedAt timestamp
* compared to its parent context.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
*/
export function validateFreshTimestamp(
childContext: IExecutionContext,
parentContext: IExecutionContext,
): void {
expect(childContext.establishedAt).toBeDefined();
expect(typeof childContext.establishedAt).toBe('number');
expect(childContext.establishedAt).toBeGreaterThanOrEqual(parentContext.establishedAt);
}
/**
* Validates that a child context has the same version as its parent.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
*/
export function validateVersionInheritance(
childContext: IExecutionContext,
parentContext: IExecutionContext,
): void {
expect(childContext.version).toBe(parentContext.version);
}
/**
* Validates that a child context source is one of the expected sub-workflow sources.
*
* @param context - The execution context to validate
* @param allowedSources - Array of allowed source modes (default: ['trigger', 'integrated', 'internal'])
*/
export function validateSubWorkflowSource(
context: IExecutionContext,
allowedSources = ['trigger', 'integrated', 'internal'],
): void {
expect(context.source).toBeDefined();
expect(allowedSources).toContain(context.source);
}
/**
* Comprehensive validation that a child context properly inherits from its parent.
* This combines multiple validation patterns into a single function.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
* @param parentExecutionId - The parent execution ID
*/
export function validateChildContextInheritance(
childContext: IExecutionContext,
parentContext: IExecutionContext,
parentExecutionId: string,
): void {
validateBasicContextStructure(childContext);
validateChildContextParentage(childContext, parentExecutionId);
validateCredentialInheritance(childContext, parentContext);
validateFreshTimestamp(childContext, parentContext);
validateVersionInheritance(childContext, parentContext);
validateSubWorkflowSource(childContext);
}
/**
* Validates a timestamp chain across multiple execution contexts.
* Ensures that each context in the chain has a timestamp greater than or equal
* to the previous context's timestamp.
*
* @param contexts - Array of execution contexts in chronological order
*/
export function validateTimestampChain(contexts: IExecutionContext[]): void {
for (let i = 0; i < contexts.length - 1; i++) {
expect(contexts[i].establishedAt).toBeLessThanOrEqual(contexts[i + 1].establishedAt);
}
}
/**
* Validates that all provided contexts have the same version number.
*
* @param contexts - Array of execution contexts to validate
*/
export function validateConsistentVersions(contexts: IExecutionContext[]): void {
const firstVersion = contexts[0].version;
for (const context of contexts) {
expect(context.version).toBe(firstVersion);
}
}
/**
* Validates a complete context inheritance chain from root to leaf.
* This validates that each child properly inherits from its parent,
* timestamps form a valid chain, and versions are consistent.
*
* @param contextChain - Array of objects containing context and parent execution ID
* First element should be the root (parentExecutionId can be undefined)
*/
export function validateContextInheritanceChain(
contextChain: Array<{
context: IExecutionContext;
parentExecutionId?: string;
}>,
): void {
// Validate root context
const root = contextChain[0];
validateBasicContextStructure(root.context);
expect(root.context.parentExecutionId).toBeUndefined();
// Validate each child in the chain
for (let i = 1; i < contextChain.length; i++) {
const parent = contextChain[i - 1];
const child = contextChain[i];
expect(child.parentExecutionId).toBeDefined();
validateChildContextInheritance(child.context, parent.context, child.parentExecutionId!);
}
// Validate timestamp chain
const contexts = contextChain.map((c) => c.context);
validateTimestampChain(contexts);
validateConsistentVersions(contexts);
}

View File

@ -0,0 +1,306 @@
/**
* Reusable workflow fixtures for execution context propagation tests.
* These fixtures create minimal workflow structures needed for testing.
*/
import { v4 as uuid } from 'uuid';
/**
* Creates a minimal child workflow with Execute Workflow Trigger.
* This is the simplest sub-workflow that can be called by another workflow.
*/
export function createSubWorkflowFixture() {
return {
nodes: [
{
parameters: {
workflowInputs: {
values: [{ name: 'test' }],
},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
],
connections: {},
pinData: {},
};
}
/**
* Creates a workflow with Manual Trigger + Execute Workflow node.
* This workflow can call another workflow (sub-workflow).
*/
export function createParentWorkflowFixture(childWorkflowId: string) {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
workflowId: {
__rl: true,
value: childWorkflowId,
mode: 'list',
cachedResultUrl: `/workflow/${childWorkflowId}`,
cachedResultName: 'Child Workflow',
},
workflowInputs: {
mappingMode: 'defineBelow',
value: { test: 'test' },
matchingColumns: ['level'],
schema: [
{
id: 'test',
displayName: 'test',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
removed: false,
},
],
attemptToConvertTypes: false,
convertFieldsToString: true,
},
options: {},
},
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.3,
position: [208, 0] as [number, number],
id: uuid(),
name: 'Execute Workflow',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'Execute Workflow',
type: 'main',
index: 0,
},
],
],
},
},
pinData: {},
};
}
/**
* Creates a middle-tier workflow with Execute Workflow Trigger + Execute Workflow node.
* This workflow can be called by a parent and can call a child (for nested scenarios).
*/
export function createMiddleWorkflowFixture(childWorkflowId: string) {
return {
nodes: [
{
parameters: {
workflowInputs: {
values: [{ name: 'test' }],
},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
workflowId: {
__rl: true,
value: childWorkflowId,
mode: 'list',
cachedResultUrl: `/workflow/${childWorkflowId}`,
cachedResultName: 'Grandchild Workflow',
},
workflowInputs: {
mappingMode: 'defineBelow',
value: { test: 'test' },
matchingColumns: ['level'],
schema: [
{
id: 'test',
displayName: 'test',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
removed: false,
},
],
attemptToConvertTypes: false,
convertFieldsToString: true,
},
options: {},
},
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.3,
position: [208, 0] as [number, number],
id: uuid(),
name: 'Execute Workflow',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'Execute Workflow',
type: 'main',
index: 0,
},
],
],
},
},
pinData: {},
};
}
/**
* Creates a simple workflow with just Manual Trigger.
* Useful for testing context isolation.
*/
export function createSimpleWorkflowFixture() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
],
connections: {},
pinData: {},
};
}
/**
* Creates an error workflow with Error Trigger node.
* This workflow gets executed when another workflow fails (if configured).
*/
export function createErrorWorkflowFixture() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.errorTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
],
connections: {},
pinData: {},
};
}
/**
* Creates a workflow that throws an error using DebugHelper node.
* Useful for testing error workflow propagation.
*/
export function createFailingWorkflowFixture() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
throwErrorType: 'Error',
throwErrorMessage: 'Test error',
},
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [208, 0] as [number, number],
id: uuid(),
name: 'DebugHelper',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'DebugHelper',
type: 'main',
index: 0,
},
],
],
},
},
pinData: {},
};
}
/**
* Creates a workflow that fails and is configured to trigger an error workflow.
* @param errorWorkflowId - The ID of the error workflow to trigger on failure
*/
export function createWorkflowWithErrorHandlerFixture(errorWorkflowId: string) {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
throwErrorType: 'Error',
throwErrorMessage: 'Test error for error workflow',
},
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [208, 0] as [number, number],
id: uuid(),
name: 'DebugHelper',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'DebugHelper',
type: 'main',
index: 0,
},
],
],
},
},
settings: {
errorWorkflow: errorWorkflowId,
},
pinData: {},
};
}

View File

@ -1,9 +1,11 @@
import { mock } from 'jest-mock-extended';
import {
UnexpectedError,
type IExecutionContext,
type INode,
type IRunExecutionData,
type IWorkflowExecuteAdditionalData,
type RelatedExecution,
type Workflow,
type WorkflowExecuteMode,
} from 'n8n-workflow';
@ -342,4 +344,632 @@ describe('establishExecutionContext', () => {
}
});
});
describe('webhook resume context preservation', () => {
it('should preserve existing context when runtimeData already exists', async () => {
const existingContext: IExecutionContext = {
version: 1,
establishedAt: 1234567890,
source: 'webhook',
credentials: 'encrypted-credentials-data',
};
const startNode = mock<INode>({ name: 'Wait', type: 'n8n-nodes-base.wait' });
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: {
main: [[{ json: {} }]],
},
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
runtimeData: existingContext, // Already has context from database
},
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'manual');
// Context should remain unchanged
expect(runExecutionData.executionData!.runtimeData).toEqual(existingContext);
expect(runExecutionData.executionData!.runtimeData!.establishedAt).toBe(1234567890);
expect(runExecutionData.executionData!.runtimeData!.source).toBe('webhook');
expect(runExecutionData.executionData!.runtimeData!.credentials).toBe(
'encrypted-credentials-data',
);
});
it('should not modify existing context even when mode differs', async () => {
const existingContext: IExecutionContext = {
version: 1,
establishedAt: 9876543210,
source: 'trigger',
credentials: 'original-credentials',
};
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
runtimeData: existingContext,
},
};
// Try to establish with different mode
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'manual');
// Original context should be completely preserved
expect(runExecutionData.executionData!.runtimeData).toEqual(existingContext);
expect(runExecutionData.executionData!.runtimeData!.source).toBe('trigger'); // NOT 'manual'
});
it('should preserve context with parentExecutionId field', async () => {
const existingContext: IExecutionContext = {
version: 1,
establishedAt: 1111111111,
source: 'webhook',
parentExecutionId: 'parent-exec-123',
credentials: 'webhook-credentials',
};
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
runtimeData: existingContext,
},
};
await establishExecutionContext(
mockWorkflow,
runExecutionData,
mockAdditionalData,
'webhook',
);
expect(runExecutionData.executionData!.runtimeData).toEqual(existingContext);
expect(runExecutionData.executionData!.runtimeData!.parentExecutionId).toBe(
'parent-exec-123',
);
});
});
describe('sub-workflow context inheritance', () => {
it('should inherit parent context with fresh establishedAt and source', async () => {
const parentContext: IExecutionContext = {
version: 1,
establishedAt: 1000000000,
source: 'manual',
credentials: 'parent-credentials',
};
const parentExecution: RelatedExecution = {
executionId: 'parent-execution-id',
workflowId: 'parent-workflow-id',
executionContext: parentContext,
};
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.start' });
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: {
main: [[{ json: {} }]],
},
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
parentExecution,
};
const beforeTimestamp = Date.now();
await establishExecutionContext(
mockWorkflow,
runExecutionData,
mockAdditionalData,
'trigger',
);
const afterTimestamp = Date.now();
const childContext = runExecutionData.executionData!.runtimeData!;
// Should inherit credentials from parent
expect(childContext.credentials).toBe('parent-credentials');
expect(childContext.version).toBe(1);
// Should have fresh establishedAt
expect(childContext.establishedAt).toBeGreaterThanOrEqual(beforeTimestamp);
expect(childContext.establishedAt).toBeLessThanOrEqual(afterTimestamp);
expect(childContext.establishedAt).not.toBe(parentContext.establishedAt);
// Should have fresh source matching current mode
expect(childContext.source).toBe('trigger');
expect(childContext.source).not.toBe(parentContext.source);
// Should track parent execution ID
expect(childContext.parentExecutionId).toBe('parent-execution-id');
});
it('should handle missing parent context gracefully', async () => {
const parentExecution: RelatedExecution = {
executionId: 'parent-execution-id',
workflowId: 'parent-workflow-id',
// executionContext is undefined
};
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.start' });
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: {
main: [[{ json: {} }]],
},
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
parentExecution,
};
await establishExecutionContext(
mockWorkflow,
runExecutionData,
mockAdditionalData,
'trigger',
);
const context = runExecutionData.executionData!.runtimeData!;
// Should create basic context without inherited fields
expect(context.version).toBe(1);
expect(context.source).toBe('trigger');
expect(context.establishedAt).toBeDefined();
expect(context.parentExecutionId).toBe('parent-execution-id');
});
it('should inherit custom fields from parent context', async () => {
const parentContext: IExecutionContext = {
version: 1,
establishedAt: 2000000000,
source: 'webhook',
credentials: 'parent-creds',
// Custom field that should be inherited
customField: 'custom-value',
} as IExecutionContext & { customField: string };
const parentExecution: RelatedExecution = {
executionId: 'parent-id',
workflowId: 'parent-wf-id',
executionContext: parentContext,
};
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
parentExecution,
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'manual');
const childContext = runExecutionData.executionData!.runtimeData! as IExecutionContext & {
customField: string;
};
// Custom fields should be inherited
expect(childContext.customField).toBe('custom-value');
expect(childContext.credentials).toBe('parent-creds');
});
it('should work with nested sub-workflows (grandchild)', async () => {
// Parent context (already has parentExecutionId from grandparent)
const parentContext: IExecutionContext = {
version: 1,
establishedAt: 2000000000,
source: 'trigger',
credentials: 'grandparent-credentials', // Inherited
parentExecutionId: 'grandparent-execution-id',
};
const parentExecution: RelatedExecution = {
executionId: 'parent-execution-id',
workflowId: 'parent-workflow-id',
executionContext: parentContext,
};
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
parentExecution,
};
await establishExecutionContext(
mockWorkflow,
runExecutionData,
mockAdditionalData,
'webhook',
);
const grandchildContext = runExecutionData.executionData!.runtimeData!;
// Should inherit credentials from ancestor
expect(grandchildContext.credentials).toBe('grandparent-credentials');
// Should have fresh values
expect(grandchildContext.source).toBe('webhook');
expect(grandchildContext.establishedAt).toBeGreaterThan(parentContext.establishedAt);
// Should track immediate parent
expect(grandchildContext.parentExecutionId).toBe('parent-execution-id');
});
});
describe('error workflow context inheritance', () => {
it('should inherit context from start item metadata', async () => {
const parentContext: IExecutionContext = {
version: 1,
establishedAt: 3000000000,
source: 'trigger',
credentials: 'original-workflow-credentials',
};
const errorTriggerNode = mock<INode>({
name: 'ErrorTrigger',
type: 'n8n-nodes-base.errorTrigger',
});
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: errorTriggerNode,
data: {
main: [[{ json: { error: 'test error' } }]],
},
source: null,
metadata: {
parentExecution: {
executionId: 'failed-execution-id',
workflowId: 'failed-workflow-id',
executionContext: parentContext,
},
},
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
const beforeTimestamp = Date.now();
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'error');
const afterTimestamp = Date.now();
const errorContext = runExecutionData.executionData!.runtimeData!;
// Should inherit credentials from failed workflow
expect(errorContext.credentials).toBe('original-workflow-credentials');
// Should have fresh establishedAt
expect(errorContext.establishedAt).toBeGreaterThanOrEqual(beforeTimestamp);
expect(errorContext.establishedAt).toBeLessThanOrEqual(afterTimestamp);
expect(errorContext.establishedAt).not.toBe(parentContext.establishedAt);
// Should have 'error' as source
expect(errorContext.source).toBe('error');
expect(errorContext.source).not.toBe(parentContext.source);
// Should track parent execution ID
expect(errorContext.parentExecutionId).toBe('failed-execution-id');
});
it('should handle error workflow without parent context', async () => {
const errorTriggerNode = mock<INode>({
name: 'ErrorTrigger',
type: 'n8n-nodes-base.errorTrigger',
});
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: errorTriggerNode,
data: {
main: [[{ json: { error: 'test error' } }]],
},
source: null,
// No metadata with parentExecution
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'error');
const context = runExecutionData.executionData!.runtimeData!;
// Should create basic context
expect(context.version).toBe(1);
expect(context.source).toBe('error');
expect(context.establishedAt).toBeDefined();
expect(context.parentExecutionId).toBeUndefined();
});
it('should inherit context when sub-workflow fails', async () => {
// Sub-workflow already had inherited context from parent
const subWorkflowContext: IExecutionContext = {
version: 1,
establishedAt: 4000000000,
source: 'trigger',
credentials: 'root-workflow-credentials',
parentExecutionId: 'root-execution-id',
};
const errorTriggerNode = mock<INode>({
name: 'ErrorTrigger',
type: 'n8n-nodes-base.errorTrigger',
});
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: errorTriggerNode,
data: {
main: [[{ json: { error: 'sub-workflow error' } }]],
},
source: null,
metadata: {
parentExecution: {
executionId: 'failed-sub-workflow-id',
workflowId: 'sub-workflow-id',
executionContext: subWorkflowContext,
},
},
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'error');
const errorContext = runExecutionData.executionData!.runtimeData!;
// Should inherit root credentials
expect(errorContext.credentials).toBe('root-workflow-credentials');
// Should track the failed sub-workflow
expect(errorContext.parentExecutionId).toBe('failed-sub-workflow-id');
// Should have fresh timing
expect(errorContext.source).toBe('error');
expect(errorContext.establishedAt).toBeGreaterThan(subWorkflowContext.establishedAt);
});
});
describe('context propagation priority and edge cases', () => {
it('should prefer runExecutionData.parentExecution over startItem.metadata', async () => {
// Both sources provide context, runExecutionData.parentExecution should win
const runDataParentContext: IExecutionContext = {
version: 1,
establishedAt: 5000000000,
source: 'manual',
credentials: 'rundata-credentials',
};
const metadataParentContext: IExecutionContext = {
version: 1,
establishedAt: 6000000000,
source: 'trigger',
credentials: 'metadata-credentials',
};
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.start' });
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: {
main: [[{ json: {} }]],
},
source: null,
metadata: {
parentExecution: {
executionId: 'metadata-parent-id',
workflowId: 'metadata-workflow-id',
executionContext: metadataParentContext,
},
},
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
parentExecution: {
executionId: 'rundata-parent-id',
workflowId: 'rundata-workflow-id',
executionContext: runDataParentContext,
},
};
await establishExecutionContext(
mockWorkflow,
runExecutionData,
mockAdditionalData,
'webhook',
);
const context = runExecutionData.executionData!.runtimeData!;
// Should use runExecutionData.parentExecution (higher priority)
expect(context.credentials).toBe('rundata-credentials');
expect(context.parentExecutionId).toBe('rundata-parent-id');
});
it('should handle empty parent execution object', async () => {
const parentExecution: RelatedExecution = {
executionId: '',
workflowId: '',
};
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
parentExecution,
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'manual');
const context = runExecutionData.executionData!.runtimeData!;
// Should create context with empty parentExecutionId
expect(context.version).toBe(1);
expect(context.source).toBe('manual');
expect(context.parentExecutionId).toBe('');
});
it('should not override existing runtimeData even with parent execution', async () => {
const existingContext: IExecutionContext = {
version: 1,
establishedAt: 7000000000,
source: 'webhook',
credentials: 'existing-credentials',
};
const parentContext: IExecutionContext = {
version: 1,
establishedAt: 8000000000,
source: 'manual',
credentials: 'parent-credentials',
};
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
runtimeData: existingContext, // Already exists
},
parentExecution: {
executionId: 'parent-id',
workflowId: 'parent-wf-id',
executionContext: parentContext,
},
};
await establishExecutionContext(
mockWorkflow,
runExecutionData,
mockAdditionalData,
'trigger',
);
// Existing context takes precedence (webhook resume scenario)
expect(runExecutionData.executionData!.runtimeData).toEqual(existingContext);
expect(runExecutionData.executionData!.runtimeData!.credentials).toBe('existing-credentials');
});
});
});

View File

@ -10,34 +10,70 @@ import { assertExecutionDataExists } from '@/utils/assertions';
/**
* Establishes the execution context for a workflow run.
*
* This function creates and initializes the execution context that persists throughout
* the workflow execution lifecycle. The context is stored directly in the provided
* `runExecutionData.executionData.runtimeData` object.
* This function creates or inherits the execution context that persists throughout the workflow
* execution lifecycle. The context is stored in `runExecutionData.executionData.runtimeData`.
*
* @param workflow - The workflow instance being executed (reserved for future context extraction)
* @param runExecutionData - The execution data structure that will be mutated to include the execution context
* @param additionalData - Additional workflow execution data used for validation and future context extraction
* @param mode - The workflow execution mode (manual, trigger, webhook, etc.)
* @param mode - The workflow execution mode (manual, trigger, webhook, error, etc.)
*
* @returns Promise that resolves when context has been established
*
* @throws {UnexpectedError} When `runExecutionData.executionData` is missing or invalid
*
* @remarks
* ## Context Establishment Strategy
*
* The function follows a priority-based approach to establish execution context:
*
* ### 1. Preserve Existing Context (Webhook Resume)
* If `executionData.runtimeData` already exists, the function returns immediately without
* modification. This preserves context when workflows resume from database (e.g., after
* waiting for a webhook or manual continuation).
*
* ### 2. Inherit from Parent Execution (Sub-workflows)
* If `runExecutionData.parentExecution` exists, creates a new context by inheriting all
* fields from the parent context while generating fresh values for:
* - `establishedAt`: Set to current timestamp
* - `source`: Set to current execution mode
* - `parentExecutionId`: Tracks the parent execution ID
*
* This applies to sub-workflows invoked via "Execute Workflow" node.
*
* ### 3. Inherit from Start Node Metadata (Error Workflows)
* If `startItem.metadata.parentExecution.executionContext` exists, creates a new context
* by inheriting from the parent context. This applies to error workflows that need to
* preserve the original workflow's context.
*
* ### 4. Create Fresh Context (New Executions)
* For new root executions, creates a fresh context with:
* - `version`: 1
* - `establishedAt`: Current timestamp
* - `source`: Current execution mode
*
* ## Mutation Behavior
* This function mutates the provided `runExecutionData` object by setting:
* - `runExecutionData.executionData.runtimeData` with the newly created execution context
* This function mutates `runExecutionData.executionData.runtimeData` with the execution context.
*
* ## Context Creation
* - Creates a new context with version 1 schema and current timestamp
* - Establishes basic context for all workflows (including Chat Trigger workflows)
* - Supports future extraction of context information from start node (when available)
* - Validates execution data structure before context creation
* ## Context Inheritance Pattern
* When inheriting context, the strategy is:
* 1. Spread all parent context fields (credentials, custom fields, etc.)
* 2. Override `establishedAt` with current timestamp
* 3. Override `source` with current execution mode
* 4. Add `parentExecutionId` to track lineage
*
* ## Chat Trigger Support
* This ensures child executions reflect their own timing and mode while preserving
* contextual information like credentials and authentication state.
*
* ## Special Cases
*
* ### Chat Trigger Workflows
* Workflows containing only Chat Trigger nodes have an empty `nodeExecutionStack`.
* In such cases, basic context (version + timestamp) is still established, but no
* start-node-specific context extraction is performed.
* Basic context is still established with version and timestamp.
*
* ### Empty Execution Stack
* If no start item exists and no parent context is available, establishes minimal
* context (version, timestamp, source) without additional enrichment.
*
* ## Future Enhancements
* The function is designed to support extracting context information from:
@ -48,13 +84,23 @@ import { assertExecutionDataExists } from '@/utils/assertions';
*
* ## Example Usage
* ```typescript
* await establishExecutionContext(workflow, runExecutionData, additionalData, mode);
* // Context is now available in: runExecutionData.executionData.runtimeData
* // New execution
* await establishExecutionContext(workflow, runExecutionData, additionalData, 'manual');
* // Context: { version: 1, establishedAt: 1234567890, source: 'manual' }
*
* // Sub-workflow execution (with parent context)
* await establishExecutionContext(workflow, runExecutionData, additionalData, 'trigger');
* // Context: { ...parentContext, establishedAt: 9876543210, source: 'trigger', parentExecutionId: 'parent-id' }
*
* // Resumed execution (webhook wait completed)
* await establishExecutionContext(workflow, runExecutionData, additionalData, 'webhook');
* // Context: <preserved from original execution>
* ```
*
* @see IExecutionContextV1 for context structure definition
* @see IRunExecutionData for execution data structure
* @see IWorkflowExecuteAdditionalData for additional execution data
* @see RelatedExecution for parent execution context propagation
*/
export const establishExecutionContext = async (
workflow: Workflow,
@ -66,6 +112,12 @@ export const establishExecutionContext = async (
const executionData = runExecutionData.executionData;
if (executionData.runtimeData) {
// Context is already established, no further action needed.
// This can happen, when a workflow is resumed from the database.
return;
}
// At this point we have established the basic execution context.
// If a context is already established we overwrite it.
// This might change depending on the propagation strategy we want to implement in the future.
@ -75,6 +127,20 @@ export const establishExecutionContext = async (
source: mode,
};
if (runExecutionData.parentExecution) {
// Create a new context by inheriting everything from the parent execution context,
// except for the establishedAt timestamp which we set to now and the source which we set to the current mode.
// This ensures that the child execution context reflects the time it was established
// and the mode in which it is running, while still retaining all other contextual information
// from the parent execution.
executionData.runtimeData = {
...(runExecutionData.parentExecution.executionContext ?? {}),
...executionData.runtimeData,
parentExecutionId: runExecutionData.parentExecution.executionId,
};
return;
}
// Next, we attempt to extract additional context from the start node of the execution stack.
const [startItem] = executionData.nodeExecutionStack;
@ -92,6 +158,17 @@ export const establishExecutionContext = async (
return;
}
// We were triggered from a parent execution
// and can inherit context from there
if (startItem.metadata?.parentExecution?.executionContext) {
executionData.runtimeData = {
...startItem.metadata.parentExecution.executionContext,
...executionData.runtimeData,
parentExecutionId: startItem.metadata.parentExecution.executionId,
};
return;
}
// TODO: the following comments will be implemented in future iterations
// to extract more context information based on the start node
// and the data that triggered the workflow execution.

View File

@ -125,6 +125,19 @@ export class BaseExecuteContext extends NodeExecutionContext {
parentExecution?: RelatedExecution;
},
): Promise<ExecuteWorkflowData> {
if (options?.parentExecution) {
// We inject the execution context of the current execution
// to the sub-workflow so that it can be accessed there
// this should only happen for the direct parent execution
// if a workflow starts a sub-workflow for a workflow that is not itself
// then the context should not be passed down
if (
!options.parentExecution.executionContext &&
options.parentExecution.executionId === this.getExecutionId()
) {
options.parentExecution.executionContext = this.getExecutionContext();
}
}
const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
...options,
parentWorkflowId: this.workflow.id,

View File

@ -31,10 +31,6 @@ export class HookContext extends NodeExecutionContext implements IHookFunctions
this.helpers = getRequestHelperFunctions(workflow, node, additionalData);
}
getExecutionContext() {
return undefined;
}
getActivationMode() {
return this.activation;
}

View File

@ -33,10 +33,6 @@ export class LoadOptionsContext extends NodeExecutionContext implements ILoadOpt
};
}
getExecutionContext() {
return undefined;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
}

View File

@ -46,10 +46,6 @@ export class PollContext extends NodeExecutionContext implements IPollFunctions
};
}
getExecutionContext() {
return undefined;
}
getActivationMode() {
return this.activation;
}

View File

@ -48,10 +48,6 @@ export class TriggerContext extends NodeExecutionContext implements ITriggerFunc
};
}
getExecutionContext() {
return undefined;
}
getActivationMode() {
return this.activation;
}

View File

@ -57,6 +57,12 @@ const ExecutionContextSchemaV1 = z.object({
*/
source: WorkflowExecuteModeSchema,
/**
* Optional ID of the parent execution, if this is set this
* execution context inherited from the mentioned parent execution context.
*/
parentExecutionId: z.string().optional(),
/**
* Encrypted credential context for dynamic credential resolution
* Always encrypted when stored, decrypted on-demand by credential resolver

View File

@ -2486,6 +2486,7 @@ export interface RelatedExecution {
workflowId: string;
// In the case of a parent execution, whether the parent should be resumed when the sub execution finishes.
shouldResume?: boolean;
executionContext?: IExecutionContext;
}
type SubNodeExecutionDataAction = {