chore(core): Establish execution context for workflows (#21729)

This commit is contained in:
Andreas Fitzek 2025-11-13 17:46:09 +01:00 committed by GitHub
parent b3af602ed0
commit 7f976f4399
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 672 additions and 31 deletions

View File

@ -0,0 +1,345 @@
import { mock } from 'jest-mock-extended';
import {
UnexpectedError,
type INode,
type IRunExecutionData,
type IWorkflowExecuteAdditionalData,
type Workflow,
type WorkflowExecuteMode,
} from 'n8n-workflow';
import { establishExecutionContext } from '../execution-context';
describe('establishExecutionContext', () => {
const mockWorkflow = mock<Workflow>({ id: 'test-workflow-id' });
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>();
const mockMode: WorkflowExecuteMode = 'manual';
describe('successful context establishment', () => {
it('should establish context with version 1 and timestamp', async () => {
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: {},
},
};
const beforeTimestamp = Date.now();
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode);
const afterTimestamp = Date.now();
expect(runExecutionData.executionData!.runtimeData).toBeDefined();
expect(runExecutionData.executionData!.runtimeData).toHaveProperty('version', 1);
expect(runExecutionData.executionData!.runtimeData).toHaveProperty('establishedAt');
expect(runExecutionData.executionData!.runtimeData).toHaveProperty('source', 'manual');
const establishedAt = runExecutionData.executionData!.runtimeData!.establishedAt;
expect(establishedAt).toBeGreaterThanOrEqual(beforeTimestamp);
expect(establishedAt).toBeLessThanOrEqual(afterTimestamp);
});
it('should mutate the provided runExecutionData object', async () => {
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: {},
},
};
const referenceBefore = runExecutionData.executionData;
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode);
const referenceAfter = runExecutionData.executionData;
// Verify the same object reference is used (mutation, not replacement)
expect(referenceBefore).toBe(referenceAfter);
expect(runExecutionData.executionData!.runtimeData).toBeDefined();
});
it('should establish context when execution stack has multiple nodes', async () => {
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.start' });
const secondNode = mock<INode>({ name: 'Second', type: 'n8n-nodes-base.set' });
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: {
main: [[{ json: {} }]],
},
source: null,
},
{
node: secondNode,
data: {
main: [[{ json: {} }]],
},
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode);
expect(runExecutionData.executionData!.runtimeData).toBeDefined();
expect(runExecutionData.executionData!.runtimeData!.version).toBe(1);
});
});
describe('Chat Trigger workflow support', () => {
it('should establish basic context for empty execution stack (Chat Trigger)', async () => {
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [], // Empty stack - Chat Trigger workflow
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
const beforeTimestamp = Date.now();
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode);
const afterTimestamp = Date.now();
// Should establish basic context even with empty stack
expect(runExecutionData.executionData!.runtimeData).toBeDefined();
expect(runExecutionData.executionData!.runtimeData!.version).toBe(1);
expect(runExecutionData.executionData!.runtimeData!.source).toBe('manual');
expect(runExecutionData.executionData!.runtimeData!.establishedAt).toBeGreaterThanOrEqual(
beforeTimestamp,
);
expect(runExecutionData.executionData!.runtimeData!.establishedAt).toBeLessThanOrEqual(
afterTimestamp,
);
});
it('should establish basic context without start node extraction for Chat Trigger', async () => {
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [], // Empty stack
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode);
const context = runExecutionData.executionData!.runtimeData;
// Verify context has only basic properties (no start-node-specific extraction)
expect(Object.keys(context!)).toEqual(['version', 'establishedAt', 'source']);
expect(typeof context!.version).toBe('number');
expect(typeof context!.establishedAt).toBe('number');
expect(context!.source).toBe('manual');
});
});
describe('error handling', () => {
it('should throw error when executionData is missing', async () => {
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
// executionData is missing
};
await expect(
establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode),
).rejects.toThrow(UnexpectedError);
});
it('should throw error when executionData is undefined', async () => {
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: undefined,
};
await expect(
establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode),
).rejects.toThrow(UnexpectedError);
});
});
describe('context structure validation', () => {
it('should create context with correct structure', async () => {
const startNode = mock<INode>({ name: 'Webhook', type: 'n8n-nodes-base.webhook' });
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: {
main: [[{ json: { test: 'data' } }]],
},
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mockMode);
const context = runExecutionData.executionData!.runtimeData;
// Verify context has only expected properties
expect(Object.keys(context!)).toEqual(['version', 'establishedAt', 'source']);
expect(typeof context!.version).toBe('number');
expect(typeof context!.establishedAt).toBe('number');
expect(context!.source).toBe('manual');
});
it('should create unique timestamps for different executions', async () => {
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.start' });
const runExecutionData1: IRunExecutionData = {
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: { main: [[{ json: {} }]] },
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
const runExecutionData2: IRunExecutionData = {
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: { main: [[{ json: {} }]] },
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
await establishExecutionContext(
mockWorkflow,
runExecutionData1,
mockAdditionalData,
mockMode,
);
// Small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 5));
await establishExecutionContext(
mockWorkflow,
runExecutionData2,
mockAdditionalData,
mockMode,
);
const timestamp1 = runExecutionData1.executionData!.runtimeData!.establishedAt;
const timestamp2 = runExecutionData2.executionData!.runtimeData!.establishedAt;
expect(timestamp1).toBeLessThan(timestamp2);
});
it('should work with different execution modes', async () => {
const startNode = mock<INode>({ name: 'Trigger', type: 'n8n-nodes-base.cron' });
const modes: WorkflowExecuteMode[] = ['manual', 'trigger', 'webhook'];
for (const mode of modes) {
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: startNode,
data: { main: [[{ json: {} }]] },
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, mode);
expect(runExecutionData.executionData!.runtimeData).toBeDefined();
expect(runExecutionData.executionData!.runtimeData!.version).toBe(1);
expect(runExecutionData.executionData!.runtimeData!.source).toBe(mode);
}
});
});
});

