feat(ai-builder): Verify approval-loop branches with scripted gate decisions (#34822)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
José Braulio González Valido 2026-07-30 12:05:51 +01:00 committed by GitHub
parent fcaadfa375
commit 00fe448a68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1175 additions and 34 deletions

View File

@ -86,3 +86,41 @@ describe('prepareVerificationRun — halted wait gates', () => {
expect(result.prepared.verificationPinData?.['Email Approval']).toEqual([]);
});
});
describe('prepareVerificationRun — gate scripts', () => {
it('exposes the script that matches a halted gate', () => {
const script = {
nodeName: 'Email Approval',
cutEdge: { source: 'Revise', target: 'Format' },
decisions: [{ label: 'approve', items: [{ data: { approved: true } }] }],
};
const result = prepareVerificationRun(
makeBuildOutcome({ nodeSimulationPlan: [gateVerdict], waitGateScripts: [script] }),
undefined,
);
expect(result.kind).toBe('ready');
if (result.kind !== 'ready') return;
expect(result.prepared.gateScript).toEqual(script);
});
it('ignores scripts that do not match a halted gate', () => {
const result = prepareVerificationRun(
makeBuildOutcome({
nodeSimulationPlan: [plainVerdict],
waitGateScripts: [
{
nodeName: 'Some Other Node',
cutEdge: { source: 'A', target: 'B' },
decisions: [{ label: 'x', items: [{}] }],
},
],
}),
undefined,
);
expect(result.kind).toBe('ready');
if (result.kind !== 'ready') return;
expect(result.prepared.gateScript).toBeUndefined();
});
});

View File

@ -0,0 +1,164 @@
import type {
WaitGateScript,
WorkflowBuildOutcome,
} from '../../../../workflow-loop/workflow-loop-state';
import type { PreparedVerificationRun } from '../prepare-run';
import { runScriptedGateVerification, type ScriptedGateRunArgs } from '../scripted-gate-run';
function makeBuildOutcome(overrides: Partial<WorkflowBuildOutcome> = {}): WorkflowBuildOutcome {
return {
workItemId: 'wi_1',
taskId: 'task_1',
workflowId: 'wf_1',
submitted: true,
triggerType: 'manual_or_testable',
needsUserInput: false,
summary: 'Built',
...overrides,
};
}
const script: WaitGateScript = {
nodeName: 'Gate',
cutEdge: { source: 'Revise', target: 'Format' },
decisions: [
{ label: 'approve', items: [{ data: { approved: true } }] },
{ label: 'decline', items: [{ data: { approved: false } }] },
],
};
const prepared: PreparedVerificationRun = {
verificationPinData: { Gate: [], Generate: [{ text: 'draft' }] },
simulatedNodes: [
{ nodeName: 'Gate', reason: 'gate' },
{ nodeName: 'Generate', reason: 'no credentials' },
],
haltedGateNames: ['Gate'],
gateScript: script,
};
const approvePassResult = {
executionId: 'exec-approve',
status: 'success',
executedNodeNames: ['Trigger', 'Generate', 'Gate', 'Router', 'Publish'],
lastNodeExecuted: 'Publish',
data: { Publish: [{ ok: true }] },
};
const declinePassResult = {
executionId: 'exec-decline',
status: 'success',
executedNodeNames: ['Trigger', 'Generate', 'Gate', 'Router', 'Revise'],
lastNodeExecuted: 'Revise',
data: { Revise: [{ text: 'revised' }] },
};
function makeArgs(runMock: ReturnType<typeof vi.fn>): ScriptedGateRunArgs {
return {
script,
prepared,
executionService: { run: runMock } as unknown as ScriptedGateRunArgs['executionService'],
workflowId: 'wf_1',
timeout: 30_000,
buildOutcome: makeBuildOutcome({
nodeSimulationPlan: [
{
nodeName: 'Gate',
verdict: 'simulate',
reason: 'gate',
confidence: 'high',
source: 'deterministic',
haltBranch: true,
},
],
}),
stateBefore: undefined,
runId: 'run-1',
};
}
describe('runScriptedGateVerification', () => {
it('runs one pass per decision with the gate pin swapped and the loop edge cut', async () => {
const run = vi
.fn()
.mockResolvedValueOnce(approvePassResult)
.mockResolvedValueOnce(declinePassResult);
const { result, analysis } = await runScriptedGateVerification(makeArgs(run));
expect(run).toHaveBeenCalledTimes(2);
type RunOptions = {
verificationPinData?: Record<string, unknown[]>;
omitConnections?: Array<{ source: string; target: string }>;
};
const optionsOfCall = (index: number): RunOptions =>
(run.mock.calls[index] as unknown[])[2] as RunOptions;
const firstOptions = optionsOfCall(0);
const secondOptions = optionsOfCall(1);
expect(firstOptions.verificationPinData?.Gate).toEqual([{ data: { approved: true } }]);
expect(secondOptions.verificationPinData?.Gate).toEqual([{ data: { approved: false } }]);
// Base pins for other simulated nodes ride along on every pass.
expect(firstOptions.verificationPinData?.Generate).toEqual([{ text: 'draft' }]);
expect(firstOptions.omitConnections).toEqual([{ source: 'Revise', target: 'Format' }]);
expect(analysis.success).toBe(true);
expect(analysis.nodesExecuted).toEqual(expect.arrayContaining(['Publish', 'Revise']));
expect(analysis.coverageNote).toContain('"approve" ended at "Publish"');
expect(analysis.coverageNote).toContain('"decline" ended at "Revise"');
expect(analysis.coverageNote).toContain('"Revise" → "Format"');
expect(result.executedNodeNames).toEqual(expect.arrayContaining(['Publish', 'Revise']));
});
it('fails the merged analysis when any pass fails', async () => {
const run = vi
.fn()
.mockResolvedValueOnce(approvePassResult)
.mockResolvedValueOnce({
...declinePassResult,
status: 'error',
error: 'expression error in Revise',
});
const { analysis } = await runScriptedGateVerification(makeArgs(run));
expect(analysis.success).toBe(false);
expect(analysis.errorMessage).toContain('expression error');
expect(analysis.remediation).toBeDefined();
});
});
describe('runScriptedGateVerification — merge consistency', () => {
it('normalizes the merged status when a pass fails on node errors despite engine success', async () => {
const run = vi
.fn()
.mockResolvedValueOnce(approvePassResult)
.mockResolvedValueOnce({
...declinePassResult,
status: 'success',
nodeErrors: [{ nodeName: 'Revise', message: 'expression error' }],
});
const { result, analysis } = await runScriptedGateVerification(makeArgs(run));
expect(analysis.success).toBe(false);
expect(result.status).toBe('error');
});
it('discloses simulated nodes reached only in an earlier pass', async () => {
const run = vi
.fn()
.mockResolvedValueOnce(approvePassResult)
.mockResolvedValueOnce(declinePassResult);
const args = makeArgs(run);
args.prepared = {
...prepared,
simulatedNodes: [...prepared.simulatedNodes, { nodeName: 'Publish', reason: 'no creds' }],
};
const { analysis } = await runScriptedGateVerification(args);
// Publish is reached only by the approve pass; the decline pass ran last.
expect(analysis.simulationNote).toContain('Publish');
});
});

