n8n/packages/@n8n/instance-ai/src/tools/workflows/wait-gate-script.ts
José Braulio González Valido 00fe448a68
feat(ai-builder): Verify approval-loop branches with scripted gate decisions (#34822)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:05:51 +00:00

278 lines
9.8 KiB
TypeScript

/**
* 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 }];
}