mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-31 04:55:25 +02:00
278 lines
9.8 KiB
TypeScript
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 }];
|
|
}
|