diff --git a/packages/cli/src/execution-lifecycle/execute-error-workflow.ts b/packages/cli/src/execution-lifecycle/execute-error-workflow.ts index af7ab46bf12..9b9de6a9d81 100644 --- a/packages/cli/src/execution-lifecycle/execute-error-workflow.ts +++ b/packages/cli/src/execution-lifecycle/execute-error-workflow.ts @@ -50,6 +50,7 @@ export function executeErrorWorkflow( lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!, mode, retryOf, + executionContext: fullRunData.data.executionData?.runtimeData, }, workflow: { id: workflowId, diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index 3d01cccc031..ae9bdc26826 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -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; diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index f41c645916b..a372e01060e 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -352,6 +352,7 @@ export class WorkflowExecutionService { ? { executionId: workflowErrorData.execution.id, workflowId: workflowErrorData.workflow.id, + executionContext: workflowErrorData.execution.executionContext, } : undefined; diff --git a/packages/cli/test/integration/execution-context-propagation.test.ts b/packages/cli/test/integration/execution-context-propagation.test.ts new file mode 100644 index 00000000000..f404381aa15 --- /dev/null +++ b/packages/cli/test/integration/execution-context-propagation.test.ts @@ -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; + + 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 { + 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'); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/execution-context-helpers.ts b/packages/cli/test/integration/shared/execution-context-helpers.ts new file mode 100644 index 00000000000..2dd6b24fe04 --- /dev/null +++ b/packages/cli/test/integration/shared/execution-context-helpers.ts @@ -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); +} diff --git a/packages/cli/test/integration/shared/workflow-fixtures.ts b/packages/cli/test/integration/shared/workflow-fixtures.ts new file mode 100644 index 00000000000..386efb8f3de --- /dev/null +++ b/packages/cli/test/integration/shared/workflow-fixtures.ts @@ -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: {}, + }; +} diff --git a/packages/core/src/execution-engine/__tests__/execution-context.test.ts b/packages/core/src/execution-engine/__tests__/execution-context.test.ts index e18b6096cad..e4c56bd6da5 100644 --- a/packages/core/src/execution-engine/__tests__/execution-context.test.ts +++ b/packages/core/src/execution-engine/__tests__/execution-context.test.ts @@ -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({ 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({ 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({ 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({ + 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({ + 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({ + 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({ 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'); + }); + }); }); diff --git a/packages/core/src/execution-engine/execution-context.ts b/packages/core/src/execution-engine/execution-context.ts index 32d6c941159..54b2f218e43 100644 --- a/packages/core/src/execution-engine/execution-context.ts +++ b/packages/core/src/execution-engine/execution-context.ts @@ -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: * ``` * * @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. diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 630dc9dd8a9..063e3e8f572 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -125,6 +125,19 @@ export class BaseExecuteContext extends NodeExecutionContext { parentExecution?: RelatedExecution; }, ): Promise { + 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, diff --git a/packages/core/src/execution-engine/node-execution-context/hook-context.ts b/packages/core/src/execution-engine/node-execution-context/hook-context.ts index 7b4e806b6cc..84bf787cd1a 100644 --- a/packages/core/src/execution-engine/node-execution-context/hook-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/hook-context.ts @@ -31,10 +31,6 @@ export class HookContext extends NodeExecutionContext implements IHookFunctions this.helpers = getRequestHelperFunctions(workflow, node, additionalData); } - getExecutionContext() { - return undefined; - } - getActivationMode() { return this.activation; } diff --git a/packages/core/src/execution-engine/node-execution-context/load-options-context.ts b/packages/core/src/execution-engine/node-execution-context/load-options-context.ts index 1d6fa24c61c..16edcb6294e 100644 --- a/packages/core/src/execution-engine/node-execution-context/load-options-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/load-options-context.ts @@ -33,10 +33,6 @@ export class LoadOptionsContext extends NodeExecutionContext implements ILoadOpt }; } - getExecutionContext() { - return undefined; - } - async getCredentials(type: string) { return await this._getCredentials(type); } diff --git a/packages/core/src/execution-engine/node-execution-context/poll-context.ts b/packages/core/src/execution-engine/node-execution-context/poll-context.ts index 6edc0424515..80221d3774a 100644 --- a/packages/core/src/execution-engine/node-execution-context/poll-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/poll-context.ts @@ -46,10 +46,6 @@ export class PollContext extends NodeExecutionContext implements IPollFunctions }; } - getExecutionContext() { - return undefined; - } - getActivationMode() { return this.activation; } diff --git a/packages/core/src/execution-engine/node-execution-context/trigger-context.ts b/packages/core/src/execution-engine/node-execution-context/trigger-context.ts index 80af1cdd079..2ed78196447 100644 --- a/packages/core/src/execution-engine/node-execution-context/trigger-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/trigger-context.ts @@ -48,10 +48,6 @@ export class TriggerContext extends NodeExecutionContext implements ITriggerFunc }; } - getExecutionContext() { - return undefined; - } - getActivationMode() { return this.activation; } diff --git a/packages/workflow/src/execution-context.ts b/packages/workflow/src/execution-context.ts index ee9585ea7df..14950ee8248 100644 --- a/packages/workflow/src/execution-context.ts +++ b/packages/workflow/src/execution-context.ts @@ -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 diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 292f2339185..df78354602e 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -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 = {