View File

@ -122,6 +122,14 @@ describe('WorkflowExecute', () => {
expect(result.finished).toEqual(true);
expect(result.data.executionData!.contextData).toEqual({});
expect(result.data.executionData!.nodeExecutionStack).toEqual([]);
// Check if execution context was established
expect(result.data.executionData!.runtimeData).toBeDefined();
expect(result.data.executionData!.runtimeData).toHaveProperty('version', 1);
expect(result.data.executionData!.runtimeData).toHaveProperty('establishedAt');
expect(result.data.executionData!.runtimeData).toHaveProperty('source');
expect(result.data.executionData!.runtimeData!.source).toEqual('manual');
expect(typeof result.data.executionData!.runtimeData!.establishedAt).toBe('number');
expect(result.data.executionData!.runtimeData!.establishedAt).toBeGreaterThan(0);
});
}
});
@ -191,6 +199,15 @@ describe('WorkflowExecute', () => {
expect(result.finished).toEqual(true);
expect(result.data.executionData!.contextData).toEqual({});
expect(result.data.executionData!.nodeExecutionStack).toEqual([]);
// Check if execution context was established
expect(result.data.executionData!.runtimeData).toBeDefined();
expect(result.data.executionData!.runtimeData).toHaveProperty('version', 1);
expect(result.data.executionData!.runtimeData).toHaveProperty('establishedAt');
expect(result.data.executionData!.runtimeData).toHaveProperty('source');
expect(result.data.executionData!.runtimeData!.source).toEqual('manual');
expect(typeof result.data.executionData!.runtimeData!.establishedAt).toBe('number');
expect(result.data.executionData!.runtimeData!.establishedAt).toBeGreaterThan(0);
});
}
});
@ -405,6 +422,15 @@ describe('WorkflowExecute', () => {
expect(result.finished).toEqual(true);
// expect(result.data.executionData!.contextData).toEqual({}); //Fails when test workflow Includes splitInbatches
expect(result.data.executionData!.nodeExecutionStack).toEqual([]);
// Check if execution context was established
expect(result.data.executionData!.runtimeData).toBeDefined();
expect(result.data.executionData!.runtimeData).toHaveProperty('version', 1);
expect(result.data.executionData!.runtimeData).toHaveProperty('establishedAt');
expect(result.data.executionData!.runtimeData).toHaveProperty('source');
expect(result.data.executionData!.runtimeData!.source).toEqual('manual');
expect(typeof result.data.executionData!.runtimeData!.establishedAt).toBe('number');
expect(result.data.executionData!.runtimeData!.establishedAt).toBeGreaterThan(0);
});
}
});

