@nanobpm/nano-workforce 0.112.0 → 0.114.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.
@@ -34,29 +34,63 @@ import type {
34
34
  ResolvedDeliveryEdge,
35
35
  ResolvedDeliveryNode,
36
36
  } from "../nano-generated/api-io.d.ts";
37
+ import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
37
38
  import {
38
39
  type DeliveryGraphError,
39
40
  deliveryNodeFacts,
40
41
  resolveDeliveryFrom,
41
42
  validateDeliveryGraph,
42
43
  } 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",
44
+ import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
45
+
46
+ /** The engine-native BODY every node kind delegates to (Decision 2 — the graph SCHEDULES, it does not
47
+ * re-implement execution). Each node compiles to an EMBEDDED `bpmn:subProcess` (call activities are a
48
+ * no-op on the pinned WASM engine the child is never instantiatedso, like the rest of the
49
+ * codebase, `plan-fanout`'s `readiness-preflight` included, delegation is an inlined subProcess that
50
+ * shares the parent variable scope). The inner task delegates to a real, already-registered worker /
51
+ * user-task body:
52
+ * • `agent` → the `senior:*` job the node names (the implementation-task body).
53
+ * • `wait` → the `pr.readiness-probe` service task (the reusable ReadinessProbe poll gate; the
54
+ * `pr` kind is S2). Polling its own target is what makes an unrelated upstream event
55
+ * unable to falsely resolve the wait (#274/S2 concurrency-correctness).
56
+ * • `human` → the S3 scheduled user-task + generic form + SLA (`delivery-human-task__<el>`,
57
+ * recognised by the `isDeliveryHumanElement` convention so it routes through the ONE
58
+ * canonical completer and the Tasks inbox).
59
+ * • `connector` → the `pr.delivery-connector` dedupe stub (forward-declared; real I/O deferred per
60
+ * the ADR non-goals — but a real, idempotent node).
61
+ * Kept as the single source of truth so the compiler, the resolved-preview and the runner agree on
62
+ * the delegation target each node names. */
63
+ const DELEGATE_TASK_TYPE: Record<Exclude<DeliveryNode["kind"], "agent" | "human">, string> = {
64
+ wait: "pr.readiness-probe",
65
+ connector: DELIVERY_CONNECTOR_TASK_TYPE,
54
66
  };
55
67
 
68
+ /** The BPMN `bpmn:process` id of the compiled one-shot definition (S1). Stable across compiles of the
69
+ * same graph — the pure S1 preview always emits this base id. The S4 runner (`deliveryRunner.ts`)
70
+ * derives a CONTENT-ADDRESSED deploy id from it (`delivery-graph-<sha>`), so re-deploying the same
71
+ * graph is idempotent and stale definitions are GC-identifiable; exported here as the single source of
72
+ * truth so the runner never hardcodes the literal it substitutes. */
73
+ export const DELIVERY_GRAPH_PROCESS_ID = "delivery-graph";
74
+
75
+ /** The BPMN element id a `human` node's inlined user task carries. One user task per human node (the
76
+ * compiled one-shot inlines each), so the id is per-node (`delivery-human-task__<element>`) — the
77
+ * `isDeliveryHumanElement` convention (single source of truth in `deliveryHuman.ts`) is what keeps it
78
+ * recognised by `ESCALATION_TASK_ELEMENTS` / the Tasks inbox despite the per-node suffix. */
79
+ function humanTaskElement(element: string): string {
80
+ return `${DELIVERY_HUMAN_ELEMENT}__${element}`;
81
+ }
82
+
83
+ /** The BPMN element id a service node's bounded-timeout escalation user task carries — same
84
+ * human-completable convention as a human node, so a stalled `agent`/`wait`/`connector` escalates onto
85
+ * the Tasks inbox and is answerable by a human OR an agent (ADR 0046). */
86
+ function escalationTaskElement(element: string): string {
87
+ return `${DELIVERY_HUMAN_ELEMENT}__${element}__esc`;
88
+ }
89
+
56
90
  /** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
57
91
  * the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
58
92
  * compiler arm, `tsc` flags this call — the compile-time half of the trust bound. */
