@nanobpm/nano-workforce 0.111.0 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ // nano-workforce — the TRUSTED, DETERMINISTIC compiler for an agent-authored delivery graph
2
+ // (ADR 0005, slice S1). It is the fast, safe INNER LOOP a co-designing agent hammers: given an
3
+ // agent-authored `DeliveryGraph` (the JSON contract from slice S0), it VALIDATES (the pure
4
+ // `validateDeliveryGraph` semantic check), then COMPILES it to a native artifact, and RENDERS a
5
+ // preview — but it NEVER deploys or dispatches anything (Decision 5/6: `compile` and `start` are
6
+ // SEPARATE doors; there is deliberately no `dryRun` flag on the start door). Being side-effect-free,
7
+ // it is callable repeatedly while the agent iterates JSON → compile → fix.
8
+ //
9
+ // Two invariants make it TRUSTED (Decision 1/2 — the closed node vocabulary is the trust boundary):
10
+ //
11
+ // • It is human-written and DETERMINISTIC: the same input JSON always produces byte-identical
12
+ // output (BPMN, diagram, resolved graph). Nodes/edges are sorted by id and every generated id is
13
+ // assigned positionally, so there is no map-iteration or timestamp nondeterminism.
14
+ // • It only ever instantiates ALLOWLISTED node kinds. The `compileNode` switch is exhaustive over
15
+ // the closed `DeliveryNodeKind` union with a `never` default, so the compiler CANNOT emit a
16
+ // construct for a non-allowlisted kind — a new kind fails `tsc` until it is deliberately handled.
17
+ // `validateDeliveryGraph` is the runtime guard (an unknown kind is rejected before compilation);
18
+ // the exhaustive switch is the compile-time guarantee.
19
+ //
20
+ // Compile-to-native (the first cut, Decision 6): each node maps to an engine-native call
21
+ // activity / sub-process (a `wait` reuses the real `readiness-gate`; `agent`/`connector` target
22
+ // forward-declared bodies S4 binds) and each edge becomes a native sequence flow, with explicit
23
+ // parallel gateways for genuine fan-out (>1 downstream) and fan-in (>1 upstream). This slice targets
24
+ // the WIRING/SHAPE — the concrete node bodies land in S4.
25
+
26
+ import type {
27
+ CompileDeliveryGraphErrors,
28
+ CompileDeliveryGraphResult,
29
+ DeliveryFact,
30
+ DeliveryGraph,
31
+ DeliveryHumanStop,
32
+ DeliveryNode,
33
+ DeliverySideEffect,
34
+ ResolvedDeliveryEdge,
35
+ ResolvedDeliveryNode,
36
+ } from "../nano-generated/api-io.d.ts";
37
+ import {
38
+ type DeliveryGraphError,
39
+ deliveryNodeFacts,
40
+ resolveDeliveryFrom,
41
+ validateDeliveryGraph,
42
+ } from "./deliveryGraph.ts";
43
+
44
+ /** The engine-native sub-process each non-human node kind delegates to (Decision 2 — the graph
45
+ * SCHEDULES, it does not re-implement execution). `wait` reuses the REAL `readiness-gate` process
46
+ * (`resources/processes/readiness-gate.bpmn`); `agent`/`connector` target forward-declared bodies
47
+ * that slice S4 binds to their concrete implementations. `human` has NO called element — it compiles
48
+ * to a native user task, not a call activity. Kept as the single source of truth so the compiler and
49
+ * the resolved-preview agree on the target. */
50
+ const CALLED_ELEMENT: Record<Exclude<DeliveryNode["kind"], "human">, string> = {
51
+ agent: "delivery-node-agent",
52
+ wait: "readiness-gate",
53
+ connector: "delivery-node-connector",
54
+ };
55
+
56
+ /** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
57
+ * the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
58
+ * compiler arm, `tsc` flags this call — the compile-time half of the trust bound. */
59
+ function assertNever(value: never, context: string): never {
60
+ throw new Error(`${context}: unreachable — non-allowlisted delivery node kind ${JSON.stringify(value)}`);
61
+ }
62
+
63
+ /** Escape a string for use as XML text / attribute content. Deterministic and total. */
64
+ function escapeXml(value: string): string {
65
+ return value
66
+ .replace(/&/g, "&amp;")
67
+ .replace(/</g, "&lt;")
68
+ .replace(/>/g, "&gt;")
69
+ .replace(/"/g, "&quot;")
70
+ .replace(/'/g, "&apos;");
71
+ }
72
+
73
+ /** Escape a string for use inside a mermaid quoted label. Mermaid uses `#` HTML-entity escapes; a
74
+ * double quote inside a `"…"` label must become `#quot;` so the label stays well-formed. */
75
+ function escapeMermaid(value: string): string {
76
+ return value.replace(/"/g, "#quot;").replace(/\n/g, " ");
77
+ }
78
+
79
+ /** A node's typed emits, normalised to a stable array (absent → `[]`). */
80
+ function normaliseEmits(node: DeliveryNode): DeliveryFact[] {
81
+ return Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
82
+ }
83
+
84
+ /** Locale-independent, byte-stable string ordering: compares by UTF-16 code unit, so the sort is
85
+ * identical across host locales (unlike `localeCompare`, whose collation varies by runtime locale for
86
+ * non-ASCII ids). This keeps the compiler's "byte-identical across environments" determinism guarantee
87
+ * strict. Returns -1 / 0 / 1. */
88
+ function byCodeUnit(a: string, b: string): number {
89
+ return a < b ? -1 : a > b ? 1 : 0;
90
+ }
91
+
92
+ /** One sequence flow in the compiled process — `source`/`target` are element ids, `name` an optional
93
+ * (fact) label. */
94
+ interface Flow {
95
+ id: string;
96
+ source: string;
97
+ target: string;
98
+ name?: string;
99
+ }
100
+
101
+ /** A compiled node's structural fixtures: its own BPMN `element` id, and — when it has >1 downstream
102
+ * or >1 upstream — the parallel fork/join gateway that fans its flow out/in. `entry` is the id
103
+ * upstream flows target (the join, else the element); `exit` is the id downstream flows leave from
104
+ * (the fork, else the element). */
105
+ interface NodeWiring {
106
+ node: DeliveryNode;
107
+ element: string;
108
+ forkGateway?: string;
109
+ joinGateway?: string;
110
+ entry: string;
111
+ exit: string;
112
+ }
113
+
114
+ /** Fetch a key that MUST be present (every node id was registered in the map above). Returns the
115
+ * value without a type assertion, throwing on the impossible missing case. */
116
+ function mustGet<K, V>(map: ReadonlyMap<K, V>, key: K): V {
117
+ const value = map.get(key);
118
+ if (value === undefined) throw new Error(`compileDeliveryGraph: missing map entry for ${String(key)}`);
119
+ return value;
120
+ }
121
+
122
+ /**
123
+ * Validate + compile a delivery graph into a PURE preview (ADR 0005 slice S1). Returns
124
+ * `{ ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }` for a well-formed graph, or
125
+ * `{ ok:false, errors }` (each error path-qualified) for a malformed one. NEVER deploys, dispatches,
126
+ * or mutates anything — safe to call repeatedly. Deterministic: identical input JSON yields
127
+ * byte-identical output.
128
+ */
129
+ export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult | CompileDeliveryGraphErrors {
130
+ const validationErrors: DeliveryGraphError[] = validateDeliveryGraph(graph);
131
+ if (validationErrors.length > 0) {
132
+ // Forward every semantic failure verbatim as a wire `{ path, message }` (the stable `code` stays
133
+ // server-side). Nothing is compiled — the agent fixes the exact offending input and re-compiles.
134
+ return { ok: false, errors: validationErrors.map(({ path, message }) => ({ path, message })) };
135
+ }
136
+
137
+ // The graph passed both the OpenAPI shape gate (at the edge) and the semantic validator, so it is
138
+ // safe to narrow to the typed contract. Every field below is well-formed by construction.
139
+ // biome-ignore lint/plugin: validated external body narrowed to its contract after validateDeliveryGraph
140
+ const typed = graph as DeliveryGraph;
141
+ const nodes = [...typed.nodes].sort((a, b) => byCodeUnit(a.id, b.id));
142
+ const edges = Array.isArray(typed.edges) ? typed.edges : [];
143
+
144
+ // Resolve every edge's `from` endpoint against the SAME node/fact map the validator checked (shared
145
+ // helper — no drift), then sort edges deterministically by (consumer, producer, fact).
146
+ const nodeFacts = deliveryNodeFacts(typed);
147
+ const resolvedEdges: ResolvedDeliveryEdge[] = edges
148
+ .map((edge) => {
149
+ const { nodeId, fact } = resolveDeliveryFrom(edge.from, nodeFacts);
150
+ const resolved: ResolvedDeliveryEdge = { from: edge.from, to: edge.to, fromNode: nodeId };
151
+ return fact !== undefined ? { ...resolved, fromFact: fact } : resolved;
152
+ })
153
+ .sort(
154
+ (a, b) =>
155
+ byCodeUnit(a.to, b.to) ||
156
+ byCodeUnit(a.fromNode, b.fromNode) ||
157
+ byCodeUnit(a.fromFact ?? "", b.fromFact ?? ""),
158
+ );
159
+
160
+ // Per-node producer/consumer adjacency (by node id), each sorted + de-duplicated for determinism.
161
+ const producersById = new Map<string, string[]>();
162
+ const consumersById = new Map<string, string[]>();
163
+ for (const node of nodes) {
164
+ producersById.set(node.id, []);
165
+ consumersById.set(node.id, []);
166
+ }
167
+ for (const edge of resolvedEdges) {
168
+ pushUnique(producersById.get(edge.to), edge.fromNode);
169
+ pushUnique(consumersById.get(edge.fromNode), edge.to);
170
+ }
171
+ for (const list of producersById.values()) list.sort(byCodeUnit);
172
+ for (const list of consumersById.values()) list.sort(byCodeUnit);
173
+
174
+ // Assign the deterministic BPMN element id per node (`n0`, `n1`, … in sorted order) plus the
175
+ // fork/join gateway ids (`gwf<i>` / `gwj<i>`) any fan-out/fan-in node needs.
176
+ const elementById = new Map<string, string>();
177
+ const wirings: NodeWiring[] = [];
178
+ const wiringById = new Map<string, NodeWiring>();
179
+ let forkSeq = 0;
180
+ let joinSeq = 0;
181
+ nodes.forEach((node, i) => {
182
+ const element = `n${i}`;
183
+ elementById.set(node.id, element);
184
+ const consumers = consumersById.get(node.id) ?? [];
185
+ const producers = producersById.get(node.id) ?? [];
186
+ const forkGateway = consumers.length > 1 ? `gwf${forkSeq++}` : undefined;
187
+ const joinGateway = producers.length > 1 ? `gwj${joinSeq++}` : undefined;
188
+ const wiring: NodeWiring = {
189
+ node,
190
+ element,
191
+ forkGateway,
192
+ joinGateway,
193
+ entry: joinGateway ?? element,
194
+ exit: forkGateway ?? element,
195
+ };
196
+ wirings.push(wiring);
197
+ wiringById.set(node.id, wiring);
198
+ });
199
+
200
+ const roots = nodes.filter((n) => (producersById.get(n.id) ?? []).length === 0);
201
+ const leaves = nodes.filter((n) => (consumersById.get(n.id) ?? []).length === 0);
202
+ const startForkGateway = roots.length > 1 ? "gwf_start" : undefined;
203
+ const endJoinGateway = leaves.length > 1 ? "gwj_end" : undefined;
204
+
205
+ // ── Build the flow list in a DETERMINISTIC order, then assign `f0…` ids positionally ────────────
206
+ const flows: Omit<Flow, "id">[] = [];
207
+ // 1. Start → root(s).
208
+ if (roots.length === 1) {
209
+ flows.push({ source: "Start", target: mustGet(wiringById, roots[0].id).entry });
210
+ } else if (roots.length > 1) {
211
+ flows.push({ source: "Start", target: "gwf_start" });
212
+ for (const root of roots) {
213
+ flows.push({ source: "gwf_start", target: mustGet(wiringById, root.id).entry });
214
+ }
215
+ }
216
+ // 2. Structural fork flows (node → its fork gateway).
217
+ for (const w of wirings) if (w.forkGateway) flows.push({ source: w.element, target: w.forkGateway });
218
+ // 3. Structural join flows (join gateway → node).
219
+ for (const w of wirings) if (w.joinGateway) flows.push({ source: w.joinGateway, target: w.element });
220
+ // 4. Edge flows: producer.exit → consumer.entry, labelled with the referenced fact(s) when
221
+ // qualified. Collapse edges sharing the same (fromNode → to) endpoints into ONE sequence flow:
222
+ // `producersById`/`consumersById` (hence the fork/join gateways) are de-duplicated by node id, so
223
+ // two fact-qualified edges between the same pair (e.g. `a.x -> b` and `a.y -> b`) would otherwise
224
+ // emit parallel flows between endpoints with no diverging gateway — invalid BPMN that schedules
225
+ // the consumer more than once. `resolvedEdges` is already sorted by (to, fromNode, fromFact), so
226
+ // same-endpoint edges are contiguous and their fact labels accumulate in deterministic order.
227
+ const collapsedEdges: { fromNode: string; to: string; facts: string[] }[] = [];
228
+ for (const edge of resolvedEdges) {
229
+ const last = collapsedEdges[collapsedEdges.length - 1];
230
+ if (last && last.fromNode === edge.fromNode && last.to === edge.to) {
231
+ if (edge.fromFact !== undefined && !last.facts.includes(edge.fromFact)) last.facts.push(edge.fromFact);
232
+ } else {
233
+ collapsedEdges.push({ fromNode: edge.fromNode, to: edge.to, facts: edge.fromFact !== undefined ? [edge.fromFact] : [] });
234
+ }
235
+ }
236
+ for (const edge of collapsedEdges) {
237
+ const producer = mustGet(wiringById, edge.fromNode);
238
+ const consumer = mustGet(wiringById, edge.to);
239
+ const flow: Omit<Flow, "id"> = { source: producer.exit, target: consumer.entry };
240
+ flows.push(edge.facts.length > 0 ? { ...flow, name: edge.facts.join(", ") } : flow);
241
+ }
242
+ // 5. Leaf(s) → End.
243
+ if (leaves.length === 1) {
244
+ flows.push({ source: mustGet(wiringById, leaves[0].id).exit, target: "End" });
245
+ } else if (leaves.length > 1) {
246
+ for (const leaf of leaves) {
247
+ flows.push({ source: mustGet(wiringById, leaf.id).exit, target: "gwj_end" });
248
+ }
249
+ flows.push({ source: "gwj_end", target: "End" });
250
+ }
251
+ const numberedFlows: Flow[] = flows.map((f, i) => ({ id: `f${i}`, ...f }));
252
+
253
+ const bpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway);
254
+ const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
255
+ const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
256
+ const humanNodes = buildHumanNodes(nodes);
257
+ const sideEffects = buildSideEffects(nodes);
258
+
259
+ return { ok: true, diagram, bpmn, resolved, humanNodes, sideEffects };
260
+ }
261
+
262
+ /** Push `value` into `list` (may be undefined for a dangling target, already reported by the
263
+ * validator) only when not already present — keeps adjacency de-duplicated. */
264
+ function pushUnique(list: string[] | undefined, value: string): void {
265
+ if (list && !list.includes(value)) list.push(value);
266
+ }
267
+
268
+ /** Build the resolved/normalised graph — nodes (sorted) with their compiled element id, engine-native
269
+ * called element, typed emits, and sorted `dependsOn`; plus the resolved, sorted edges. */
270
+ function buildResolved(
271
+ graph: DeliveryGraph,
272
+ wirings: readonly NodeWiring[],
273
+ edges: readonly ResolvedDeliveryEdge[],
274
+ producersById: ReadonlyMap<string, string[]>,
275
+ ): CompileDeliveryGraphResult["resolved"] {
276
+ const nodes: ResolvedDeliveryNode[] = wirings.map((w) => {
277
+ const base: ResolvedDeliveryNode = {
278
+ id: w.node.id,
279
+ kind: w.node.kind,
280
+ element: w.element,
281
+ emits: normaliseEmits(w.node),
282
+ dependsOn: [...(producersById.get(w.node.id) ?? [])],
283
+ };
284
+ return w.node.kind === "human" ? base : { ...base, calledElement: CALLED_ELEMENT[w.node.kind] };
285
+ });
286
+ const resolved: CompileDeliveryGraphResult["resolved"] = { nodes, edges: [...edges] };
287
+ return graph.name !== undefined ? { name: graph.name, ...resolved } : resolved;
288
+ }
289
+
290
+ /** Extract the human STOP-points (sorted by id) — where the graph pauses for a person/agent, with the
291
+ * instruction, optional attached form, and the typed facts the node will emit. */
292
+ function buildHumanNodes(nodes: readonly DeliveryNode[]): DeliveryHumanStop[] {
293
+ const stops: DeliveryHumanStop[] = [];
294
+ for (const node of nodes) {
295
+ if (node.kind !== "human") continue;
296
+ const stop: DeliveryHumanStop = { nodeId: node.id, emits: normaliseEmits(node) };
297
+ const withPrompt = node.human?.prompt !== undefined ? { ...stop, prompt: node.human.prompt } : stop;
298
+ stops.push(node.human?.formKey !== undefined ? { ...withPrompt, formKey: node.human.formKey } : withPrompt);
299
+ }
300
+ return stops;
301
+ }
302
+
303
+ /** Extract the SIDE EFFECTS (sorted by id) the compiled graph will perform — `agent` job runs and
304
+ * `connector` outbound actions. `wait` gates are read-only and `human` stops are surfaced separately,
305
+ * so neither is a side effect. */
306
+ function buildSideEffects(nodes: readonly DeliveryNode[]): DeliverySideEffect[] {
307
+ const effects: DeliverySideEffect[] = [];
308
+ for (const node of nodes) {
309
+ if (node.kind === "agent") {
310
+ effects.push({
311
+ nodeId: node.id,
312
+ kind: "agent",
313
+ description: `runs agent job \`${node.agent.jobType}\``,
314
+ });
315
+ } else if (node.kind === "connector") {
316
+ const effect: DeliverySideEffect = {
317
+ nodeId: node.id,
318
+ kind: "connector",
319
+ description: `invokes connector target \`${node.connector.target}\``,
320
+ };
321
+ effects.push(
322
+ node.connector.dedupeKey !== undefined ? { ...effect, dedupeKey: node.connector.dedupeKey } : effect,
323
+ );
324
+ }
325
+ }
326
+ return effects;
327
+ }
328
+
329
+ /** Render the compiled one-shot BPMN process definition (compile-to-native). Deterministic — element
330
+ * order is fixed (start, gateways, nodes sorted, end) and every id is positional. */
331
+ function renderBpmn(
332
+ graph: DeliveryGraph,
333
+ wirings: readonly NodeWiring[],
334
+ flows: readonly Flow[],
335
+ startForkGateway: string | undefined,
336
+ endJoinGateway: string | undefined,
337
+ ): string {
338
+ // Precompute incoming/outgoing flow-id maps once (single pass over flows) so BPMN rendering stays
339
+ // linear in the number of flows instead of O(elements * flows) from repeated full-array filtering.
340
+ // Insertion order is preserved, matching the previous per-element filter order.
341
+ const incomingById = new Map<string, string[]>();
342
+ const outgoingById = new Map<string, string[]>();
343
+ const appendTo = (map: Map<string, string[]>, key: string, id: string): void => {
344
+ const list = map.get(key);
345
+ if (list) list.push(id);
346
+ else map.set(key, [id]);
347
+ };
348
+ for (const f of flows) {
349
+ appendTo(incomingById, f.target, f.id);
350
+ appendTo(outgoingById, f.source, f.id);
351
+ }
352
+ const incoming = (elementId: string): string[] => incomingById.get(elementId) ?? [];
353
+ const outgoing = (elementId: string): string[] => outgoingById.get(elementId) ?? [];
354
+ const refs = (tag: string, ids: readonly string[]): string =>
355
+ ids.map((id) => ` <bpmn:${tag}>${id}</bpmn:${tag}>`).join("\n");
356
+
357
+ const lines: string[] = [];
358
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
359
+ lines.push(
360
+ '<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ' +
361
+ 'xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ' +
362
+ 'id="Definitions_delivery_graph" targetNamespace="http://nanobpm.io/nano-workforce">',
363
+ );
364
+ const processName = graph.name ?? "Delivery graph";
365
+ lines.push(` <bpmn:process id="delivery-graph" name="${escapeXml(processName)}" isExecutable="true">`);
366
+
367
+ // Start event.
368
+ lines.push(' <bpmn:startEvent id="Start" name="Graph opened">');
369
+ lines.push(refs("outgoing", outgoing("Start")));
370
+ lines.push(" </bpmn:startEvent>");
371
+
372
+ // Start fork gateway (fan-out to multiple roots).
373
+ if (startForkGateway) {
374
+ lines.push(` <bpmn:parallelGateway id="${startForkGateway}" name="fan out to roots">`);
375
+ lines.push(refs("incoming", incoming(startForkGateway)));
376
+ lines.push(refs("outgoing", outgoing(startForkGateway)));
377
+ lines.push(" </bpmn:parallelGateway>");
378
+ }
379
+
380
+ // Node elements (sorted), each preceded by its join gateway and followed by its fork gateway.
381
+ for (const w of wirings) {
382
+ if (w.joinGateway) {
383
+ lines.push(` <bpmn:parallelGateway id="${w.joinGateway}" name="join into ${escapeXml(w.node.id)}">`);
384
+ lines.push(refs("incoming", incoming(w.joinGateway)));
385
+ lines.push(refs("outgoing", outgoing(w.joinGateway)));
386
+ lines.push(" </bpmn:parallelGateway>");
387
+ }
388
+ lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element)));
389
+ if (w.forkGateway) {
390
+ lines.push(` <bpmn:parallelGateway id="${w.forkGateway}" name="fan out of ${escapeXml(w.node.id)}">`);
391
+ lines.push(refs("incoming", incoming(w.forkGateway)));
392
+ lines.push(refs("outgoing", outgoing(w.forkGateway)));
393
+ lines.push(" </bpmn:parallelGateway>");
394
+ }
395
+ }
396
+
397
+ // End join gateway (fan-in from multiple leaves) + end event.
398
+ if (endJoinGateway) {
399
+ lines.push(` <bpmn:parallelGateway id="${endJoinGateway}" name="join leaves">`);
400
+ lines.push(refs("incoming", incoming(endJoinGateway)));
401
+ lines.push(refs("outgoing", outgoing(endJoinGateway)));
402
+ lines.push(" </bpmn:parallelGateway>");
403
+ }
404
+ lines.push(' <bpmn:endEvent id="End" name="Graph complete">');
405
+ lines.push(refs("incoming", incoming("End")));
406
+ lines.push(" </bpmn:endEvent>");
407
+
408
+ // Sequence flows.
409
+ for (const f of flows) {
410
+ const nameAttr = f.name !== undefined ? ` name="${escapeXml(f.name)}"` : "";
411
+ lines.push(
412
+ ` <bpmn:sequenceFlow id="${f.id}"${nameAttr} sourceRef="${f.source}" targetRef="${f.target}" />`,
413
+ );
414
+ }
415
+
416
+ lines.push(" </bpmn:process>");
417
+ lines.push("</bpmn:definitions>");
418
+ // Drop empty ref lines (nodes/events with no incoming or outgoing) so the output stays clean.
419
+ return `${lines.filter((l) => l.length > 0).join("\n")}\n`;
420
+ }
421
+
422
+ /** Render one node's BPMN element — a `callActivity` delegating to its engine-native body for
423
+ * `agent`/`wait`/`connector` (Decision 2), or a native `userTask` for `human`. The `switch` is
424
+ * EXHAUSTIVE over the closed kind union (the compile-time trust bound): a non-allowlisted kind cannot
425
+ * be instantiated. */
426
+ function renderNodeElement(w: NodeWiring, incoming: readonly string[], outgoing: readonly string[]): string {
427
+ const node = w.node;
428
+ const name = escapeXml(`${node.kind}: ${node.id}`);
429
+ const flowRefs =
430
+ incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`).join("\n") +
431
+ (incoming.length > 0 && outgoing.length > 0 ? "\n" : "") +
432
+ outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`).join("\n");
433
+ const body = flowRefs.length > 0 ? `\n${flowRefs}\n ` : "";
434
+
435
+ switch (node.kind) {
436
+ case "agent":
437
+ case "wait":
438
+ case "connector": {
439
+ const called = CALLED_ELEMENT[node.kind];
440
+ return (
441
+ ` <bpmn:callActivity id="${w.element}" name="${name}">\n` +
442
+ ` <bpmn:extensionElements>\n` +
443
+ ` <zeebe:calledElement processId="${called}" propagateAllChildVariables="false" />\n` +
444
+ ` </bpmn:extensionElements>${body ? "" : "\n"}` +
445
+ (body ? body : "") +
446
+ `</bpmn:callActivity>`
447
+ );
448
+ }
449
+ case "human":
450
+ return (
451
+ ` <bpmn:userTask id="${w.element}" name="${name}">\n` +
452
+ ` <bpmn:extensionElements>\n` +
453
+ ` <zeebe:userTask />\n` +
454
+ ` </bpmn:extensionElements>${body ? "" : "\n"}` +
455
+ (body ? body : "") +
456
+ `</bpmn:userTask>`
457
+ );
458
+ default:
459
+ return assertNever(node, "renderNodeElement");
460
+ }
461
+ }
462
+
463
+ /** Render a human-readable mermaid `flowchart` of the resolved graph — one node per box labelled
464
+ * `<kind>: <id>`, one arrow per edge (labelled with the referenced fact when qualified). Deterministic
465
+ * (nodes/edges already sorted). */
466
+ function renderMermaid(
467
+ graph: DeliveryGraph,
468
+ wirings: readonly NodeWiring[],
469
+ edges: readonly ResolvedDeliveryEdge[],
470
+ elementById: ReadonlyMap<string, string>,
471
+ ): string {
472
+ const lines: string[] = ["flowchart TD"];
473
+ if (graph.name !== undefined) lines.push(` %% ${escapeMermaid(graph.name)}`);
474
+ for (const w of wirings) {
475
+ lines.push(` ${w.element}["${escapeMermaid(`${w.node.kind}: ${w.node.id}`)}"]`);
476
+ }
477
+ for (const edge of edges) {
478
+ const from = mustGet(elementById, edge.fromNode);
479
+ const to = mustGet(elementById, edge.to);
480
+ if (edge.fromFact !== undefined) {
481
+ lines.push(` ${from} -- "${escapeMermaid(edge.fromFact)}" --> ${to}`);
482
+ } else {
483
+ lines.push(` ${from} --> ${to}`);
484
+ }
485
+ }
486
+ return `${lines.join("\n")}\n`;
487
+ }
@@ -80,7 +80,7 @@ test("merge-protocol: only a `ui` land method is decision-required; the rest are
80
80
  }