View File

@ -0,0 +1,119 @@
import {
type IWorkflowExecuteAdditionalData,
type WorkflowExecuteMode,
type IRunExecutionData,
type Workflow,
} from 'n8n-workflow';
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.
*
* @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.)
*
* @returns Promise that resolves when context has been established
*
* @throws {UnexpectedError} When `runExecutionData.executionData` is missing or invalid
*
* @remarks
* ## Mutation Behavior
* This function mutates the provided `runExecutionData` object by setting:
* - `runExecutionData.executionData.runtimeData` with the newly created 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
*
* ## Chat Trigger Support
* 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.
*
* ## Future Enhancements
* The function is designed to support extracting context information from:
* - Start node parameters (e.g., webhook authentication tokens)
* - Start node type (trigger, manual, webhook, etc.)
* - Input data from triggering events
* - User identification from various sources
*
* ## Example Usage
* ```typescript
* await establishExecutionContext(workflow, runExecutionData, additionalData, mode);
* // Context is now available in: runExecutionData.executionData.runtimeData
* ```
*
* @see IExecutionContextV1 for context structure definition
* @see IRunExecutionData for execution data structure
* @see IWorkflowExecuteAdditionalData for additional execution data
*/
export const establishExecutionContext = async (
workflow: Workflow,
runExecutionData: IRunExecutionData,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
): Promise<void> => {
assertExecutionDataExists(runExecutionData.executionData, workflow, additionalData, mode);
const executionData = runExecutionData.executionData;
// 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.
executionData.runtimeData = {
version: 1,
establishedAt: Date.now(),
source: mode,
};
// Next, we attempt to extract additional context from the start node of the execution stack.
const [startItem] = executionData.nodeExecutionStack;
// The nodeExecutionStack is typically initialized in one of three ways:
// 1. run() method: Creates stack with start node (workflow-execute.ts:143-157)
// 2. runPartialWorkflow2(): Recreates stack from existing runData via recreateNodeExecutionStack()
// 3. Constructor with executionData: Pre-populated from caller (resume scenarios)
//
// However, the stack CAN be legitimately empty for workflows containing only Chat Trigger nodes
// (see workflow-execute.ts:1368-1369). In such cases, we cannot extract context from a start
// node, but we should still establish basic execution context.
//
// We cannot extract user specific information from the initial item though. So we exit early.
if (!startItem) {
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.
// the comments are left here for now to provide more context on how
// this can be achieved.
// startNodeParameters will hold the parameters of the start node
// this can be the settings for the different hooks to be executed
// for example to extract the bearer token from the start node data.
// const startNodeParameters = startItem.node.parameters;
// startNodeType holds the type of the start node
// const startNodeType = startItem.node.type;
// Main input data is an array of items, each item represents an event that triggers the workflow execution
// The 'main' selector selects the input name of the nodes, and the 0 index represents the runIndex,
// 0 being the first run of this node in the workflow.
// const mainInput = startItem.data["main"][0];
// based on startNodeParameters, startNodeType and mainInput we can now
// iterate over the different hooks to extract specific data for the runtime context
};

View File

@ -59,8 +59,10 @@ import PCancelable from 'p-cancelable';
import { ErrorReporter } from '@/errors/error-reporter';
import { WorkflowHasIssuesError } from '@/errors/workflow-has-issues.error';
import * as NodeExecuteFunctions from '@/node-execute-functions';
import { assertExecutionDataExists } from '@/utils/assertions';
import { isJsonCompatible } from '@/utils/is-json-compatible';
import { establishExecutionContext } from './execution-context';
import type { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
import { ExecuteContext, PollContext } from './node-execution-context';
import {
@ -1325,22 +1327,6 @@ export class WorkflowExecute {
);
}
private assertExecutionDataExists(
this: WorkflowExecute,
executionData: IRunExecutionData['executionData'],
workflow: Workflow,
): asserts executionData is NonNullable<IRunExecutionData['executionData']> {
if (!executionData) {
throw new UnexpectedError('Failed to run workflow due to missing execution data', {
extra: {
workflowId: workflow.id,
executionId: this.additionalData.executionId,
mode: this.mode,
},
});
}
}
/**
* Handles executions that have been waiting by
* 1. unsetting the `waitTill`
@ -1354,7 +1340,12 @@ export class WorkflowExecute {
if (this.runExecutionData.waitTill) {
this.runExecutionData.waitTill = undefined;
this.assertExecutionDataExists(this.runExecutionData.executionData, workflow);
assertExecutionDataExists(
this.runExecutionData.executionData,
workflow,
this.additionalData,
this.mode,
);
this.runExecutionData.executionData.nodeExecutionStack[0].node.disabled = true;
const lastNodeExecuted = this.runExecutionData.resultData.lastNodeExecuted as string;
@ -1363,7 +1354,12 @@ export class WorkflowExecute {
}
private checkForWorkflowIssues(workflow: Workflow): void {
this.assertExecutionDataExists(this.runExecutionData.executionData, workflow);
assertExecutionDataExists(
this.runExecutionData.executionData,
workflow,
this.additionalData,
this.mode,
);
// Node execution stack will be empty for an execution containing only Chat
// Trigger.
const startNode = this.runExecutionData.executionData.nodeExecutionStack.at(0)?.node.name;
@ -1487,6 +1483,14 @@ export class WorkflowExecute {
// eslint-disable-next-line complexity
const returnPromise = (async () => {
try {
// Establish the execution context
await establishExecutionContext(
workflow,
this.runExecutionData,
this.additionalData,
this.mode,
);
if (!this.additionalData.restartExecutionId) {
await hooks.runHook('workflowExecuteBefore', [workflow, this.runExecutionData]);
}

View File

@ -0,0 +1,109 @@
import { mock } from 'jest-mock-extended';
import type { IRunExecutionData, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import { assertExecutionDataExists } from '../assertions';
describe('assertExecutionDataExists', () => {
const mockWorkflow = mock<Workflow>({ id: 'test-workflow-123' });
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: 'test-execution-456',
});
const mode = 'manual';
describe('when executionData is valid', () => {
it('should not throw when executionData exists', () => {
const validExecutionData = {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
};
expect(() =>
assertExecutionDataExists(validExecutionData, mockWorkflow, mockAdditionalData, mode),
).not.toThrow();
});
it('should not throw when executionData has minimal properties', () => {
const minimalExecutionData = {
nodeExecutionStack: [],
};
expect(() =>
assertExecutionDataExists(
minimalExecutionData as unknown as IRunExecutionData['executionData'],
mockWorkflow,
mockAdditionalData,
mode,
),
).not.toThrow();
});
});
describe('when executionData is missing', () => {
it('should throw UnexpectedError when executionData is undefined', () => {
expect(() =>
assertExecutionDataExists(undefined, mockWorkflow, mockAdditionalData, mode),
).toThrow(UnexpectedError);
});
it('should throw UnexpectedError when executionData is null', () => {
expect(() =>
assertExecutionDataExists(
null as unknown as IRunExecutionData['executionData'],
mockWorkflow,
mockAdditionalData,
mode,
),
).toThrow(UnexpectedError);
});
it('should throw error with correct message', () => {
expect(() =>
assertExecutionDataExists(undefined, mockWorkflow, mockAdditionalData, mode),
).toThrow(/missing execution data/i);
});
it('should include workflow metadata in error', () => {
try {
assertExecutionDataExists(undefined, mockWorkflow, mockAdditionalData, mode);
fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(UnexpectedError);
const unexpectedError = error as UnexpectedError;
expect(unexpectedError.extra).toEqual({
workflowId: 'test-workflow-123',
executionId: 'test-execution-456',
mode: 'manual',
});
}
});
});
describe('with different execution modes', () => {
it('should work with trigger mode', () => {
const validExecutionData = { nodeExecutionStack: [] };
expect(() =>
assertExecutionDataExists(
validExecutionData as unknown as IRunExecutionData['executionData'],
mockWorkflow,
mockAdditionalData,
'trigger',
),
).not.toThrow();
});
it('should include mode in error metadata', () => {
try {
assertExecutionDataExists(undefined, mockWorkflow, mockAdditionalData, 'webhook');
fail('Expected error to be thrown');
} catch (error) {
const unexpectedError = error as UnexpectedError;
expect(unexpectedError.extra?.mode).toBe('webhook');
}
});
});
});

View File

@ -0,0 +1,24 @@
import {
type IRunExecutionData,
type IWorkflowExecuteAdditionalData,
UnexpectedError,
type Workflow,
type WorkflowExecuteMode,
} from 'n8n-workflow';
export function assertExecutionDataExists(
executionData: IRunExecutionData['executionData'],
workflow: Workflow,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
): asserts executionData is NonNullable<IRunExecutionData['executionData']> {
if (!executionData) {
throw new UnexpectedError('Failed to run workflow due to missing execution data', {
extra: {
workflowId: workflow.id,
executionId: additionalData.executionId,
mode,
},
});
}
}

View File

@ -30,12 +30,33 @@ export const CredentialContextSchema = z
*/
export type ICredentialContext = z.output<typeof CredentialContextSchema>;
const WorkflowExecuteModeSchema = z.union([
z.literal('cli'),
z.literal('error'),
z.literal('integrated'),
z.literal('internal'),
z.literal('manual'),
z.literal('retry'),
z.literal('trigger'),
z.literal('webhook'),
z.literal('evaluation'),
z.literal('chat'),
]);
export type WorkflowExecuteModeValues = z.infer<typeof WorkflowExecuteModeSchema>;
const ExecutionContextSchemaV1 = z.object({
version: z.literal(1),
/**
* When the context was established (Unix timestamp in milliseconds)
*/
establishedAt: z.number(),
/**
* The mode in which the workflow is being executed
*/
source: WorkflowExecuteModeSchema,
/**
* Encrypted credential context for dynamic credential resolution
* Always encrypted when stored, decrypted on-demand by credential resolver

View File

@ -24,11 +24,16 @@ import type { NodeApiError } from './errors/node-api.error';
import type { NodeOperationError } from './errors/node-operation.error';
import type { WorkflowActivationError } from './errors/workflow-activation.error';
import type { WorkflowOperationError } from './errors/workflow-operation.error';
import type {
IExecutionContext,
WorkflowExecuteModeValues as WorkflowExecuteMode,
} from './execution-context';
import type { ExecutionStatus } from './execution-status';
import type { Result } from './result';
import type { Workflow } from './workflow';
import type { EnvProviderState } from './workflow-data-proxy-env-provider';
import type { IExecutionContext } from './execution-context';
export type { WorkflowExecuteModeValues as WorkflowExecuteMode } from './execution-context';
export interface IAdditionalCredentialOptions {
oauth2?: IOAuth2Options;
@ -2723,18 +2728,6 @@ export interface IWorkflowExecuteAdditionalData {
): Promise<Result<T, E>>;
}
export type WorkflowExecuteMode =
| 'cli'
| 'error'
| 'integrated'
| 'internal'
| 'manual'
| 'retry'
| 'trigger'
| 'webhook'
| 'evaluation'
| 'chat';
export type WorkflowActivateMode =
| 'init'
| 'create' // unused