59
- function assertNever(value: never, context: string): never {
93
+ export function assertNever(value: never, context: string): never {
60
94
  throw new Error(`${context}: unreachable — non-allowlisted delivery node kind ${JSON.stringify(value)}`);
61
95
  }
62
96
 
@@ -70,6 +104,20 @@ function escapeXml(value: string): string {
70
104
  .replace(/'/g, "&apos;");
71
105
  }
72
106
 
107
+ /** Render a `name=value` XML attribute, choosing the delimiter so FEEL string literals survive the
108
+ * WASM engine's deploy path. That path does NOT decode `&#34;`/`&quot;` entities before FEEL parsing,
109
+ * so a FEEL expression containing a string literal MUST use a SINGLE-QUOTE attribute delimiter with
110
+ * literal double-quotes inside (verified empirically — an entity-escaped `"` silently yields no value,
111
+ * not an incident). When the value has no `"`, the ordinary double-quote form (with full entity
112
+ * escaping) is used. Deterministic. */
113
+ function attr(name: string, value: string): string {
114
+ if (value.includes('"')) {
115
+ const inner = value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&apos;");
116
+ return `${name}='${inner}'`;
117
+ }
118
+ return `${name}="${escapeXml(value)}"`;
119
+ }
120
+
73
121
  /** Escape a string for use inside a mermaid quoted label. Mermaid uses `#` HTML-entity escapes; a
74
122
  * double quote inside a `"…"` label must become `#quot;` so the label stays well-formed. */
75
123
  function escapeMermaid(value: string): string {
@@ -89,6 +137,36 @@ function byCodeUnit(a: string, b: string): number {
89
137
  return a < b ? -1 : a > b ? 1 : 0;
90
138
  }
91
139
 
140
+ /** The engine variable a producer node's OUTPUT mapping reads to publish a declared emitted `fact`
141
+ * (S4 late-binding). Each node kind's real body exposes the observed value under a canonical name:
142
+ * • `wait` (readiness-gate) — a `mergedSha` fact reads the merge oid; an `artifact` fact reads the
143
+ * `resolvedArtifact` bind (mirroring the `capability`/`pr` probe binds); anything else reads the
144
+ * probe's `detail`.
145
+ * • `human` (delivery-human) — an `artifact` fact reads `humanEmitArtifact`; anything else reads
146
+ * `humanEmitValue` (the generic typed-emit form's captured value).
147
+ * • `agent`/`connector` — the body's job worker returns the value under the fact's own name.
148
+ * Deterministic and total over the closed kind set. */
149
+ function factSourceVar(kind: DeliveryNode["kind"], fact: DeliveryFact): string {
150
+ switch (kind) {
151
+ case "wait":
152
+ return fact.name === "mergedSha" ? "mergedSha" : fact.type === "artifact" ? "resolvedArtifact" : "detail";
153
+ case "human":
154
+ return fact.type === "artifact" ? "humanEmitArtifact" : "humanEmitValue";
155
+ case "agent":
156
+ case "connector":
157
+ return fact.name;
158
+ default:
159
+ return assertNever(kind, "factSourceVar");
160
+ }
161
+ }
162
+
163
+ /** A FEEL string literal (raw, with literal double-quotes). XML-attribute escaping and delimiter
164
+ * choice are handled by `attr` at emit time — do NOT pre-escape here, or the quote is hidden from
165
+ * `attr`'s single-quote-delimiter heuristic and gets double-encoded. */
166
+ function feelStr(value: string): string {
167
+ return JSON.stringify(value);
168
+ }
169
+
92
170
  /** One sequence flow in the compiled process — `source`/`target` are element ids, `name` an optional
93
171
  * (fact) label. */
94
172
  interface Flow {
@@ -98,6 +176,15 @@ interface Flow {
98
176
  name?: string;
99
177
  }
100
178
 
179
+ /** One late-binding input a consumer node receives (S4): the producer node's business id, the
180
+ * referenced emitted fact name, and the flat parent variable (`<producerElement>_<fact>`) the
181
+ * producer's output mapping publishes the observed value into. */
182
+ interface BoundInput {
183
+ fromNode: string;
184
+ fact: string;
185
+ producerElement: string;
186
+ }
187
+
101
188
  /** A compiled node's structural fixtures: its own BPMN `element` id, and — when it has >1 downstream
102
189
  * or >1 upstream — the parallel fork/join gateway that fans its flow out/in. `entry` is the id
103
190
  * upstream flows target (the join, else the element); `exit` is the id downstream flows leave from
@@ -250,7 +337,24 @@ export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult
250
337
  }
251
338
  const numberedFlows: Flow[] = flows.map((f, i) => ({ id: `f${i}`, ...f }));
252
339
 
253
- const bpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway);
340
+ // Per-consumer late-binding inputs (S4): for every FACT-QUALIFIED edge, the consumer node receives
341
+ // the producer's emitted fact as a `boundFacts` list entry (`{from,name,value}`), threaded from the
342
+ // flat `<producerElement>_<fact>` variable the producer's output mapping publishes. Grouped by the
343
+ // consumer's element id and sorted (producer element, then fact) for determinism.
344
+ const boundInputsByElement = new Map<string, BoundInput[]>();
345
+ for (const edge of resolvedEdges) {
346
+ if (edge.fromFact === undefined) continue;
347
+ const consumerEl = mustGet(elementById, edge.to);
348
+ const producerEl = mustGet(elementById, edge.fromNode);
349
+ const list = boundInputsByElement.get(consumerEl) ?? [];
350
+ list.push({ fromNode: edge.fromNode, fact: edge.fromFact, producerElement: producerEl });
351
+ boundInputsByElement.set(consumerEl, list);
352
+ }
353
+ for (const list of boundInputsByElement.values()) {
354
+ list.sort((a, b) => byCodeUnit(a.producerElement, b.producerElement) || byCodeUnit(a.fact, b.fact));
355
+ }
356
+
357
+ const bpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
254
358
  const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
255
359
  const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
256
360
  const humanNodes = buildHumanNodes(nodes);
@@ -280,13 +384,33 @@ function buildResolved(
280
384
  element: w.element,
281
385
  emits: normaliseEmits(w.node),
282
386
  dependsOn: [...(producersById.get(w.node.id) ?? [])],
387
+ calledElement: delegateTarget(w.node, w.element),
283
388
  };
284
- return w.node.kind === "human" ? base : { ...base, calledElement: CALLED_ELEMENT[w.node.kind] };
389
+ return base;
285
390
  });
286
391
  const resolved: CompileDeliveryGraphResult["resolved"] = { nodes, edges: [...edges] };
287
392
  return graph.name !== undefined ? { name: graph.name, ...resolved } : resolved;
288
393
  }
289
394
 
395
+ /** The engine-native delegation target a node's inlined subProcess drives — its job `taskType`
396
+ * (`agent` → the named `senior:*` job; `wait` → `pr.readiness-probe`; `connector` →
397
+ * `pr.delivery-connector`) or, for a `human` node, its per-node user-task element id. Surfaced on the
398
+ * resolved preview so a co-designing agent sees exactly which worker/user-task each node fans out to.
399
+ * Deterministic and total over the closed kind set. */
400
+ function delegateTarget(node: DeliveryNode, element: string): string {
401
+ switch (node.kind) {
402
+ case "agent":
403
+ return node.agent.jobType;
404
+ case "human":
405
+ return humanTaskElement(element);
406
+ case "wait":
407
+ case "connector":
408
+ return DELEGATE_TASK_TYPE[node.kind];
409
+ default:
410
+ return assertNever(node, "delegateTarget");
411
+ }
412
+ }
413
+
290
414
  /** Extract the human STOP-points (sorted by id) — where the graph pauses for a person/agent, with the
291
415
  * instruction, optional attached form, and the typed facts the node will emit. */
292
416
  function buildHumanNodes(nodes: readonly DeliveryNode[]): DeliveryHumanStop[] {
@@ -334,6 +458,7 @@ function renderBpmn(
334
458
  flows: readonly Flow[],
335
459
  startForkGateway: string | undefined,
336
460
  endJoinGateway: string | undefined,
461
+ boundInputsByElement: ReadonlyMap<string, BoundInput[]>,
337
462
  ): string {
338
463
  // Precompute incoming/outgoing flow-id maps once (single pass over flows) so BPMN rendering stays
339
464
  // linear in the number of flows instead of O(elements * flows) from repeated full-array filtering.
@@ -359,10 +484,11 @@ function renderBpmn(
359
484
  lines.push(
360
485
  '<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ' +
361
486
  'xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ' +
487
+ 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' +
362
488
  'id="Definitions_delivery_graph" targetNamespace="http://nanobpm.io/nano-workforce">',
363
489
  );
364
490
  const processName = graph.name ?? "Delivery graph";
365
- lines.push(` <bpmn:process id="delivery-graph" name="${escapeXml(processName)}" isExecutable="true">`);
491
+ lines.push(` <bpmn:process id="${DELIVERY_GRAPH_PROCESS_ID}" name="${escapeXml(processName)}" isExecutable="true">`);
366
492
 
367
493
  // Start event.
368
494
  lines.push(' <bpmn:startEvent id="Start" name="Graph opened">');
@@ -385,7 +511,7 @@ function renderBpmn(
385
511
  lines.push(refs("outgoing", outgoing(w.joinGateway)));
386
512
  lines.push(" </bpmn:parallelGateway>");
387
513
  }
388
- lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element)));
514
+ lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element), boundInputsByElement.get(w.element) ?? []));
389
515
  if (w.forkGateway) {
390
516
  lines.push(` <bpmn:parallelGateway id="${w.forkGateway}" name="fan out of ${escapeXml(w.node.id)}">`);
391
517
  lines.push(refs("incoming", incoming(w.forkGateway)));
@@ -419,47 +545,268 @@ function renderBpmn(
419
545
  return `${lines.filter((l) => l.length > 0).join("\n")}\n`;
420
546
  }
421
547
 
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 {
548
+ /** Render one node as an EMBEDDED `bpmn:subProcess` the engine-native delegation unit (Decision 2).
549
+ * Call activities are a no-op on the pinned WASM engine (the child is never instantiated), so — like
550
+ * `plan-fanout`'s `readiness-preflight` every node inlines a subProcess that shares the parent
551
+ * variable scope. A single outer in/out keeps the compiler's fan-out/fan-in topology clean; the
552
+ * subProcess's own `zeebe:ioMapping` (a) seeds the body its config from `nodeInputs.<element>` (runner-
553
+ * set), (b) threads late-binding `boundFacts` from upstream producers' emitted-fact variables, and (c)
554
+ * publishes this node's declared emits into flat `<element>_<fact>` variables a downstream consumer
555
+ * binds. Every node is bounded (a timeout escalates onto a human-completable user task) and resumable
556
+ * (engine-persisted). The `switch` is EXHAUSTIVE over the closed kind union — the compile-time trust
557
+ * bound. */
558
+ function renderNodeElement(
559
+ w: NodeWiring,
560
+ incoming: readonly string[],
561
+ outgoing: readonly string[],
562
+ boundInputs: readonly BoundInput[],
563
+ ): string {
564
+ const el = w.element;
565
+ const name = escapeXml(`${w.node.kind}: ${w.node.id}`);
566
+ const flowRefs = [
567
+ ...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
568
+ ...outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`),
569
+ ];
570
+ const io = ioMappingLines(w, boundInputs);
571
+ const inner = innerBodyLines(w);
572
+ const lines = [
573
+ ` <bpmn:subProcess id="${el}" name="${name}">`,
574
+ ...flowRefs,
575
+ " <bpmn:extensionElements>",
576
+ ...io,
577
+ " </bpmn:extensionElements>",
578
+ ...inner,
579
+ " </bpmn:subProcess>",
580
+ ];
581
+ return lines.join("\n");
582
+ }
583
+
584
+ /** The subProcess `<zeebe:ioMapping>` lines (8-space indented, inside `extensionElements`). Inputs
585
+ * pull the body's config from `nodeInputs.<element>` (runner-seeded) plus any late-binding
586
+ * `boundFacts`; outputs publish the node's declared emits into flat `<element>_<fact>` variables.
587
+ * Deterministic — fixed input/output order, positional fact targets. FEEL sources go through `attr`
588
+ * (single-quote delimiter) so embedded string literals survive the engine's deploy path. */
589
+ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): string[] {
590
+ const el = w.element;
427
591
  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 ` : "";
592
+ const inputs: { source: string; target: string }[] = [];
593
+ const outputs: { source: string; target: string }[] = [];
594
+ const cfg = (field: string): string => `=nodeInputs.${el}.${field}`;
595
+ const guarded = (src: string): string => `=if (is defined(${src})) then ${src} else null`;
434
596
 
435
597
  switch (node.kind) {
436
598
  case "agent":
599
+ inputs.push({ source: cfg("jobType"), target: "jobType" });
600
+ inputs.push({ source: cfg("appendPrompt"), target: "appendPrompt" });
601
+ inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
602
+ break;
437
603
  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
- }
604
+ inputs.push({ source: cfg("gateKey"), target: "gateKey" });
605
+ inputs.push({ source: cfg("probe"), target: "probe" });
606
+ inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
607
+ break;
449
608
  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
- );
609
+ inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
610
+ inputs.push({ source: cfg("escalationAssignee"), target: "escalationAssignee" });
611
+ break;
612
+ case "connector":
613
+ inputs.push({ source: cfg("target"), target: "target" });
614
+ inputs.push({ source: cfg("dedupeKey"), target: "dedupeKey" });
615
+ inputs.push({ source: cfg("payload"), target: "payload" });
616
+ inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
617
+ break;
618
+ default:
619
+ return assertNever(node, "ioMappingLines");
620
+ }
621
+
622
+ // Late-binding: a deterministic FEEL list literal of the upstream producers' emitted facts, keyed
623
+ // exactly as the edge references them (`<producerNode>.<fact>`), read from the flat parent variable
624
+ // each producer publishes. Guarded so an as-yet-unobserved fact threads as null, not a FEEL error.
625
+ if (boundInputs.length > 0) {
626
+ const entries = boundInputs.map((b) => {
627
+ const varName = `${b.producerElement}_${b.fact}`;
628
+ return `{from: ${feelStr(b.fromNode)}, name: ${feelStr(b.fact)}, value: if (is defined(${varName})) then ${varName} else null}`;
629
+ });
630
+ inputs.push({ source: `=[${entries.join(", ")}]`, target: "boundFacts" });
631
+ }
632
+
633
+ // Outputs: publish each declared emit into `<element>_<fact>` for a downstream consumer to bind.
634
+ for (const fact of normaliseEmits(node)) {
635
+ outputs.push({ source: guarded(factSourceVar(node.kind, fact)), target: `${el}_${fact.name}` });
636
+ }
637
+
638
+ const lines: string[] = [" <zeebe:ioMapping>"];
639
+ for (const i of inputs) lines.push(` <zeebe:input ${attr("source", i.source)} target="${i.target}" />`);
640
+ for (const o of outputs) lines.push(` <zeebe:output ${attr("source", o.source)} target="${o.target}" />`);
641
+ lines.push(" </zeebe:ioMapping>");
642
+ return lines;
643
+ }
644
+
645
+ /** The inner flow of a node's subProcess (6-space indented). `agent`/`connector` delegate to a job
646
+ * worker; `wait` polls the ReadinessProbe gate (blocking until its target is observed ready — polling
647
+ * its OWN target is what makes an unrelated upstream event unable to falsely resolve it); `human` is
648
+ * the S3 scheduled user-task + generic form + SLA. Each is a single-entry / single-exit subgraph with
649
+ * a bounded timeout that escalates onto a human-completable user task (or, for `human`, records an
650
+ * escalated outcome). */
651
+ function innerBodyLines(w: NodeWiring): string[] {
652
+ const el = w.element;
653
+ const node = w.node;
654
+ switch (node.kind) {
655
+ case "agent":
656
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), []);
657
+ case "connector":
658
+ return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, []);
659
+ case "wait":
660
+ return waitBodyLines(el, node.id);
661
+ case "human":
662
+ return humanBodyLines(el, node.id);
458
663
  default:
459
- return assertNever(node, "renderNodeElement");
664
+ return assertNever(node, "innerBodyLines");
460
665
  }
