mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 14:57:21 +02:00
chore(core): Propagate execution context (#21880)
This commit is contained in:
parent
d9e2dc2166
commit
ec5e17ff4b
|
|
@ -50,6 +50,7 @@ export function executeErrorWorkflow(
|
|||
lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!,
|
||||
mode,
|
||||
retryOf,
|
||||
executionContext: fullRunData.data.executionData?.runtimeData,
|
||||
},
|
||||
workflow: {
|
||||
id: workflowId,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ export class WorkflowExecutionService {
|
|||
? {
|
||||
executionId: workflowErrorData.execution.id,
|
||||
workflowId: workflowErrorData.workflow.id,
|
||||
executionContext: workflowErrorData.execution.executionContext,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
306
packages/cli/test/integration/shared/workflow-fixtures.ts
Normal file
306
packages/cli/test/integration/shared/workflow-fixtures.ts
Normal 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: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -31,10 +31,6 @@ export class HookContext extends NodeExecutionContext implements IHookFunctions
|
|||
this.helpers = getRequestHelperFunctions(workflow, node, additionalData);
|
||||
}
|
||||
|
||||
getExecutionContext() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,6 @@ export class PollContext extends NodeExecutionContext implements IPollFunctions
|
|||
};
|
||||
}
|
||||
|
||||
getExecutionContext() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,10 +48,6 @@ export class TriggerContext extends NodeExecutionContext implements ITriggerFunc
|
|||
};
|
||||
}
|
||||
|
||||
getExecutionContext() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getActivationMode() {
|
||||
return this.activation;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user