81
81
  });
82
82
 
83
- // --- task (plan-fanout w_gw "escalated?") ---
83
+ // --- task (plan-fanout w_gw "clean terminal?" — the implement-stage escalation net, #360) ---
84
84
 
85
85
  test("task: status=escalated with an answerable question is decision-required; blank is none", () => {
86
86
  assertEquals(classifyEscalation({ kind: "task", question: "which approach?" }), "decision-required");
@@ -39,8 +39,10 @@ export type EscalationKind =
39
39
  | "dead-end-base"
40
40
  // `mergeProtocol` (app/mergeProtocol.ts) — the repo's declared land method.
41
41
  | "merge-protocol"
42
- // plan-fanout `w_gw` "escalated?" gateway — an implementation agent reported
43
- // `status = "escalated"` with a question.
42
+ // plan-fanout `w_gw` "clean terminal?" gateway — the implement-stage escalation net (issue #360).
43
+ // Any non-clean-terminal slice outcome routes through the `record-wave-escalation` worker, which
44
+ // classifies with this kind: the agent's own answerable question passes through, and a no-machine-
45
+ // readable result (or a blank-question `escalated`) is synthesised into an answerable one.
44
46
  | "task";
45
47
 
46
48
  /** Everything the classifier may need from any raise site. Each field is consumed only by the