@nanobpm/nano-workforce 0.120.0 → 0.120.2

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/app/capabilityNeed.test.ts +4 -2
  3. package/app/capabilityNeed.ts +3 -1
  4. package/app/deliveryGraphCompiler.test.ts +72 -44
  5. package/app/deliveryGraphCompiler.ts +121 -19
  6. package/app/deliveryGraphRun.test.ts +2 -2
  7. package/app/deliveryRunner.test.ts +29 -17
  8. package/app/deliveryRunner.ts +31 -10
  9. package/app/feature.test.ts +3 -1
  10. package/app/feature.ts +9 -0
  11. package/app/featureReadiness.test.ts +7 -4
  12. package/app/featureReadiness.ts +8 -3
  13. package/app/plan.test.ts +1 -1
  14. package/app/plan.ts +9 -0
  15. package/app/planFanoutPreflight.test.ts +12 -12
  16. package/app/planLowering.test.ts +2 -0
  17. package/app/planLowering.ts +7 -2
  18. package/app/pollUserTasks.test.ts +49 -1
  19. package/app/readiness.test.ts +9 -0
  20. package/app/readiness.ts +25 -0
  21. package/app/service.ts +24 -6
  22. package/biome.json +24 -1
  23. package/e2e/delivery-graph.e2e.ts +2 -1
  24. package/e2e/feature-preflight.e2e.ts +2 -0
  25. package/e2e/inter-epic-dependency.e2e.ts +7 -1
  26. package/e2e/plan-fanout-preflight.e2e.ts +2 -0
  27. package/e2e/readiness-gate.e2e.ts +27 -11
  28. package/operations/compileDeliveryGraph.test.ts +3 -0
  29. package/operations/compileDeliveryGraph.ts +1 -1
  30. package/operations/dispatchDeliveryGraph.ts +1 -1
  31. package/operations/previewDeliveryGraph.ts +1 -1
  32. package/operations/startDeliveryGraph.ts +1 -1
  33. package/package.json +3 -2
  34. package/resources/processes/feature.bpmn +168 -52
  35. package/resources/processes/plan-fanout.bpmn +168 -52
  36. package/resources/processes/readiness-gate.bpmn +194 -84
  37. package/workers/readiness-probe/worker.test.ts +82 -238
  38. package/workers/readiness-probe/worker.ts +46 -128
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.120.2](https://github.com/nanobpm/nano-workforce/compare/v0.120.1...v0.120.2) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **delivery-graph:** attach DI to compiled BPMN via layoutBpmn ([#440](https://github.com/nanobpm/nano-workforce/issues/440)) ([#444](https://github.com/nanobpm/nano-workforce/issues/444)) ([ed87208](https://github.com/nanobpm/nano-workforce/commit/ed87208e3c4d941a88a36ec5cad6c2661e15d587)), closes [#34](https://github.com/nanobpm/nano-workforce/issues/34)
7
+ * **tasks:** surface delivery-graph human gates in the Tasks inbox ([#442](https://github.com/nanobpm/nano-workforce/issues/442)) ([#443](https://github.com/nanobpm/nano-workforce/issues/443)) ([a550e77](https://github.com/nanobpm/nano-workforce/commit/a550e7730fe496f34e1a1017fb84d448a2840f15))
8
+
9
+ ## [0.120.1](https://github.com/nanobpm/nano-workforce/compare/v0.120.0...v0.120.1) (2026-08-22)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **readiness:** make pr.readiness-probe single-shot; engine owns retry cadence ([#428](https://github.com/nanobpm/nano-workforce/issues/428)) ([#429](https://github.com/nanobpm/nano-workforce/issues/429)) ([07db031](https://github.com/nanobpm/nano-workforce/commit/07db031f0d5e3f63ff47ffa51583bdef3012a2ab))
15
+
1
16
  # [0.120.0](https://github.com/nanobpm/nano-workforce/compare/v0.119.0...v0.120.0) (2026-08-22)
2
17
 
3
18
 
@@ -106,9 +106,11 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
106
106
  planKey: "owner/repo#289",
107
107
  taskId: "issue-289",
108
108
  probeTimeout: "PT12H",
109
+ probePollEvery: "PT15S",
109
110
  });
110
111
  assertEquals(input.gateKey, "owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban");
111
112
  assertEquals(input.probeTimeout, "PT12H");
113
+ assertEquals(input.probePollEvery, "PT15S");
112
114
  assertEquals(input.onTimeout, "escalate");
113
115
  assertEquals(input.probe.kind, "capability");
114
116
  assertEquals(input.probe.target, "github-releases:nanobpm/nano-ide");
@@ -121,7 +123,7 @@ test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability p
121
123
  test("capabilityNeedToProbeInput: omits verifyCommand when the need has none", () => {
122
124
  const input = capabilityNeedToProbeInput(
123
125
  { capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" },
124
- { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
126
+ { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
125
127
  );
126
128
  assertEquals(input.probe.match?.verifyCommand, undefined);
127
129
  });
@@ -131,7 +133,7 @@ test("capabilityNeedToProbeInput: throws when the handle names no releases sourc
131
133
  () =>
132
134
  capabilityNeedToProbeInput(
133
135
  { capabilityRef: "#274", package: "@nanobpm/urban" },
134
- { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
136
+ { planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H", probePollEvery: "PT15S" },
135
137
  ),
136
138
  UnresolvableCapabilityRefError,
137
139
  "names no owner/repo releases source",
@@ -36,6 +36,7 @@ export interface CapabilityNeed {
36
36
  export interface ReadinessProbeInput {
37
37
  readonly gateKey: string;
38
38
  readonly probeTimeout: string;
39
+ readonly probePollEvery: string;
39
40
  readonly onTimeout?: OnTimeout;
40
41
  readonly probe: ReadinessProbe;
41
42
  }
@@ -152,7 +153,7 @@ export function capabilityTaskBarrierKey(planKey: string, taskId: string): strin
152
153
  * the handle names no releases source. */
153
154
  export function capabilityNeedToProbeInput(
154
155
  need: CapabilityNeed,
155
- opts: { planKey: string; taskId: string; probeTimeout: string },
156
+ opts: { planKey: string; taskId: string; probeTimeout: string; probePollEvery: string },
156
157
  ): ReadinessProbeInput {
157
158
  const repo = capabilityReleasesRepo(need.capabilityRef);
158
159
  if (!repo) throw new UnresolvableCapabilityRefError(need.capabilityRef);
@@ -170,6 +171,7 @@ export function capabilityNeedToProbeInput(
170
171
  return {
171
172
  gateKey: capabilityGateKey(opts.planKey, opts.taskId, need.capabilityRef, need.package),
172
173
  probeTimeout: opts.probeTimeout,
174
+ probePollEvery: opts.probePollEvery,
173
175
  onTimeout: "escalate",
174
176
  probe,
175
177
  };
@@ -16,15 +16,15 @@ import { assert, assertEquals } from "#test-assert";
16
16
  import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
17
17
 
18
18
  /** Compile and assert success, returning the narrowed ok-result. */
19
- function compileOk(graph: unknown) {
20
- const r = compileDeliveryGraph(graph);
19
+ async function compileOk(graph: unknown) {
20
+ const r = await compileDeliveryGraph(graph);
21
21
  assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
22
22
  return r;
23
23
  }
24
24
 
25
25
  /** Compile and assert failure, returning the errors. */
26
- function compileFail(graph: unknown) {
27
- const r = compileDeliveryGraph(graph);
26
+ async function compileFail(graph: unknown) {
27
+ const r = await compileDeliveryGraph(graph);
28
28
  assert(!r.ok, `expected ok:false, got ${JSON.stringify(r)}`);
29
29
  return r.errors;
30
30
  }
@@ -57,8 +57,8 @@ const RELEASE_RUNBOOK = {
57
57
  ],
58
58
  };
59
59
 
60
- test("happy path: a well-formed graph compiles to a full preview with no side effects", () => {
61
- const r = compileOk(RELEASE_RUNBOOK);
60
+ test("happy path: a well-formed graph compiles to a full preview with no side effects", async () => {
61
+ const r = await compileOk(RELEASE_RUNBOOK);
62
62
  assertEquals(r.ok, true);
63
63
  assert(r.bpmn.includes("<bpmn:process id=\"delivery-graph\""), "bpmn carries the compiled process");
64
64
  assert(r.diagram.startsWith("flowchart TD"), "diagram is a mermaid flowchart");
@@ -70,25 +70,25 @@ test("happy path: a well-formed graph compiles to a full preview with no side ef
70
70
  assertEquals(r.sideEffects.length, 2);
71
71
  });
72
72
 
73
- test("determinism: the same JSON always yields byte-identical bpmn/diagram/resolved", () => {
74
- const a = compileOk(RELEASE_RUNBOOK);
75
- const b = compileOk(RELEASE_RUNBOOK);
73
+ test("determinism: the same JSON always yields byte-identical bpmn/diagram/resolved", async () => {
74
+ const a = await compileOk(RELEASE_RUNBOOK);
75
+ const b = await compileOk(RELEASE_RUNBOOK);
76
76
  assertEquals(a.bpmn, b.bpmn);
77
77
  assertEquals(a.diagram, b.diagram);
78
78
  assertEquals(JSON.stringify(a.resolved), JSON.stringify(b.resolved));
79
79
  // Node ORDER in the input must not change the artifact (nodes are sorted by id).
80
80
  const shuffled = { ...RELEASE_RUNBOOK, nodes: [...RELEASE_RUNBOOK.nodes].reverse() };
81
- const c = compileOk(shuffled);
81
+ const c = await compileOk(shuffled);
82
82
  assertEquals(c.bpmn, a.bpmn);
83
83
  assertEquals(c.diagram, a.diagram);
84
84
  });
85
85
 
86
- test("trust bound: every node inlines an embedded subProcess delegating to an allowlisted body — no other activity type", () => {
87
- const r = compileOk(RELEASE_RUNBOOK);
88
- // Each of the 4 nodes compiles to an EMBEDDED subProcess (call activities are a no-op on the pinned
86
+ test("trust bound: every node inlines an embedded subProcess delegating to an allowlisted body — no other activity type", async () => {
87
+ const r = await compileOk(RELEASE_RUNBOOK);
88
+ // Each of the 4 nodes compiles to an EMBEDDED subProcess; wait adds one nested retry-loop subProcess (call activities are a no-op on the pinned
89
89
  // WASM engine, so delegation is an inlined subProcess sharing the parent scope — never a callActivity).
90
90
  assertEquals((r.bpmn.match(/<bpmn:callActivity/g) ?? []).length, 0);
91
- assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length, 4);
91
+ assertEquals((r.bpmn.match(/<bpmn:subProcess /g) ?? []).length, 5);
92
92
  assert(!r.bpmn.includes("<bpmn:scriptTask"), "no script task is ever emitted");
93
93
  // Each node's inner body delegates to an allowlisted engine-native body: a `serviceTask` typed to a
94
94
  // worker (agent → its `senior:*` job; wait → `pr.readiness-probe`; connector → `pr.delivery-connector`)
@@ -103,8 +103,8 @@ test("trust bound: every node inlines an embedded subProcess delegating to an al
103
103
  assert(/<bpmn:userTask id="delivery-human-task__n\d+__esc"/.test(r.bpmn), "a bounded node inlines an escalation user task");
104
104
  });
105
105
 
106
- test("late-binding: a fact-qualified edge threads a boundFacts input into the consumer subProcess", () => {
107
- const r = compileOk(RELEASE_RUNBOOK);
106
+ test("late-binding: a fact-qualified edge threads a boundFacts input into the consumer subProcess", async () => {
107
+ const r = await compileOk(RELEASE_RUNBOOK);
108
108
  // `publish.resolvedArtifact -> consume`: the connector subProcess receives the human's emitted fact as
109
109
  // a boundFacts list entry, read from the flat `<producerElement>_<fact>` variable the producer publishes.
110
110
  // FEEL string literals must use single-quote XML-attribute delimiters (the engine deploy path drops
@@ -114,8 +114,8 @@ test("late-binding: a fact-qualified edge threads a boundFacts input into the co
114
114
  assert(boundInput, `boundFacts is a single-quoted FEEL list literal, got: ${r.bpmn.match(/source='[^']*' target="boundFacts"/)?.[0] ?? r.bpmn.match(/source="[^"]*" target="boundFacts"/)?.[0]}`);
115
115
  });
116
116
 
117
- test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", () => {
118
- const errors = compileFail({
117
+ test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", async () => {
118
+ const errors = await compileFail({
119
119
  nodes: [{ id: "x", kind: "deploy", deploy: { target: "prod" } }],
120
120
  });
121
121
  const e = errors.find((err) => err.path === "nodes[0].kind");
@@ -123,8 +123,8 @@ test("rejects unknown kind (by construction) with a path-qualified error, nothin
123
123
  assert(e.message.length > 0);
124
124
  });
125
125
 
126
- test("rejects a dependency cycle with a path-qualified error", () => {
127
- const errors = compileFail({
126
+ test("rejects a dependency cycle with a path-qualified error", async () => {
127
+ const errors = await compileFail({
128
128
  nodes: [
129
129
  { id: "a", kind: "agent", agent: { jobType: "j" } },
130
130
  { id: "b", kind: "agent", agent: { jobType: "j" } },
@@ -137,14 +137,14 @@ test("rejects a dependency cycle with a path-qualified error", () => {
137
137
  assert(errors.some((e) => /cycle/i.test(e.message)), `expected a cycle error, got ${JSON.stringify(errors)}`);
138
138
  });
139
139
 
140
- test("rejects a dangling edge and a bad fact reference, each path-qualified", () => {
141
- const dangling = compileFail({
140
+ test("rejects a dangling edge and a bad fact reference, each path-qualified", async () => {
141
+ const dangling = await compileFail({
142
142
  nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
143
143
  edges: [{ from: "a", to: "ghost" }],
144
144
  });
145
145
  assert(dangling.some((e) => e.path === "edges[0].to"));
146
146
 
147
- const badFrom = compileFail({
147
+ const badFrom = await compileFail({
148
148
  nodes: [
149
149
  { id: "a", kind: "wait", wait: { kind: "http", target: "u" }, emits: [{ name: "x", type: "string" }] },
150
150
  { id: "b", kind: "agent", agent: { jobType: "j" } },
@@ -154,8 +154,8 @@ test("rejects a dangling edge and a bad fact reference, each path-qualified", ()
154
154
  assert(badFrom.some((e) => e.path === "edges[0].from"));
155
155
  });
156
156
 
157
- test("fan-in: a node with two producers gets a parallel JOIN gateway", () => {
158
- const r = compileOk({
157
+ test("fan-in: a node with two producers gets a parallel JOIN gateway", async () => {
158
+ const r = await compileOk({
159
159
  nodes: [
160
160
  { id: "a", kind: "agent", agent: { jobType: "j" } },
161
161
  { id: "b", kind: "agent", agent: { jobType: "j" } },
@@ -171,8 +171,8 @@ test("fan-in: a node with two producers gets a parallel JOIN gateway", () => {
171
171
  assertEquals(cNode?.dependsOn, ["a", "b"]);
172
172
  });
173
173
 
174
- test("fan-out: a node with two consumers gets a parallel FORK gateway", () => {
175
- const r = compileOk({
174
+ test("fan-out: a node with two consumers gets a parallel FORK gateway", async () => {
175
+ const r = await compileOk({
176
176
  nodes: [
177
177
  { id: "a", kind: "agent", agent: { jobType: "j" } },
178
178
  { id: "b", kind: "agent", agent: { jobType: "j" } },
@@ -186,8 +186,8 @@ test("fan-out: a node with two consumers gets a parallel FORK gateway", () => {
186
186
  assert(r.bpmn.includes('name="fan out of a"'), "a fork gateway for node a is emitted");
187
187
  });
188
188
 
189
- test("multiple roots fork from Start and multiple leaves join into End", () => {
190
- const r = compileOk({
189
+ test("multiple roots fork from Start and multiple leaves join into End", async () => {
190
+ const r = await compileOk({
191
191
  nodes: [
192
192
  { id: "r1", kind: "agent", agent: { jobType: "j" } },
193
193
  { id: "r2", kind: "agent", agent: { jobType: "j" } },
@@ -198,8 +198,8 @@ test("multiple roots fork from Start and multiple leaves join into End", () => {
198
198
  assert(r.bpmn.includes('id="gwj_end"'), "an end join gateway for multiple leaves");
199
199
  });
200
200
 
201
- test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing", () => {
202
- const r = compileOk({
201
+ test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing", async () => {
202
+ const r = await compileOk({
203
203
  nodes: [
204
204
  {
205
205
  id: "publish",
@@ -220,8 +220,8 @@ test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing
220
220
  assertEquals(ack?.prompt, undefined);
221
221
  });
222
222
 
223
- test("sideEffects: agent + connector only; connector carries its dedupeKey", () => {
224
- const r = compileOk(RELEASE_RUNBOOK);
223
+ test("sideEffects: agent + connector only; connector carries its dedupeKey", async () => {
224
+ const r = await compileOk(RELEASE_RUNBOOK);
225
225
  const agent = r.sideEffects.find((s) => s.nodeId === "open-b");
226
226
  assertEquals(agent?.kind, "agent");
227
227
  assert(agent?.description.includes("senior:feature"));
@@ -233,8 +233,8 @@ test("sideEffects: agent + connector only; connector carries its dedupeKey", ()
233
233
  assert(!r.sideEffects.some((s) => s.nodeId === "publish"));
234
234
  });
235
235
 
236
- test("resolved edges carry the resolved fromNode and the referenced fact", () => {
237
- const r = compileOk(RELEASE_RUNBOOK);
236
+ test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
237
+ const r = await compileOk(RELEASE_RUNBOOK);
238
238
  const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
239
239
  assertEquals(factEdge?.fromNode, "watch-b");
240
240
  assertEquals(factEdge?.fromFact, "mergedSha");
@@ -243,8 +243,8 @@ test("resolved edges carry the resolved fromNode and the referenced fact", () =>
243
243
  assertEquals(plainEdge?.fromFact, undefined);
244
244
  });
245
245
 
246
- test("BPMN is structurally coherent: one process start, one process end, every flow endpoint declared", () => {
247
- const r = compileOk(RELEASE_RUNBOOK);
246
+ test("BPMN is structurally coherent: one process start, one process end, every flow endpoint declared", async () => {
247
+ const r = await compileOk(RELEASE_RUNBOOK);
248
248
  // The TOP-LEVEL process has exactly one Start and one End (each inlined subProcess has its OWN
249
249
  // start/end events, so a raw `<bpmn:startEvent>` count is not the process boundary — the fixed ids are).
250
250
  assertEquals((r.bpmn.match(/ id="Start"/g) ?? []).length, 1);
@@ -257,12 +257,12 @@ test("BPMN is structurally coherent: one process start, one process end, every f
257
257
  }
258
258
  });
259
259
 
260
- test("duplicate fact-qualified edges between the same node pair collapse to ONE sequence flow", () => {
260
+ test("duplicate fact-qualified edges between the same node pair collapse to ONE sequence flow", async () => {
261
261
  // `src` emits two facts, both feeding `b` (`src.x -> b` and `src.y -> b`). Adjacency is de-duped by
262
262
  // node id, so no fork/join gateway is inserted — the producer wires straight to the consumer. The
263
263
  // compiler must therefore collapse the two edges into a SINGLE sequenceFlow so `b` is not scheduled
264
264
  // twice (multiple outgoing flows without a diverging gateway is invalid/double-executing BPMN).
265
- const r = compileOk({
265
+ const r = await compileOk({
266
266
  nodes: [
267
267
  {
268
268
  id: "src",
@@ -289,9 +289,37 @@ test("duplicate fact-qualified edges between the same node pair collapse to ONE
289
289
  assert(!r.bpmn.includes("<bpmn:parallelGateway"), "no gateway for a single de-duplicated producer/consumer pair");
290
290
  });
291
291
 
292
- test("a non-object / empty body is a clean ok:false, never a throw", () => {
293
- assert(!compileDeliveryGraph(undefined).ok);
294
- assert(!compileDeliveryGraph(null).ok);
295
- assert(!compileDeliveryGraph({}).ok);
296
- assert(!compileDeliveryGraph({ nodes: [] }).ok);
292
+ test("a non-object / empty body is a clean ok:false, never a throw", async () => {
293
+ assert(!(await compileDeliveryGraph(undefined)).ok);
294
+ assert(!(await compileDeliveryGraph(null)).ok);
295
+ assert(!(await compileDeliveryGraph({})).ok);
296
+ assert(!(await compileDeliveryGraph({ nodes: [] })).ok);
297
+ });
298
+
299
+ test("DI (#440): the compiled bpmn carries an auto-laid-out bpmndi:BPMNDiagram — a shape per element, an edge per flow", async () => {
300
+ // The delivery-graph compiler is the ONE BPMN generated at runtime; every AUTHORED process gets DI
301
+ // from `npm run layout` (`layoutBpmn`), and before #440 this generated one skipped that pass and
302
+ // shipped DI-less — a compiled/running graph rendered positionless in the process explorer. The
303
+ // compiler now runs the SAME `layoutBpmn` autolayout, so the preview `bpmn` (what actually deploys)
304
+ // carries diagram interchange. This RED/GREEN guard fails on the old DI-less output.
305
+ const r = await compileOk(RELEASE_RUNBOOK);
306
+ assert(r.bpmn.includes("<bpmndi:BPMNDiagram"), "compiled bpmn carries a bpmndi:BPMNDiagram");
307
+ assert(r.bpmn.includes("<bpmndi:BPMNPlane"), "the diagram has a plane");
308
+ // The top-level plane references the compiled process, so the process explorer can render it.
309
+ assert(r.bpmn.includes('bpmnElement="delivery-graph"'), "the top-level plane binds to the process id");
310
+ // A shape per BPMN element and an edge per sequence flow — the same "N shapes + M edges" accounting
311
+ // `scripts/layout-bpmn.ts` reports. Every declared sequenceFlow gets a BPMNEdge.
312
+ const shapes = (r.bpmn.match(/<bpmndi:BPMNShape\b/g) ?? []).length;
313
+ const edges = (r.bpmn.match(/<bpmndi:BPMNEdge\b/g) ?? []).length;
314
+ const flows = (r.bpmn.match(/<bpmn:sequenceFlow\b/g) ?? []).length;
315
+ assert(shapes > 0, "at least one BPMNShape is drawn");
316
+ assertEquals(edges, flows, "every sequence flow gets exactly one BPMNEdge");
317
+ });
318
+
319
+ test("DI (#440) is deterministic: identical JSON yields byte-identical laid-out bpmn", async () => {
320
+ // `layoutBpmn` (bpmn-auto-layout) is deterministic given identical semantic input, so adding the
321
+ // diagram must not break the compiler's "same JSON → byte-identical XML" trust property.
322
+ const a = await compileOk(RELEASE_RUNBOOK);
323
+ const b = await compileOk(RELEASE_RUNBOOK);
324
+ assertEquals(a.bpmn, b.bpmn);
297
325
  });
@@ -23,6 +23,7 @@
23
23
  // parallel gateways for genuine fan-out (>1 downstream) and fan-in (>1 upstream). This slice targets
24
24
  // the WIRING/SHAPE — the concrete node bodies land in S4.
25
25
 
26
+ import { layoutBpmn } from "@nanobpm/urban";
26
27
  import type {
27
28
  CompileDeliveryGraphErrors,
28
29
  CompileDeliveryGraphResult,
@@ -212,8 +213,17 @@ function mustGet<K, V>(map: ReadonlyMap<K, V>, key: K): V {
212
213
  * `{ ok:false, errors }` (each error path-qualified) for a malformed one. NEVER deploys, dispatches,
213
214
  * or mutates anything — safe to call repeatedly. Deterministic: identical input JSON yields
214
215
  * byte-identical output.
216
+ *
217
+ * ASYNC because the final step attaches DIAGRAM INTERCHANGE (`bpmndi:BPMNDiagram`) via the toolkit
218
+ * autolayout (`layoutBpmn` — `bpmn-auto-layout`), the SAME pass every AUTHORED process gets from
219
+ * `npm run layout` (`scripts/layout-bpmn.ts`). This is the one BPMN in the system generated at
220
+ * runtime, so without this it was the only one shipping DI-less — unrenderable in the process
221
+ * explorer (#440). `layoutBpmn` is itself deterministic given identical semantic input, so
222
+ * "same JSON → byte-identical XML" still holds with the diagram included.
215
223
  */
216
- export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult | CompileDeliveryGraphErrors {
224
+ export async function compileDeliveryGraph(
225
+ graph: unknown,
226
+ ): Promise<CompileDeliveryGraphResult | CompileDeliveryGraphErrors> {
217
227
  const validationErrors: DeliveryGraphError[] = validateDeliveryGraph(graph);
218
228
  if (validationErrors.length > 0) {
219
229
  // Forward every semantic failure verbatim as a wire `{ path, message }` (the stable `code` stays
@@ -354,7 +364,8 @@ export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult
354
364
  list.sort((a, b) => byCodeUnit(a.producerElement, b.producerElement) || byCodeUnit(a.fact, b.fact));
355
365
  }
356
366
 
357
- const bpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
367
+ const semanticBpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
368
+ const bpmn = await layoutDeliveryDiagram(semanticBpmn);
358
369
  const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
359
370
  const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
360
371
  const humanNodes = buildHumanNodes(nodes);
@@ -363,6 +374,48 @@ export function compileDeliveryGraph(graph: unknown): CompileDeliveryGraphResult
363
374
  return { ok: true, diagram, bpmn, resolved, humanNodes, sideEffects };
364
375
  }
365
376
 
377
+ /** Attach diagram interchange (`bpmndi:BPMNDiagram`) to the semantic-only compiled BPMN via the
378
+ * toolkit autolayout — the SAME `layoutBpmn` (`bpmn-auto-layout`) pass `npm run layout` runs over
379
+ * every authored process (`scripts/layout-bpmn.ts`), so there is ONE layout source, not two. Without
380
+ * it, a compiled/running delivery graph rendered positionless in the process explorer (#440).
381
+ *
382
+ * We do NOT return `layoutBpmn`'s serialized output directly: its moddle round-trip re-serializes the
383
+ * semantic model, and in doing so normalizes attribute quoting — a single-quote-delimited attribute
384
+ * with literal double-quotes inside becomes a double-quoted attribute with `&#34;` entities. The
385
+ * compiler deliberately emits FEEL string literals (`boundFacts`) with SINGLE-quote delimiters because
386
+ * the WASM engine deploy path does NOT decode those entities before FEEL parsing (see `attr`), so a
387
+ * round-trip would silently blank every late-bound fact. Instead we keep the compiler's carefully
388
+ * encoded semantic XML BYTE-FOR-BYTE and graft only the computed `<bpmndi:BPMNDiagram>` block(s) onto
389
+ * it — the diagram references element ids `layoutBpmn` leaves untouched, so the graft is sound.
390
+ *
391
+ * `bpmn-auto-layout` is a real runtime dependency of `@nanobpm/urban` (which re-exports `layoutBpmn`),
392
+ * but the toolkit no-ops layout (semantic model unchanged, no DI) when it is somehow absent. That
393
+ * silent no-op is exactly the DI-less bug this fixes, so we FAIL LOUD if the pass produced no diagram.
394
+ * Deterministic given identical input, preserving the compiler's "same JSON → byte-identical XML". */
395
+ async function layoutDeliveryDiagram(semanticBpmn: string): Promise<string> {
396
+ const laidOut = await layoutBpmn(semanticBpmn);
397
+ const start = laidOut.indexOf("<bpmndi:BPMNDiagram");
398
+ const endTag = "</bpmndi:BPMNDiagram>";
399
+ const end = laidOut.lastIndexOf(endTag);
400
+ if (start === -1 || end === -1) {
401
+ throw new Error(
402
+ "compileDeliveryGraph: layoutBpmn produced no bpmndi:BPMNDiagram, so the compiled graph would " +
403
+ "deploy DI-less and render positionless in the process explorer (#440). This usually means the " +
404
+ "`bpmn-auto-layout` toolkit peer is missing (the toolkit then silently no-ops layout), but it " +
405
+ "can also indicate a change in `layoutBpmn` output (different namespace prefix/serialization) or " +
406
+ "an internal layout failure returning semantic-only XML. Ensure `bpmn-auto-layout` is installed " +
407
+ "as a runtime dependency and that `layoutBpmn` still emits a `<bpmndi:BPMNDiagram>` block.",
408
+ );
409
+ }
410
+ const diagram = laidOut.slice(start, end + endTag.length);
411
+ const closing = "</bpmn:definitions>";
412
+ const insertAt = semanticBpmn.lastIndexOf(closing);
413
+ if (insertAt === -1) {
414
+ throw new Error("compileDeliveryGraph: compiled BPMN has no </bpmn:definitions> to graft DI into");
415
+ }
416
+ return `${semanticBpmn.slice(0, insertAt)} ${diagram}\n${semanticBpmn.slice(insertAt)}`;
417
+ }
418
+
366
419
  /** Push `value` into `list` (may be undefined for a dangling target, already reported by the
367
420
  * validator) only when not already present — keeps adjacency de-duplicated. */
368
421
  function pushUnique(list: string[] | undefined, value: string): void {
@@ -485,6 +538,9 @@ function renderBpmn(
485
538
  '<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" ' +
486
539
  'xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" ' +
487
540
  'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' +
541
+ 'xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" ' +
542
+ 'xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" ' +
543
+ 'xmlns:di="http://www.omg.org/spec/DD/20100524/DI" ' +
488
544
  'id="Definitions_delivery_graph" targetNamespace="http://nanobpm.io/nano-workforce">',
489
545
  );
490
546
  const processName = graph.name ?? "Delivery graph";
@@ -604,6 +660,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
604
660
  inputs.push({ source: cfg("gateKey"), target: "gateKey" });
605
661
  inputs.push({ source: cfg("probe"), target: "probe" });
606
662
  inputs.push({ source: cfg("probeTimeout"), target: "probeTimeout" });
663
+ inputs.push({ source: cfg("probePollEvery"), target: "probePollEvery" });
607
664
  break;
608
665
  case "human":
609
666
  inputs.push({ source: cfg("escalationSlaTimeout"), target: "escalationSlaTimeout" });
@@ -712,33 +769,78 @@ function waitBodyLines(el: string, nodeId: string): string[] {
712
769
  const esc = escalationTaskElement(el);
713
770
  return [
714
771
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
715
- ` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
772
+ ` <bpmn:subProcess id="${el}_probeLoop" name="Probe readiness loop: ${escapeXml(nodeId)}">`,
773
+ " <bpmn:extensionElements>",
774
+ " <zeebe:ioMapping>",
775
+ ' <zeebe:output source="=ready" target="ready" />',
776
+ ' <zeebe:output source="=if (is defined(detail)) then detail else null" target="detail" />',
777
+ ' <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="resolvedArtifact" />',
778
+ ' <zeebe:output source="=if (is defined(mergedSha)) then mergedSha else null" target="mergedSha" />',
779
+ " </zeebe:ioMapping>",
780
+ " </bpmn:extensionElements>",
781
+ ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
782
+ ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
783
+ ` <bpmn:startEvent id="${el}_loopStart"><bpmn:outgoing>${el}_li0</bpmn:outgoing></bpmn:startEvent>`,
784
+ ` <bpmn:serviceTask id="${el}_task" name="Probe readiness: ${escapeXml(nodeId)}">`,
785
+ " <bpmn:extensionElements>",
786
+ ` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
787
+ " <zeebe:properties>",
788
+ ' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
789
+ ' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
790
+ " </zeebe:properties>",
791
+ " </bpmn:extensionElements>",
792
+ ` <bpmn:incoming>${el}_li0</bpmn:incoming>`,
793
+ ` <bpmn:incoming>${el}_li4</bpmn:incoming>`,
794
+ ` <bpmn:outgoing>${el}_li1</bpmn:outgoing>`,
795
+ " </bpmn:serviceTask>",
796
+ ` <bpmn:exclusiveGateway id="${el}_gw" name="ready?" default="${el}_li3">`,
797
+ ` <bpmn:incoming>${el}_li1</bpmn:incoming>`,
798
+ ` <bpmn:outgoing>${el}_li2</bpmn:outgoing>`,
799
+ ` <bpmn:outgoing>${el}_li3</bpmn:outgoing>`,
800
+ " </bpmn:exclusiveGateway>",
801
+ ` <bpmn:intermediateCatchEvent id="${el}_waitPoll" name="Wait poll interval">`,
802
+ ` <bpmn:incoming>${el}_li3</bpmn:incoming>`,
803
+ ` <bpmn:outgoing>${el}_li4</bpmn:outgoing>`,
804
+ ` <bpmn:timerEventDefinition id="${el}_pollTed"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probePollEvery</bpmn:timeDuration></bpmn:timerEventDefinition>`,
805
+ " </bpmn:intermediateCatchEvent>",
806
+ ` <bpmn:endEvent id="${el}_loopEnd"><bpmn:incoming>${el}_li2</bpmn:incoming></bpmn:endEvent>`,
807
+ flow(`${el}_li0`, `${el}_loopStart`, `${el}_task`),
808
+ flow(`${el}_li1`, `${el}_task`, `${el}_gw`),
809
+ ` <bpmn:sequenceFlow id="${el}_li2" name="ready" sourceRef="${el}_gw" targetRef="${el}_loopEnd"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
810
+ ` <bpmn:sequenceFlow id="${el}_li3" name="not ready" sourceRef="${el}_gw" targetRef="${el}_waitPoll" />`,
811
+ flow(`${el}_li4`, `${el}_waitPoll`, `${el}_task`),
812
+ " </bpmn:subProcess>",
813
+ ` <bpmn:boundaryEvent id="${el}_be" name="Gate timed out" attachedToRef="${el}_probeLoop">`,
814
+ ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
815
+ ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=probeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
816
+ " </bpmn:boundaryEvent>",
817
+ ` <bpmn:serviceTask id="${el}_lastAttempt" name="Probe readiness at boundary: ${escapeXml(nodeId)}">`,
716
818
  " <bpmn:extensionElements>",
717
819
  ` <zeebe:taskDefinition type="${DELEGATE_TASK_TYPE.wait}" />`,
718
820
  " <zeebe:properties>",
719
821
  ' <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessProbeIn" />',
720
822
  ' <zeebe:property name="io.nanobpm.dataEnvelope.out" value="ReadinessProbeOut" />',
721
823
  " </zeebe:properties>",
824
+ " <zeebe:ioMapping>",
825
+ ' <zeebe:input source="=true" target="lastAttempt" />',
826
+ " </zeebe:ioMapping>",
722
827
  " </bpmn:extensionElements>",
723
- ` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
724
- ` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
828
+ ` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
829
+ ` <bpmn:outgoing>${el}_i6</bpmn:outgoing>`,
725
830
  " </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>`,
831
+ ` <bpmn:exclusiveGateway id="${el}_lastGw" name="ready after boundary?" default="${el}_i4">`,
832
+ ` <bpmn:incoming>${el}_i6</bpmn:incoming>`,
833
+ ` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
733
834
  ` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
734
835
  " </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}" />`,
836
+ ...escalationTaskLines(esc, nodeId, [`${el}_i4`], `${el}_i5`),
837
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
838
+ flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
839
+ flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
840
+ flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
841
+ flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
842
+ ` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
843
+ ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${esc}" />`,
742
844
  flow(`${el}_i5`, esc, `${el}_end`),
743
845
  ];
744
846
  }
@@ -179,7 +179,7 @@ test("isDeliveryGraphApproved: a side-effecting graph dispatches ONLY with the m
179
179
  });
180
180
 
181
181
  // ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
182
- test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", () => {
182
+ test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", async () => {
183
183
  const graph = {
184
184
  nodes: [
185
185
  { id: "open-b", kind: "agent", agent: { jobType: "j" } },
@@ -188,7 +188,7 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
188
188
  ],
189
189
  edges: [{ from: "open-b", to: "publish" }, { from: "publish", to: "ack" }],
190
190
  };
191
- const compiled = compileDeliveryGraph(graph);
191
+ const compiled = await compileDeliveryGraph(graph);
192
192
  assertEquals(compiled.ok, true);
193
193
  if (!compiled.ok) return;
194
194
  const labels = buildHumanLabels(compiled);