View File

@ -199,7 +199,7 @@ function classifyVerificationFailure(
});
}
function buildSimulationNote(
export function buildSimulationNote(
reachedSimulatedNodes: Array<{ nodeName: string; reason: string }>,
planMissing: boolean,
): string | undefined {

View File

@ -1,12 +1,17 @@
import type { VerifyBuiltWorkflowOutput, VerifyToolInput } from './types';
import { createRemediation } from '../../../workflow-loop/remediation';
import type { WorkflowBuildOutcome } from '../../../workflow-loop/workflow-loop-state';
import type {
WaitGateScript,
WorkflowBuildOutcome,
} from '../../../workflow-loop/workflow-loop-state';
export interface PreparedVerificationRun {
verificationPinData: Record<string, unknown[]> | undefined;
simulatedNodes: Array<{ nodeName: string; reason: string }>;
/** Wait-gate nodes pinned with zero items — verification halts at these. */
haltedGateNames: string[];
/** When set, verify runs one scripted pass per decision instead of halting. */
gateScript?: WaitGateScript;
}
function getInvalidFixtureOverrideNodeNames(
@ -61,10 +66,15 @@ function buildVerificationPinData(
}
}
const gateScript = (buildOutcome.waitGateScripts ?? []).find((script) =>
haltedGateNames.includes(script.nodeName),
);
return {
verificationPinData: Object.keys(merged).length > 0 ? merged : undefined,
simulatedNodes,
haltedGateNames,
...(gateScript ? { gateScript } : {}),
};
}
@ -83,9 +93,10 @@ export function prepareVerificationRun(
if (haltedOverrideNodeNames.length > 0) {
const guidance =
`Node(s) ${haltedOverrideNodeNames.join(', ')} pause the workflow for a human decision and sit on a loop — ` +
'verification always halts there and their output cannot be overridden: a canned response would ' +
're-run the loop with the same answer forever. Treat reaching the gate as verified and tell the ' +
'user the approval loop needs a manual end-to-end test.';
'their output cannot be overridden: a constant canned response would re-run the loop with the same ' +
'answer forever. Verification drives such gates with scripted decisions where possible, or halts at ' +
'them otherwise. Treat the scripted/halted result as authoritative and tell the user the approval ' +
'loop needs a manual end-to-end test for final confirmation.';
const remediation = createRemediation({
category: 'blocked',
shouldEdit: false,

View File

@ -0,0 +1,154 @@
/**
* Scripted wait-gate verification: one execution per gate decision, each on an
* ephemeral workflow copy with the script's loop edge removed so no decision
* can re-enter the gate. Pass results merge into a single verification
* analysis success requires every pass to succeed, coverage is the union.
*/
import {
analyzeVerificationResult,
buildSimulationNote,
type VerificationAnalysis,
} from './analyze-result';
import type { PreparedVerificationRun } from './prepare-run';
import type { ExecutionRunResult } from './types';
import type { OrchestrationContext } from '../../../types';
import type {
WaitGateScript,
WorkflowBuildOutcome,
WorkflowLoopState,
} from '../../../workflow-loop/workflow-loop-state';
type VerificationExecutionService = NonNullable<
OrchestrationContext['domainContext']
>['executionService'];
export interface ScriptedGateRunArgs {
script: WaitGateScript;
prepared: PreparedVerificationRun;
executionService: VerificationExecutionService;
workflowId: string;
inputData?: Record<string, unknown>;
timeout?: number;
abortSignal?: AbortSignal;
buildOutcome: WorkflowBuildOutcome;
stateBefore: WorkflowLoopState | undefined;
runId: string;
}
interface DecisionPass {
label: string;
result: ExecutionRunResult;
analysis: VerificationAnalysis;
}
export async function runScriptedGateVerification(
args: ScriptedGateRunArgs,
): Promise<{ result: ExecutionRunResult; analysis: VerificationAnalysis }> {
const { script, prepared, executionService, workflowId, buildOutcome, stateBefore, runId } = args;
const basePins = prepared.verificationPinData ?? {};
const passes: DecisionPass[] = [];
for (const decision of script.decisions) {
const result = await executionService.run(workflowId, args.inputData, {
timeout: args.timeout,
verificationPinData: { ...basePins, [script.nodeName]: decision.items },
omitConnections: [script.cutEdge],
abortSignal: args.abortSignal,
});
const analysis = analyzeVerificationResult({
result,
buildOutcome,
simulatedNodes: prepared.simulatedNodes,
stateBefore,
runId,
});
passes.push({ label: decision.label, result, analysis });
}
return { result: mergeResults(passes), analysis: mergeAnalyses(script, prepared, passes) };
}
function mergeResults(passes: DecisionPass[]): ExecutionRunResult {
const last = passes[passes.length - 1];
const failing = passes.find((pass) => !pass.analysis.success);
const data: Record<string, unknown> = {};
const executedNodeNames = new Set<string>();
for (const pass of passes) {
Object.assign(data, pass.result.data ?? {});
for (const name of pass.analysis.reachedNames) executedNodeNames.add(name);
}
// A pass can fail on node errors while its engine status is still
// 'success' — normalize so the merged status never contradicts `success`.
const failingStatus = failing
? failing.result.status === 'success'
? 'error'
: failing.result.status
: undefined;
return {
...last.result,
status: failingStatus ?? last.result.status,
data: Object.keys(data).length > 0 ? data : undefined,
executedNodeNames: [...executedNodeNames],
error: failing?.result.error ?? failing?.analysis.errorMessage,
};
}
function mergeAnalyses(
script: WaitGateScript,
prepared: PreparedVerificationRun,
passes: DecisionPass[],
): VerificationAnalysis {
const reachedNames = new Set<string>();
for (const pass of passes) {
for (const name of pass.analysis.reachedNames) reachedNames.add(name);
}
const failing = passes.find((pass) => !pass.analysis.success);
const nodesNotReached = [
...new Set(passes.flatMap((pass) => pass.analysis.nodesNotReached)),
].filter((name) => !reachedNames.has(name));
const nodeErrors = passes.flatMap((pass) =>
pass.analysis.nodeErrors.map((nodeError) => ({
...nodeError,
message: `[decision: ${pass.label}] ${nodeError.message ?? 'node execution failed'}`,
})),
);
const endings = passes
.map(
(pass) =>
`"${pass.label}" ended at ${pass.result.lastNodeExecuted ? `"${pass.result.lastNodeExecuted}"` : 'an unknown node'}`,
)
.join('; ');
const coverageNote =
`Scripted gate verification: ran ${String(passes.length)} decision pass(es) through ` +
`"${script.nodeName}" with loop edge "${script.cutEdge.source}" → "${script.cutEdge.target}" ` +
`removed so each pass is loop-safe (${endings}). The gate's send/wait behaviour itself was ` +
'simulated — recommend a live end-to-end test for final confirmation.' +
(nodesNotReached.length > 0
? ` ${String(nodesNotReached.length)} planned node(s) not reached by any pass: ` +
`${nodesNotReached.join(', ')}. If a decision should route to them, inspect the ` +
'branch wiring before editing.'
: '');
// Rebuild the note from the union — a node simulated only in an earlier
// pass must still be disclosed.
const reachedSimulatedNodes = prepared.simulatedNodes.filter((node) =>
reachedNames.has(node.nodeName),
);
return {
success: passes.every((pass) => pass.analysis.success),
reachedNames,
reachedSimulatedNodes,
nodesNotReached,
remediation: failing?.analysis.remediation,
nodesExecuted: [...reachedNames],
simulationNote: buildSimulationNote(reachedSimulatedNodes, false),
coverageNote,
errorMessage: failing?.analysis.errorMessage,
nodeErrors,
};
}

View File

@ -18,6 +18,7 @@ import {
import { prepareVerificationRun } from './verification/prepare-run';
import { reconcileStaleCredentialPlan } from './verification/reconcile-plan';
import { resolveVerificationTarget } from './verification/resolve-target';
import { runScriptedGateVerification } from './verification/scripted-gate-run';
import { executionNodeErrorSchema } from '../../workflow-loop/workflow-loop-state';
const DEFAULT_NODE_PREVIEW_CHARS = 600;
@ -157,24 +158,43 @@ export function createVerifyBuiltWorkflowTool(context: OrchestrationContext) {
}
const { prepared } = preparedResult;
const result = await target.domainContext.executionService.run(
workflowId,
resolvedInput.inputData,
{
timeout: resolvedInput.timeout,
verificationPinData: prepared.verificationPinData,
abortSignal: context.abortSignal,
},
);
const analysis = analyzeVerificationResult({
result,
buildOutcome,
simulatedNodes: prepared.simulatedNodes,
haltedGateNames: prepared.haltedGateNames,
stateBefore: target.stateBefore,
runId: context.runId,
});
// A scripted gate replaces the halt with one loop-safe pass per decision;
// otherwise run the single standard pass (halted gates pin zero items).
const { result, analysis } = prepared.gateScript
? await runScriptedGateVerification({
script: prepared.gateScript,
prepared,
executionService: target.domainContext.executionService,
workflowId,
inputData: resolvedInput.inputData,
timeout: resolvedInput.timeout,
abortSignal: context.abortSignal,
buildOutcome,
stateBefore: target.stateBefore,
runId: context.runId,
})
: await (async () => {
const runResult = await target.domainContext.executionService.run(
workflowId,
resolvedInput.inputData,
{
timeout: resolvedInput.timeout,
verificationPinData: prepared.verificationPinData,
abortSignal: context.abortSignal,
},
);
return {
result: runResult,
analysis: analyzeVerificationResult({
result: runResult,
buildOutcome,
simulatedNodes: prepared.simulatedNodes,
haltedGateNames: prepared.haltedGateNames,
stateBefore: target.stateBefore,
runId: context.runId,
}),
};
})();
await persistVerificationOutcome({
input: resolvedInput,
context,

View File

@ -428,3 +428,70 @@ describe('planVerificationSimulation — wait-gate halt verdicts', () => {
).toBeUndefined();
});
});
describe('planVerificationSimulation — wait-gate scripts', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGenerateFixtures.mockResolvedValue({});
});
it('derives a decision script for a halted send-and-wait gate', async () => {
mockClassify.mockResolvedValue([
{
nodeName: 'Email Approval',
verdict: 'simulate',
reason: 'Credentials are not configured for this node',
confidence: 'high',
source: 'deterministic',
},
]);
const workflow = {
name: 'approval loop',
nodes: [
{
id: 'id-0',
name: 'Every day',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'id-1',
name: 'Email Approval',
type: 'n8n-nodes-base.gmail',
typeVersion: 2,
position: [100, 0],
parameters: { operation: 'sendAndWait', responseType: 'approval' },
},
{
id: 'id-2',
name: 'Revise',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [200, 0],
parameters: {},
},
],
connections: {
'Every day': { main: [[{ node: 'Email Approval', type: 'main', index: 0 }]] },
'Email Approval': { main: [[{ node: 'Revise', type: 'main', index: 0 }]] },
Revise: { main: [[{ node: 'Email Approval', type: 'main', index: 0 }]] },
},
} as unknown as WorkflowJSON;
const { nodeSimulationPlan, waitGateScripts } = await planVerificationSimulation({
workflow,
workflowId: 'wf-1',
});
expect(nodeSimulationPlan?.find((v) => v.nodeName === 'Email Approval')?.haltBranch).toBe(true);
expect(waitGateScripts).toHaveLength(1);
expect(waitGateScripts?.[0]).toMatchObject({
nodeName: 'Email Approval',
cutEdge: { source: 'Revise', target: 'Email Approval' },
});
expect(waitGateScripts?.[0].decisions.map((d) => d.label)).toEqual(['approve', 'decline']);
});
});

View File

@ -0,0 +1,238 @@
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { NodeSimulationVerdict } from '../../../workflow-loop/workflow-loop-state';
import { deriveWaitGateScripts, findCycleCutEdge } from '../wait-gate-script';
const gateVerdict = (nodeName: string): NodeSimulationVerdict => ({
nodeName,
verdict: 'simulate',
reason: 'Send-and-wait gate on a loop',
confidence: 'high',
source: 'deterministic',
haltBranch: true,
});
const main = (...targets: string[]) => ({
main: [targets.map((target) => ({ node: target, type: 'main', index: 0 }))],
});
/** trigger → Generate → Format → Gate → Router → (Publish | Revise → Format). */
function approvalLoopWorkflow(gateParameters: Record<string, unknown>): WorkflowJSON {
return {
name: 'approval loop',
nodes: [
{ name: 'Every day', type: 'n8n-nodes-base.scheduleTrigger', parameters: {} },
{ name: 'Generate', type: '@n8n/n8n-nodes-langchain.chainLlm', parameters: {} },
{ name: 'Format', type: 'n8n-nodes-base.set', parameters: {} },
{ name: 'Gate', type: 'n8n-nodes-base.gmail', parameters: gateParameters },
{ name: 'Router', type: 'n8n-nodes-base.if', parameters: {} },
{ name: 'Publish', type: 'n8n-nodes-base.linkedIn', parameters: {} },
{ name: 'Revise', type: '@n8n/n8n-nodes-langchain.chainLlm', parameters: {} },
].map((node, index) => ({
id: `id-${index}`,
typeVersion: 1,
position: [index * 100, 0],
...node,
})),
connections: {
'Every day': main('Generate'),
Generate: main('Format'),
Format: main('Gate'),
Gate: main('Router'),
Router: {
main: [
[{ node: 'Publish', type: 'main', index: 0 }],
[{ node: 'Revise', type: 'main', index: 0 }],
],
},
Revise: main('Format'),
},
} as unknown as WorkflowJSON;
}
describe('findCycleCutEdge', () => {
it('picks the loop-back edge, never one that costs downstream coverage', () => {
const workflow = approvalLoopWorkflow({ operation: 'sendAndWait' });
// Gate→Router also breaks the cycle but would disconnect the router,
// publish and revise branches — only Revise→Format preserves coverage.
expect(findCycleCutEdge(workflow, 'Gate')).toEqual({ source: 'Revise', target: 'Format' });
});
it('returns undefined when the gate is not on a cycle or no trigger exists', () => {
const acyclic = approvalLoopWorkflow({ operation: 'sendAndWait' });
(acyclic.connections as Record<string, unknown>).Revise = { main: [[]] };
expect(findCycleCutEdge(acyclic, 'Gate')).toBeUndefined();
const triggerless = approvalLoopWorkflow({ operation: 'sendAndWait' });
triggerless.nodes = triggerless.nodes.filter((node) => node.name !== 'Every day');
expect(findCycleCutEdge(triggerless, 'Gate')).toBeUndefined();
});
});
describe('deriveWaitGateScripts', () => {
it('derives approve/decline passes for the approval response type', () => {
const workflow = approvalLoopWorkflow({ operation: 'sendAndWait', responseType: 'approval' });
const scripts = deriveWaitGateScripts(workflow, [gateVerdict('Gate')]);
expect(scripts).toHaveLength(1);
expect(scripts[0].nodeName).toBe('Gate');
expect(scripts[0].cutEdge).toEqual({ source: 'Revise', target: 'Format' });
expect(scripts[0].decisions.map((d) => d.label)).toEqual(['approve', 'decline']);
expect(scripts[0].decisions[0].items[0]).toMatchObject({ data: { approved: true } });
expect(scripts[0].decisions[1].items[0]).toMatchObject({ data: { approved: false } });
});
it('defaults to the approval shape when responseType is omitted', () => {
const workflow = approvalLoopWorkflow({ operation: 'sendAndWait' });
const scripts = deriveWaitGateScripts(workflow, [gateVerdict('Gate')]);
expect(scripts[0]?.decisions[0].items[0]).toMatchObject({ data: { approved: true } });
});
it('enumerates the first single-select dropdown for custom forms', () => {
const workflow = approvalLoopWorkflow({
operation: 'sendAndWait',
responseType: 'customForm',
formFields: {
values: [
{
fieldLabel: 'Decision',
fieldType: 'dropdown',
fieldOptions: { values: [{ option: 'Approve' }, { option: 'Request changes' }] },
},
{ fieldLabel: 'Requested changes', fieldType: 'textarea' },
],
},
});
const scripts = deriveWaitGateScripts(workflow, [gateVerdict('Gate')]);
expect(scripts[0]?.decisions.map((d) => d.label)).toEqual(['Approve', 'Request changes']);
expect(scripts[0]?.decisions[1].items[0]).toMatchObject({
data: { Decision: 'Request changes' },
});
});
it('derives canned approve/revise texts for free-text gates', () => {
const workflow = approvalLoopWorkflow({ operation: 'sendAndWait', responseType: 'freeText' });
const scripts = deriveWaitGateScripts(workflow, [gateVerdict('Gate')]);
expect(scripts[0]?.decisions).toHaveLength(2);
const firstItem = scripts[0]?.decisions[0].items[0] as { data?: { text?: string } };
expect(firstItem.data?.text).toContain('Approved');
});
it('returns [] for underivable shapes', () => {
const noDropdown = approvalLoopWorkflow({
operation: 'sendAndWait',
responseType: 'customForm',
formFields: { values: [{ fieldLabel: 'Decision', fieldType: 'text' }] },
});
expect(deriveWaitGateScripts(noDropdown, [gateVerdict('Gate')])).toEqual([]);
// Multiple halted gates — v1 scripts exactly one.
const workflow = approvalLoopWorkflow({ operation: 'sendAndWait' });
expect(deriveWaitGateScripts(workflow, [gateVerdict('Gate'), gateVerdict('Other')])).toEqual(
[],
);
// Not a send-and-wait node (Wait node keeps the halt in v1).
const waitNode = approvalLoopWorkflow({});
waitNode.nodes = waitNode.nodes.map((node) =>
node.name === 'Gate' ? { ...node, type: 'n8n-nodes-base.wait', parameters: {} } : node,
);
expect(deriveWaitGateScripts(waitNode, [gateVerdict('Gate')])).toEqual([]);
});
});
describe('deriveWaitGateScripts — field identifiers and JSON forms', () => {
const formFields = {
values: [
{
fieldLabel: 'Decision',
fieldName: 'decision_key',
fieldType: 'dropdown',
fieldOptions: { values: [{ option: 'Approve' }, { option: 'Reject' }] },
},
],
};
it('keys fixtures by fieldName on v2.4+ gates, mirroring getFieldIdentifier', () => {
const workflow = approvalLoopWorkflow({
operation: 'sendAndWait',
responseType: 'customForm',
formFields,
});
workflow.nodes = workflow.nodes.map((node) =>
node.name === 'Gate' ? { ...node, typeVersion: 2.4 } : node,
);
const scripts = deriveWaitGateScripts(workflow, [gateVerdict('Gate')]);
expect(scripts[0]?.decisions[0].items[0]).toMatchObject({
data: { decision_key: 'Approve' },
});
});
it('keys fixtures by label on pre-2.4 gates even when fieldName is set', () => {
const workflow = approvalLoopWorkflow({
operation: 'sendAndWait',
responseType: 'customForm',
formFields,
});
const scripts = deriveWaitGateScripts(workflow, [gateVerdict('Gate')]);
expect(scripts[0]?.decisions[0].items[0]).toMatchObject({ data: { Decision: 'Approve' } });
});
it('parses static JSON-defined forms and bails on expressions', () => {
const staticJson = approvalLoopWorkflow({
operation: 'sendAndWait',
responseType: 'customForm',
defineForm: 'json',
jsonOutput: JSON.stringify([
{
fieldLabel: 'Decision',
fieldType: 'dropdown',
fieldOptions: { values: [{ option: 'Ship it' }, { option: 'Revise' }] },
},
]),
});
expect(
deriveWaitGateScripts(staticJson, [gateVerdict('Gate')])[0]?.decisions.map((d) => d.label),
).toEqual(['Ship it', 'Revise']);
const dynamicJson = approvalLoopWorkflow({
operation: 'sendAndWait',
responseType: 'customForm',
defineForm: 'json',
jsonOutput: '{{ $json.fields }}',
});
expect(deriveWaitGateScripts(dynamicJson, [gateVerdict('Gate')])).toEqual([]);
});
it('accepts a radio field as the decision driver', () => {
const workflow = approvalLoopWorkflow({
operation: 'sendAndWait',
responseType: 'customForm',
formFields: {
values: [
{
fieldLabel: 'Verdict',
fieldType: 'radio',
fieldOptions: { values: [{ option: 'Yes' }, { option: 'No' }] },
},
],
},
});
expect(
deriveWaitGateScripts(workflow, [gateVerdict('Gate')])[0]?.decisions.map((d) => d.label),
).toEqual(['Yes', 'No']);
});
});

View File

@ -0,0 +1,47 @@
import type { InstanceAiContext } from '../../../types';
import { trackWaitGateVerificationPlan } from '../workflow-build-telemetry';
function makeContext(trackTelemetry = vi.fn()): InstanceAiContext {
return {
trackTelemetry,
threadId: 'thread-1',
runId: 'run-1',
} as unknown as InstanceAiContext;
}
describe('trackWaitGateVerificationPlan', () => {
it('emits gate counts and the multi-gate flag', () => {
const trackTelemetry = vi.fn();
trackWaitGateVerificationPlan(makeContext(trackTelemetry), {
haltedGateCount: 2,
scriptedGateCount: 0,
savedWorkflowId: 'wf-1',
});
expect(trackTelemetry).toHaveBeenCalledWith(
'instance_ai_wait_gate_verification_plan',
expect.objectContaining({
halted_gate_count: 2,
scripted_gate_count: 0,
multi_gate: true,
scripted: false,
workflow_id: 'wf-1',
thread_id: 'thread-1',
run_id: 'run-1',
}),
);
});
it('stays silent for builds without halted gates', () => {
const trackTelemetry = vi.fn();
trackWaitGateVerificationPlan(makeContext(trackTelemetry), {
haltedGateCount: 0,
scriptedGateCount: 0,
savedWorkflowId: 'wf-1',
});
expect(trackTelemetry).not.toHaveBeenCalled();
});
});

View File

@ -37,7 +37,10 @@ import {
reportWorkflowBuildOutcome,
} from './workflow-build-reporting';
import { withDeterministicRouting } from './workflow-build-routing';
import { trackWorkflowSourceBuild } from './workflow-build-telemetry';
import {
trackWaitGateVerificationPlan,
trackWorkflowSourceBuild,
} from './workflow-build-telemetry';
import {
bindSourceFileToExistingWorkflow,
getWorkflowSourceFileBinding,
@ -757,14 +760,21 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
) => {
const setupRequests = await analyzeWorkflow(context, saved.id);
const workflowNeedsSetup = setupRequests.some((request) => request.needsAction);
const { nodeSimulationPlan, simulationFixtures } = await planVerificationSimulation({
workflow: json,
mockedNodeNames: mockResult.mockedNodeNames,
declaredOutputFixtures: compiled.declaredOutputFixtures,
workflowId: saved.id,
outputSchemaLookup: context.outputSchemaLookup,
fallbackModelConfig: context.modelId,
logger: context.logger,
const { nodeSimulationPlan, simulationFixtures, waitGateScripts } =
await planVerificationSimulation({
workflow: json,
mockedNodeNames: mockResult.mockedNodeNames,
declaredOutputFixtures: compiled.declaredOutputFixtures,
workflowId: saved.id,
outputSchemaLookup: context.outputSchemaLookup,
fallbackModelConfig: context.modelId,
logger: context.logger,
});
trackWaitGateVerificationPlan(context, {
haltedGateCount: (nodeSimulationPlan ?? []).filter((verdict) => verdict.haltBranch)
.length,
scriptedGateCount: waitGateScripts?.length ?? 0,
savedWorkflowId: saved.id,
});
const runId = buildContext?.runId ?? context.runId;
const workflowName = json.name || 'workflow';
@ -831,6 +841,7 @@ export function createBuildWorkflowTool(context: InstanceAiContext) {
workflowNeedsSetup,
nodeSimulationPlan,
simulationFixtures,
waitGateScripts,
supportingWorkflowIds:
referencedWorkflowIds.length > 0 ? referencedWorkflowIds : undefined,
hasUnresolvedPlaceholders: hasPlaceholders || undefined,

View File

@ -20,6 +20,7 @@ import {
generateSimulationFixtures,
type SimulationFixtures,
} from './generate-simulation-fixtures.service';
import { deriveWaitGateScripts } from './wait-gate-script';
import {
isMockableTriggerNodeType,
isTriggerNodeType,
@ -28,7 +29,10 @@ import {
} from './workflow-json-utils';
import type { Logger } from '../../logger';
import type { ModelConfig } from '../../types';
import type { NodeSimulationVerdict } from '../../workflow-loop/workflow-loop-state';
import type {
NodeSimulationVerdict,
WaitGateScript,
} from '../../workflow-loop/workflow-loop-state';
export interface PlanVerificationSimulationInput {
workflow: WorkflowJSON;
@ -50,6 +54,8 @@ export interface PlanVerificationSimulationInput {
export interface VerificationSimulationPlan {
nodeSimulationPlan?: NodeSimulationVerdict[];
/** Scripted decisions for wait gates on loops; absent when none derivable. */
waitGateScripts?: WaitGateScript[];
simulationFixtures?: SimulationFixtures;
}
@ -299,6 +305,7 @@ export async function planVerificationSimulation({
}: PlanVerificationSimulationInput): Promise<VerificationSimulationPlan> {
let nodeSimulationPlan: NodeSimulationVerdict[] | undefined;
let simulationFixtures: SimulationFixtures | undefined;
let waitGateScripts: WaitGateScript[] | undefined;
const declaredFixtures = nonEmptyDeclaredFixtures(declaredOutputFixtures);
try {
nodeSimulationPlan = await classifyNodesForSimulation({
@ -314,6 +321,8 @@ export async function planVerificationSimulation({
new Set(mockedNodeNames ?? []),
);
nodeSimulationPlan = withWaitGateHaltVerdicts(nodeSimulationPlan, workflow);
const derivedScripts = deriveWaitGateScripts(workflow, nodeSimulationPlan);
waitGateScripts = derivedScripts.length > 0 ? derivedScripts : undefined;
if (nodeSimulationPlan.length > 0) {
const planNeedingGeneratedFixtures = nodeSimulationPlan.filter(
(verdict) =>
@ -365,5 +374,5 @@ export async function planVerificationSimulation({
simulationFixtures = declaredFixtures;
}
}
return { nodeSimulationPlan, simulationFixtures };
return { nodeSimulationPlan, simulationFixtures, waitGateScripts };
}

View File

@ -0,0 +1,277 @@
/**
* Wait-gate decision scripts for verification.
*
* A send-and-wait gate on a loop cannot be verified with a single pinned
* response (the constant decision re-runs the loop forever). Instead, verify
* runs one pass per scripted decision approve-ish and reject-ish on an
* ephemeral workflow copy with one loop edge removed, so every pass is
* acyclic regardless of how the decision routes. This module derives that
* script (cut edge + decision fixtures) from the workflow at build time.
*
* V1 limits: send-and-wait operations only (Wait/Form keep the halt), exactly
* one scripted gate per workflow, and custom forms need a dropdown field to
* enumerate decisions. Anything underivable returns [] and the gate falls
* back to its `haltBranch` behaviour.
*/
import { isRecord } from '@n8n/utils/is-record';
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import { isTriggerNodeType } from './workflow-json-utils';
import type {
NodeSimulationVerdict,
WaitGateScript,
} from '../../workflow-loop/workflow-loop-state';
type WorkflowNode = WorkflowJSON['nodes'][number];
type Edge = { source: string; target: string };
const SEND_AND_WAIT_OPERATION = 'sendAndWait';
const APPROVE_TEXT = 'Approved — looks good, go ahead.';
const REVISE_TEXT = 'Please revise: tighten the copy and add one concrete example.';
function isSendAndWaitNode(node: WorkflowNode): boolean {
const parameters = isRecord(node.parameters) ? node.parameters : {};
return parameters.operation === SEND_AND_WAIT_OPERATION;
}
/** All `main` edges of the workflow, in stable declaration order. */
function mainEdges(json: WorkflowJSON): Edge[] {
const edges: Edge[] = [];
for (const [source, byType] of Object.entries(json.connections ?? {})) {
if (!isRecord(byType) || !Array.isArray(byType.main)) continue;
for (const group of byType.main) {
if (!Array.isArray(group)) continue;
for (const connection of group) {
if (isRecord(connection) && typeof connection.node === 'string') {
edges.push({ source, target: connection.node });
}
}
}
}
return edges;
}
function buildAdjacency(edges: Edge[], skip?: Edge): Map<string, string[]> {
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (skip && edge.source === skip.source && edge.target === skip.target) continue;
const targets = adjacency.get(edge.source);
if (targets) targets.push(edge.target);
else adjacency.set(edge.source, [edge.target]);
}
return adjacency;
}
function reaches(edges: Edge[], from: string, to: string, skip?: Edge): boolean {
const adjacency = buildAdjacency(edges, skip);
// Seed with successors so `from === to` requires traversing at least one edge.
const queue = [...(adjacency.get(from) ?? [])];
const visited = new Set<string>();
while (queue.length > 0) {
const current = queue.pop();
if (current === undefined) break;
if (current === to) return true;
if (visited.has(current)) continue;
visited.add(current);
queue.push(...(adjacency.get(current) ?? []));
}
return false;
}
function reachableFrom(edges: Edge[], sources: string[], skip?: Edge): Set<string> {
const adjacency = buildAdjacency(edges, skip);
const visited = new Set<string>(sources);
const queue = [...sources];
while (queue.length > 0) {
const current = queue.pop();
if (current === undefined) break;
for (const next of adjacency.get(current) ?? []) {
if (visited.has(next)) continue;
visited.add(next);
queue.push(next);
}
}
return visited;
}
/**
* Find a `main` edge whose removal breaks every cycle through `gateName`
* without shrinking the set of nodes reachable from the triggers i.e. a
* true loop-back edge whose cut costs no coverage. Returns undefined when no
* such edge exists (e.g. the loop-back edge is also the only inbound path).
*/
export function findCycleCutEdge(json: WorkflowJSON, gateName: string): Edge | undefined {
const edges = mainEdges(json);
const triggers = (json.nodes ?? [])
.filter(
(node): node is WorkflowNode & { name: string } =>
Boolean(node.name) && !node.disabled && isTriggerNodeType(node.type),
)
.map((node) => node.name);
if (triggers.length === 0) return undefined;
if (!reaches(edges, gateName, gateName)) return undefined;
const baseline = reachableFrom(edges, triggers);
for (const edge of edges) {
if (reaches(edges, gateName, gateName, edge)) continue;
const afterCut = reachableFrom(edges, triggers, edge);
const preservesCoverage = [...baseline].every((node) => afterCut.has(node));
if (preservesCoverage) return edge;
}
return undefined;
}
type Decision = WaitGateScript['decisions'][number];
interface FormField {
fieldLabel?: string;
fieldName?: string;
fieldType?: string;
options: string[];
multiselect: boolean;
}
function toFormField(value: unknown): FormField | undefined {
if (!isRecord(value)) return undefined;
const fieldLabel = typeof value.fieldLabel === 'string' ? value.fieldLabel : undefined;
const fieldName = typeof value.fieldName === 'string' ? value.fieldName : undefined;
if (!fieldLabel && !fieldName) return undefined;
const fieldOptions = isRecord(value.fieldOptions) ? value.fieldOptions : {};
const optionValues = Array.isArray(fieldOptions.values) ? fieldOptions.values : [];
const options = optionValues
.map((option) => (isRecord(option) ? option.option : undefined))
.filter((option): option is string => typeof option === 'string');
return {
fieldLabel,
fieldName,
fieldType: typeof value.fieldType === 'string' ? value.fieldType : undefined,
options,
multiselect: value.multiselectDropdown === true || value.multiselect === true,
};
}
/** UI-defined and static-JSON forms parse; JSON with expressions is dynamic → undefined. */
function parseFormFields(parameters: Record<string, unknown>): FormField[] | undefined {
let values: unknown[];
if (parameters.defineForm === 'json') {
const jsonOutput = parameters.jsonOutput;
if (typeof jsonOutput !== 'string' || jsonOutput.includes('{{')) return undefined;
try {
const parsed: unknown = JSON.parse(jsonOutput);
if (!Array.isArray(parsed)) return undefined;
values = parsed;
} catch {
return undefined;
}
} else {
const formFields = isRecord(parameters.formFields) ? parameters.formFields : {};
values = Array.isArray(formFields.values) ? formFields.values : [];
}
return values.map(toFormField).filter((field): field is FormField => field !== undefined);
}
/** Mirrors nodes-base `getFieldIdentifier`: v2.4+ prefers `fieldName`, else the label. */
function fieldKey(field: FormField, typeVersion: number): string | undefined {
if (typeVersion >= 2.4 && field.fieldName) return field.fieldName;
return field.fieldLabel ?? field.fieldName;
}
function cannedFieldValue(field: FormField, passLabel: string): unknown {
switch (field.fieldType) {
case 'dropdown':
return field.multiselect ? field.options.slice(0, 1) : (field.options[0] ?? '');
case 'radio':
return field.options[0] ?? '';
case 'number':
return 1;
case 'date':
return '2026-01-01';
case 'email':
return 'reviewer@example.com';
case 'checkbox':
return false;
default:
return passLabel;
}
}
/** Response fixtures per decision, mirroring the shared send-and-wait output shapes. */
function deriveDecisions(node: WorkflowNode): Decision[] | undefined {
const parameters = isRecord(node.parameters) ? node.parameters : {};
const responseType =
typeof parameters.responseType === 'string' ? parameters.responseType : 'approval';
const respondedAt = new Date().toISOString();
if (responseType === 'approval') {
return [
{ label: 'approve', items: [{ data: { approved: true, respondedAt } }] },
{ label: 'decline', items: [{ data: { approved: false, respondedAt } }] },
];
}
if (responseType === 'freeText') {
return [
{ label: 'approve', items: [{ data: { text: APPROVE_TEXT, respondedAt } }] },
{ label: 'request changes', items: [{ data: { text: REVISE_TEXT, respondedAt } }] },
];
}
if (responseType === 'customForm') {
const fields = parseFormFields(parameters);
if (!fields || fields.length === 0) return undefined;
const typeVersion = typeof node.typeVersion === 'number' ? node.typeVersion : 0;
// The first single-select dropdown/radio with 2+ options drives the
// decision; without one the routing condition is free text we cannot
// enumerate.
const driver = fields.find(
(field) =>
(field.fieldType === 'dropdown' || field.fieldType === 'radio') &&
!field.multiselect &&
field.options.length >= 2 &&
fieldKey(field, typeVersion) !== undefined,
);
if (!driver) return undefined;
const buildPass = (driverValue: string): Decision => {
const data: Record<string, unknown> = {};
for (const field of fields) {
if (field.fieldType === 'html') continue;
const key = fieldKey(field, typeVersion);
if (!key) continue;
data[key] = field === driver ? driverValue : cannedFieldValue(field, driverValue);
}
data.respondedAt = respondedAt;
return { label: driverValue, items: [{ data }] };
};
return [buildPass(driver.options[0]), buildPass(driver.options[1])];
}
return undefined;
}
/**
* Derive scripted-decision verification for the workflow's halted wait gates.
* V1 scripts exactly one send-and-wait gate; every underivable shape returns
* [] so the gate keeps its halt fallback.
*/
export function deriveWaitGateScripts(
workflow: WorkflowJSON,
plan: NodeSimulationVerdict[],
): WaitGateScript[] {
const halted = plan.filter((verdict) => verdict.verdict === 'simulate' && verdict.haltBranch);
if (halted.length !== 1) return [];
const gateName = halted[0].nodeName;
const node = (workflow.nodes ?? []).find((candidate) => candidate.name === gateName);
if (!node || !isSendAndWaitNode(node)) return [];
const cutEdge = findCycleCutEdge(workflow, gateName);
if (!cutEdge) return [];
const decisions = deriveDecisions(node);
if (!decisions) return [];
return [{ nodeName: gateName, cutEdge, decisions }];
}

View File

@ -57,3 +57,29 @@ export function trackWorkflowSourceBuild(
: {}),
});
}
/**
* Emitted once per successful build whose plan halts wait gates, so we learn
* how often scripted verification applies and whether multi-gate loop shapes
* (currently always halt-fallback) occur in the wild.
*/
export function trackWaitGateVerificationPlan(
context: InstanceAiContext,
input: {
haltedGateCount: number;
scriptedGateCount: number;
savedWorkflowId: string;
},
): void {
if (input.haltedGateCount === 0) return;
const buildContext = context.workflowBuildContext;
context.trackTelemetry?.('instance_ai_wait_gate_verification_plan', {
halted_gate_count: input.haltedGateCount,
scripted_gate_count: input.scriptedGateCount,
multi_gate: input.haltedGateCount >= 2,
scripted: input.scriptedGateCount > 0,
workflow_id: input.savedWorkflowId,
thread_id: context.threadId ?? buildContext?.threadId ?? 'unknown',
run_id: buildContext?.runId ?? context.runId ?? 'unknown',
});
}

View File

@ -387,6 +387,12 @@ export interface InstanceAiExecutionService {
verificationPinData?: Record<string, unknown[]>;
/** When set, execute this specific trigger node instead of auto-detecting. */
triggerNodeName?: string;
/**
* Connections removed from this run's ephemeral workflow copy (the saved
* workflow is untouched). Used to sever a loop edge so scripted wait-gate
* verification passes are acyclic.
*/
omitConnections?: Array<{ source: string; target: string }>;
abortSignal?: AbortSignal;
},
): Promise<ExecutionResult>;

View File

@ -252,6 +252,25 @@ export const nodeSimulationVerdictSchema = z.object({
export type NodeSimulationVerdict = z.infer<typeof nodeSimulationVerdictSchema>;
/**
* Scripted verification for a wait gate on a loop: verify runs one pass per
* decision with `cutEdge` removed from the run's connections, so every pass
* is acyclic no matter which way the decision routes. The gate keeps its
* `haltBranch` verdict as the fallback for older readers of this outcome.
*/
export const waitGateScriptSchema = z.object({
nodeName: z.string(),
/** Loop edge (main connection) removed for the scripted passes. */
cutEdge: z.object({ source: z.string(), target: z.string() }),
/** Response fixtures, one verification pass per entry. */
decisions: z
.array(z.object({ label: z.string(), items: z.array(z.record(z.unknown())) }))
.min(1)
.max(3),
});
export type WaitGateScript = z.infer<typeof waitGateScriptSchema>;
export const workflowBuildOutcomeSchema = z.object({
workItemId: z.string(),
runId: z.string().optional(),
@ -299,6 +318,8 @@ export const workflowBuildOutcomeSchema = z.object({
* this build, never persisted to the workflow.
*/
nodeSimulationPlan: z.array(nodeSimulationVerdictSchema).optional(),
/** Scripted decisions for wait gates on loops — see `waitGateScriptSchema`. */
waitGateScripts: z.array(waitGateScriptSchema).optional(),
/**
* LLM-generated mock output for `simulate`-verdict nodes, keyed by node
* name. Items are plain objects (no `{json}` envelope) the shape

View File

@ -2998,6 +2998,32 @@ describe('createExecutionAdapter run()', () => {
expect(secondRun.workflowData.settings?.executionTimeout).toBe(600);
});
it('omits the listed connections on the ephemeral run copy only', async () => {
const workflow = {
id: 'wf-1',
nodes: [],
connections: {
Revise: { main: [[{ node: 'Format', type: 'main', index: 0 }]] },
Format: { main: [[{ node: 'Gate', type: 'main', index: 0 }]] },
},
};
const { adapter, mockWorkflowRunner } = createRunAdapterForTests(workflow);
await adapter.run('wf-1', undefined, {
omitConnections: [{ source: 'Revise', target: 'Format' }],
});
const runData = mockWorkflowRunner.run.mock.calls[0][0];
expect(runData.workflowData.connections.Revise.main).toEqual([[]]);
expect(runData.workflowData.connections.Format.main).toEqual([
[{ node: 'Gate', type: 'main', index: 0 }],
]);
// The saved workflow object is untouched.
expect(workflow.connections.Revise.main).toEqual([
[{ node: 'Format', type: 'main', index: 0 }],
]);
});
it('attaches Instance AI execution telemetry metadata to workflow runs', async () => {
const { adapter, mockWorkflowRunner } = createRunAdapterForTests({
id: 'wf-1',

View File

@ -1138,6 +1138,12 @@ export class InstanceAiAdapterService {
const timeoutMs = Math.min(options?.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
// Sever the listed edges on this run's ephemeral copy only — used by
// scripted wait-gate verification to keep each pass acyclic.
const connections = options?.omitConnections?.length
? omitWorkflowConnections(workflow.connections, options.omitConnections)
: workflow.connections;
// Force-save AI-initiated executions so that follow-up
// `executions(list/get/debug)` calls can read the result, regardless of
// instance-wide or per-workflow save settings. Manual mode is gated by
@ -1149,6 +1155,7 @@ export class InstanceAiAdapterService {
: ('manual' as WorkflowExecuteMode),
workflowData: {
...workflow,
connections,
settings: {
...workflow.settings,
saveManualExecutions: true,
@ -3461,6 +3468,25 @@ function findTriggerNode(nodes: INode[]): INode | undefined {
return nodes.find((n) => isTriggerNodeType(n.type));
}
/** Copy of `connections` minus every listed source→target edge (any type). */
function omitWorkflowConnections(
connections: IConnections,
omit: Array<{ source: string; target: string }>,
): IConnections {
const omitted = new Set(omit.map((edge) => `${edge.source}${edge.target}`));
const result: IConnections = {};
for (const [source, byType] of Object.entries(connections)) {
const nextByType: IConnections[string] = {};
for (const [type, groups] of Object.entries(byType)) {
nextByType[type] = (groups ?? []).map((group) =>
group ? group.filter((connection) => !omitted.has(`${source}${connection.node}`)) : group,
);
}
result[source] = nextByType;
}
return result;
}
/** Get the execution mode based on the trigger node type. */
function getExecutionModeForTrigger(node: INode): WorkflowExecuteMode {
switch (node.type) {