461
666
  }
462
667
 
668
+ /** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
669
+ * escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
670
+ * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines. */
671
+ function serviceBodyLines(el: string, nodeId: string, taskDefAttr: string, taskProps: readonly string[]): string[] {
672
+ const esc = escalationTaskElement(el);
673
+ const taskExt =
674
+ taskProps.length > 0
675
+ ? [
676
+ " <bpmn:extensionElements>",
677
+ ` <zeebe:taskDefinition ${taskDefAttr} />`,
678
+ " <zeebe:properties>",
679
+ ...taskProps,
680
+ " </zeebe:properties>",
681
+ " </bpmn:extensionElements>",
682
+ ]
683
+ : [
684
+ " <bpmn:extensionElements>",
685
+ ` <zeebe:taskDefinition ${taskDefAttr} />`,
686
+ " </bpmn:extensionElements>",
687
+ ];
688
+ return [
689
+ ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
690
+ ` <bpmn:serviceTask id="${el}_task" name="${escapeXml(nodeId)}">`,
691
+ ...taskExt,
692
+ ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
693
+ ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
694
+ " </bpmn:serviceTask>",
695
+ ` <bpmn:boundaryEvent id="${el}_be" name="Node timed out" attachedToRef="${el}_task">`,
696
+ ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
697
+ ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=nodeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
698
+ " </bpmn:boundaryEvent>",
699
+ ...escalationTaskLines(esc, nodeId, [`${el}_i2`], `${el}_i3`),
700
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
701
+ flow(`${el}_i0`, `${el}_start`, `${el}_task`),
702
+ flow(`${el}_i1`, `${el}_task`, `${el}_end`),
703
+ flow(`${el}_i2`, `${el}_be`, esc),
704
+ flow(`${el}_i3`, esc, `${el}_end`),
705
+ ];
706
+ }
707
+
708
+ /** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
709
+ * `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
710
+ * never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
711
+ function waitBodyLines(el: string, nodeId: string): string[] {
712
+ const esc = escalationTaskElement(el);
713
+ return [
714
+ ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
715
+ ` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
716
+ " <bpmn:extensionElements>",
717
+ ` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
718
+ " <zeebe:properties>",
719
+ ' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
720
+ ' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
721
+ " </zeebe:properties>",
722
+ " </bpmn:extensionElements>",
723
+ ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
724
+ ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
725
+ " </bpmn:serviceTask>",
726
+ ` <bpmn:boundaryEvent id="${el}_be" name="Gate timed out" attachedToRef="${el}_task">`,
727
+ ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
728
+ ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
729
+ " </bpmn:boundaryEvent>",
730
+ ` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_i4">`,
731
+ ` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
732
+ ` <bpmn:outgoing>${el}_i3</bpmn:outgoing>`,
733
+ ` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
734
+ " </bpmn:exclusiveGateway>",
735
+ ...escalationTaskLines(esc, nodeId, [`${el}_i2`, `${el}_i4`], `${el}_i5`),
736
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i3</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming></bpmn:endEvent>`,
737
+ flow(`${el}_i0`, `${el}_start`, `${el}_task`),
738
+ flow(`${el}_i1`, `${el}_task`, `${el}_gw`),
739
+ flow(`${el}_i2`, `${el}_be`, esc),
740
+ ` <bpmn:sequenceFlow id="${el}_i3" name="ready" sourceRef="${el}_gw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
741
+ ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_gw" targetRef="${esc}" />`,
742
+ flow(`${el}_i5`, esc, `${el}_end`),
743
+ ];
744
+ }
745
+
746
+ /** `human` body: the S3 scheduled user-task (`delivery-human-task__<el>`) + generic form + assignment
747
+ * + SLA. On completion the form's captured typed value is output (`humanEmitValue`/`humanEmitArtifact`
748
+ * — the subProcess ioMapping then publishes it as the node's fact); on SLA expiry the node records an
749
+ * `escalated` outcome and settles (bounded — the graph cannot silently wedge). Mirrors the standalone
750
+ * `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`). */
751
+ function humanBodyLines(el: string, nodeId: string): string[] {
752
+ const task = humanTaskElement(el);
753
+ const assignee =
754
+ '=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != "") then escalationAssignee else null';
755
+ return [
756
+ ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
757
+ ` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(nodeId)}">`,
758
+ " <bpmn:extensionElements>",
759
+ ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
760
+ " <zeebe:userTask />",
761
+ ` <zeebe:assignmentDefinition candidateGroups="operators" ${attr("assignee", assignee)} />`,
762
+ " <zeebe:ioMapping>",
763
+ ` <zeebe:output ${attr("source", '="completed"')} target="humanOutcome" />`,
764
+ ` <zeebe:output ${attr("source", "=if (is defined(value)) then value else null")} target="humanEmitValue" />`,
765
+ ` <zeebe:output ${attr("source", "=if (is defined(resolvedArtifact)) then resolvedArtifact else null")} target="humanEmitArtifact" />`,
766
+ ` <zeebe:output ${attr("source", "=if (is defined(note)) then note else null")} target="humanNote" />`,
767
+ " </zeebe:ioMapping>",
768
+ " </bpmn:extensionElements>",
769
+ ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
770
+ ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
771
+ " </bpmn:userTask>",
772
+ ` <bpmn:boundaryEvent id="${el}_sla" name="SLA elapsed" attachedToRef="${task}">`,
773
+ ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
774
+ ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=escalationSlaTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
775
+ " </bpmn:boundaryEvent>",
776
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming></bpmn:endEvent>`,
777
+ ` <bpmn:endEvent id="${el}_escEnd" name="Escalated">`,
778
+ " <bpmn:extensionElements>",
779
+ ` <zeebe:ioMapping><zeebe:input ${attr("source", '="escalated"')} target="humanOutcome" /></zeebe:ioMapping>`,
780
+ " </bpmn:extensionElements>",
781
+ ` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
782
+ " </bpmn:endEvent>",
783
+ flow(`${el}_i0`, `${el}_start`, task),
784
+ flow(`${el}_i1`, task, `${el}_end`),
785
+ flow(`${el}_i2`, `${el}_sla`, `${el}_escEnd`),
786
+ ];
787
+ }
788
+
789
+ /** A bounded node's escalation user task — a human-completable stop (`isDeliveryHumanElement`
790
+ * convention) that a human OR an agent (ADR 0046) answers to unstick a stalled node. */
791
+ function escalationTaskLines(esc: string, nodeId: string, incoming: readonly string[], outgoing: string): string[] {
792
+ return [
793
+ ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(nodeId)}">`,
794
+ " <bpmn:extensionElements>",
795
+ ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
796
+ " <zeebe:userTask />",
797
+ ' <zeebe:assignmentDefinition candidateGroups="operators" />',
798
+ " </bpmn:extensionElements>",
799
+ ...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
800
+ ` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
801
+ " </bpmn:userTask>",
802
+ ];
803
+ }
804
+
805
+ /** A plain `<bpmn:sequenceFlow>` (6-space indented). */
806
+ function flow(id: string, source: string, target: string): string {
807
+ return ` <bpmn:sequenceFlow id="${id}" sourceRef="${source}" targetRef="${target}" />`;
808
+ }
809
+
463
810
  /** Render a human-readable mermaid `flowchart` of the resolved graph — one node per box labelled
464
811
  * `<kind>: <id>`, one arrow per edge (labelled with the referenced fact when qualified). Deterministic
465
812
  * (nodes/edges already sorted). */