@nanobpm/nano-workforce 0.120.1 → 0.121.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ # [0.121.0](https://github.com/nanobpm/nano-workforce/compare/v0.120.2...v0.121.0) (2026-08-22)
2
+
3
+
4
+ ### Features
5
+
6
+ * **delivery-graphs:** surface the compile preview in the console (compose → preview → dispatch) ([#445](https://github.com/nanobpm/nano-workforce/issues/445)) ([eb8bc7a](https://github.com/nanobpm/nano-workforce/commit/eb8bc7a609f894ddf83248bf356ab18864f63b62)), closes [#279](https://github.com/nanobpm/nano-workforce/issues/279) [#441](https://github.com/nanobpm/nano-workforce/issues/441)
7
+
8
+ ## [0.120.2](https://github.com/nanobpm/nano-workforce/compare/v0.120.1...v0.120.2) (2026-08-22)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+ * **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))
15
+
1
16
  ## [0.120.1](https://github.com/nanobpm/nano-workforce/compare/v0.120.0...v0.120.1) (2026-08-22)
2
17
 
3
18
 
@@ -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,21 +70,21 @@ 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);
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
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);
@@ -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";
@@ -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);
@@ -29,31 +29,42 @@ const GRAPH: DeliveryGraph = {
29
29
  ],
30
30
  };
31
31
 
32
- function prepareOk(graph: DeliveryGraph, options = {}) {
33
- const r = prepareDeliveryGraph(graph, options);
32
+ async function prepareOk(graph: DeliveryGraph, options = {}) {
33
+ const r = await prepareDeliveryGraph(graph, options);
34
34
  assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
35
35
  return r.prepared;
36
36
  }
37
37
 
38
- test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", () => {
39
- const a = prepareOk(GRAPH);
40
- const b = prepareOk(GRAPH);
38
+ test("content-addressed id: deterministic for the same graph, content-sensitive across graphs", async () => {
39
+ const a = await prepareOk(GRAPH);
40
+ const b = await prepareOk(GRAPH);
41
41
  assert(/^delivery-graph-[0-9a-f]{12}$/.test(a.processDefinitionId), `id is content-addressed, got ${a.processDefinitionId}`);
42
42
  assertEquals(a.processDefinitionId, b.processDefinitionId);
43
43
 
44
44
  // A structurally different graph gets a DIFFERENT id (no collision / no accidental redeploy-as-same).
45
- const other = prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
45
+ const other = await prepareOk({ ...GRAPH, nodes: [...GRAPH.nodes, { id: "extra", kind: "agent", agent: { jobType: "senior:feature" } }], edges: [...GRAPH.edges, { from: "consume", to: "extra" }] });
46
46
  assert(other.processDefinitionId !== a.processDefinitionId, "a different graph yields a different id");
47
47
  });
48
48
 
49
- test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", () => {
50
- const p = prepareOk(GRAPH);
49
+ test("the deployable BPMN rewrites the base process id to the content-addressed deploy id", async () => {
50
+ const p = await prepareOk(GRAPH);
51
51
  assert(p.bpmn.includes(`<bpmn:process id="${p.processDefinitionId}"`), "process id is the content-addressed id");
52
52
  assert(!p.bpmn.includes('<bpmn:process id="delivery-graph"'), "the base id no longer appears as the process id");
53
53
  });
54
54
 
55
- test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMapping reads", () => {
56
- const p = prepareOk(GRAPH, { nodeTimeout: "PT10M", probeTimeout: "PT20M", escalationSlaTimeout: "PT2H", escalationAssignee: "alice", runKey: "run-7" });
55
+ test("DI (#440): the deployable definition carries diagram interchange bound to the rewritten process id", async () => {
56
+ // The DEPLOYED definition (not just the compile preview) must render in the process explorer, so it
57
+ // carries the auto-laid-out `bpmndi:BPMNDiagram`. The top-level plane's `bpmnElement` reference is
58
+ // rewritten in lock-step with the process id, otherwise the deployed diagram would dangle and render
59
+ // positionless — the exact bug #440 fixes.
60
+ const p = await prepareOk(GRAPH);
61
+ assert(p.bpmn.includes("<bpmndi:BPMNDiagram"), "deployable bpmn carries a diagram");
62
+ assert(p.bpmn.includes(`bpmnElement="${p.processDefinitionId}"`), "the plane binds to the content-addressed id");
63
+ assert(!p.bpmn.includes('bpmnElement="delivery-graph"'), "no dangling reference to the base process id remains");
64
+ });
65
+
66
+ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMapping reads", async () => {
67
+ const p = await prepareOk(GRAPH, { nodeTimeout: "PT10M", probeTimeout: "PT20M", escalationSlaTimeout: "PT2H", escalationAssignee: "alice", runKey: "run-7" });
57
68
  // Element ids are positional by sorted node id: consume, open-b, publish, watch-b → n0..n3.
58
69
  const inputs = p.nodeInputs;
59
70
  const byField = (pred: (v: Record<string, unknown>) => boolean) => Object.values(inputs).find((v) => pred(v as Record<string, unknown>)) as Record<string, unknown> | undefined;
@@ -74,12 +85,12 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
74
85
  assertEquals(connector, { target: "npm:install", dedupeKey: "consume-1", payload: null, timeout: "PT10M" });
75
86
  });
76
87
 
77
- test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", () => {
78
- const gateKeyOf = (p: ReturnType<typeof prepareOk>) =>
88
+ test("wait gateKeys default to a fresh per-run token so concurrent runs of one graph never cross-correlate", async () => {
89
+ const gateKeyOf = (p: Awaited<ReturnType<typeof prepareOk>>) =>
79
90
  (Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { gateKey?: string } | undefined)?.gateKey;
80
91
 
81
- const a = prepareOk(GRAPH);
82
- const b = prepareOk(GRAPH);
92
+ const a = await prepareOk(GRAPH);
93
+ const b = await prepareOk(GRAPH);
83
94
  assert(gateKeyOf(a) && gateKeyOf(b), "each run seeds a wait gateKey");
84
95
  assert(gateKeyOf(a) !== gateKeyOf(b), "two runs of the same graph get DISTINCT default gate scopes");
85
96
  // The gate key must NOT be derived from the (shared) content digest — that is the bug this guards.
@@ -89,12 +100,12 @@ test("wait gateKeys default to a fresh per-run token so concurrent runs of one g
89
100
  assertEquals(a.bpmn, b.bpmn);
90
101
 
91
102
  // An explicit runKey is honoured verbatim (reproducible seed).
92
- const seeded = prepareOk(GRAPH, { runKey: "run-7" });
103
+ const seeded = await prepareOk(GRAPH, { runKey: "run-7" });
93
104
  assertEquals(gateKeyOf(seeded), "run-7:n3");
94
105
  });
95
106
 
96
- test("a malformed graph returns the S1 compile errors and prepares nothing", () => {
97
- const r = prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
107
+ test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
108
+ const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
98
109
  assert(!r.ok, "a dangling edge fails to prepare");
99
110
  assert(r.errors.some((e) => e.path === "edges[0].to"), `expected a dangling-edge error, got ${JSON.stringify(r.errors)}`);
100
111
  });
@@ -97,8 +97,11 @@ export type RunDeliveryResult =
97
97
  * so each call's `wait` `gateKey`s differ (two concurrent runs of the same graph never cross-correlate);
98
98
  * pass an explicit `runKey` for a reproducible seed. Returns the S1 compile errors verbatim for a
99
99
  * malformed graph. */
100
- export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunOptions = {}): PrepareDeliveryResult {
101
- const compiled = compileDeliveryGraph(graph);
100
+ export async function prepareDeliveryGraph(
101
+ graph: DeliveryGraph,
102
+ options: DeliveryRunOptions = {},
103
+ ): Promise<PrepareDeliveryResult> {
104
+ const compiled = await compileDeliveryGraph(graph);
102
105
  if (!compiled.ok) return { ok: false, errors: compiled.errors };
103
106
 
104
107
  const digest = deliveryGraphDigest(compiled.bpmn);
@@ -132,7 +135,7 @@ export async function runDeliveryGraph(
132
135
  graph: DeliveryGraph,
133
136
  options: DeliveryRunOptions = {},
134
137
  ): Promise<RunDeliveryResult> {
135
- const prep = prepareDeliveryGraph(graph, options);
138
+ const prep = await prepareDeliveryGraph(graph, options);
136
139
  if (!prep.ok) return prep;
137
140
  const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
138
141
 
@@ -150,10 +153,16 @@ export async function runDeliveryGraph(
150
153
  }
151
154
 
152
155
  /** Rewrite the compiled BPMN's base `bpmn:process` id to the content-addressed deploy id. The base id
153
- * appears exactly once as the process element's `id` attribute (element ids are `n<i>`/`gw*`/`Start`/
154
- * `End`, never the process id) so a single targeted replacement is unambiguous. */
156
+ * appears exactly once as the process element's `id` attribute (element ids are `n<i>`/`gw*`/`Start`/
157
+ * `End`, never the process id), and once more as the top-level `bpmndi:BPMNPlane`'s `bpmnElement`
158
+ * reference back to that process (the diagram interchange the compiler now attaches, #440). Both must
159
+ * move together, otherwise the deployed definition carries a DANGLING plane reference and renders
160
+ * positionless — the very bug DI was added to fix. Nested sub-process planes reference `n<i>` element
161
+ * ids, which are untouched. */
155
162
  function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
156
- return bpmn.replace(`id="${DELIVERY_GRAPH_PROCESS_ID}"`, `id="${processDefinitionId}"`);
163
+ return bpmn
164
+ .replace(`id="${DELIVERY_GRAPH_PROCESS_ID}"`, `id="${processDefinitionId}"`)
165
+ .replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
157
166
  }
158
167
 
159
168
  /** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
@@ -535,4 +535,52 @@ test("pollUserTasks (engine-first): pages through a large open set (no first-pag
535
535
  }
536
536
 
537
537
  assertEquals((stores.user_tasks ?? []).length, 150);
538
- });
538
+ });
539
+ test("pollUserTasks (engine-first): surfaces an inlined delivery-graph human task, enriched + bucketed as `delivery` (issue #442)", async () => {
540
+ // A delivery-graph `human` node is compiled (S4) as an INLINED user task with a per-node id
541
+ // `delivery-human-task__<node>` — the bare `delivery-human-task` never appears at runtime. The poller's
542
+ // leak guards must recognise it through the single-source-of-truth predicate (`userTaskKindLabel` /
543
+ // `isDeliveryHumanElement`), NOT exact `USER_TASK_KIND_LABELS` membership — else every delivery-graph
544
+ // human gate is silently dropped from the Tasks inbox and no operator can tick it off (merlin task 35002).
545
+ const { data, stores } = memData({
546
+ delivery_graph_runs: [
547
+ { run_key: "delivery-graph-403eb22e", process_key: "dg-1", status: "running", title: "release runbook" },
548
+ ],
549
+ });
550
+ const restore = stubUserTaskSearch([
551
+ { userTaskKey: "35002", elementId: "delivery-human-task__n1", processInstanceKey: "dg-1", state: "CREATED" },
552
+ ]);
553
+ try {
554
+ await pollUserTasks(data, fakeEngine({}), REST);
555
+ } finally {
556
+ restore();
557
+ }
558
+
559
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
560
+ assertEquals(Object.keys(byKey), ["35002"]);
561
+ assertEquals(byKey["35002"].element_id, "delivery-human-task__n1");
562
+ assertEquals(byKey["35002"].kind_label, "Delivery: human step");
563
+ assertEquals(byKey["35002"].subject_type, "delivery");
564
+ assertEquals(byKey["35002"].subject_key, "delivery-graph-403eb22e");
565
+ assertEquals(byKey["35002"].subject_title, "release runbook");
566
+ });
567
+
568
+ test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run still surfaces (bucketed `delivery`, instance fallback) (issue #442)", async () => {
569
+ // Even with no `delivery_graph_runs` row referencing the instance, the kind implies its aggregate, so
570
+ // the row renders and stays answerable — mirroring the orphaned-escalation guarantee (#358).
571
+ const { data, stores } = memData({});
572
+ const restore = stubUserTaskSearch([
573
+ { userTaskKey: "35002", elementId: "delivery-human-task__n1", processInstanceKey: "dg-9", state: "CREATED" },
574
+ ]);
575
+ try {
576
+ await pollUserTasks(data, fakeEngine({}), REST);
577
+ } finally {
578
+ restore();
579
+ }
580
+
581
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
582
+ assertEquals(Object.keys(byKey), ["35002"]);
583
+ assertEquals(byKey["35002"].kind_label, "Delivery: human step");
584
+ assertEquals(byKey["35002"].subject_type, "delivery");
585
+ assertEquals(byKey["35002"].subject_key, "dg-9"); // instance fallback — non-blank so it renders
586
+ });
package/app/service.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
30
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
31
31
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
32
+ import { isDeliveryHumanElement } from "./deliveryHuman.ts";
32
33
  import { fleetSupportsDurableResume } from "./durableResume.ts";
33
34
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
34
35
  import {
@@ -87,9 +88,9 @@ import {
87
88
  prEscalations,
88
89
  reconcileUserTasks,
89
90
  TRIAL_MERGE_ELEMENT,
90
- USER_TASK_KIND_LABELS,
91
91
  type UserTaskContext,
92
92
  type UserTaskRow,
93
+ userTaskKindLabel,
93
94
  userTasks,
94
95
  } from "./userTasks.ts";
95
96
  import { deriveWaitGate } from "./waitGate.ts";
@@ -2166,7 +2167,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2166
2167
  // answerable, so a lagging COMPLETED/CANCELED read must never surface a dead affordance (#294).
2167
2168
  if (typeof it.state === "string" && it.state.toUpperCase() !== "CREATED") continue;
2168
2169
  const elementId = typeof it.elementId === "string" ? it.elementId : undefined;
2169
- if (!elementId || !Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) continue;
2170
+ if (!elementId || userTaskKindLabel(elementId) === undefined) continue;
2170
2171
  const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
2171
2172
  if (!userTaskKey || seen.has(userTaskKey)) continue;
2172
2173
  seen.add(userTaskKey);
@@ -2254,7 +2255,7 @@ export async function pollUserTasks(
2254
2255
  // summary for the `conformance-escalation` ack (its instance is tracked on `plan_conformance`, not a
2255
2256
  // delivery aggregate).
2256
2257
  interface Subject {
2257
- type: "feature" | "plan" | "pr";
2258
+ type: "feature" | "plan" | "pr" | "delivery";
2258
2259
  key: string;
2259
2260
  title?: string | null;
2260
2261
  url?: string | null;
@@ -2278,6 +2279,13 @@ export async function pollUserTasks(
2278
2279
  const plan = await plans(data).get(review.plan_key);
2279
2280
  subjectByInstance.set(review.process_key, { type: "plan", key: review.plan_key, title: plan?.title ?? null, url: plan?.issue_url ?? null, conformanceSummary: review.summary });
2280
2281
  }
2282
+ // A delivery-graph `human` node parks on its run's engine instance; enrich from the run row so the
2283
+ // Tasks inbox shows the graph's title (its `run_key` as the stable subject key), mirroring the
2284
+ // feature/plan/pr enrichment. The row's inlined `delivery-human-task__<node>` id is recognised by
2285
+ // the shared `userTaskKindLabel` predicate, and buckets as `delivery` (below).
2286
+ for (const run of await deliveryGraphRuns(data).all()) {
2287
+ if (run.process_key) subjectByInstance.set(run.process_key, { type: "delivery", key: run.run_key, title: run.title, url: null });
2288
+ }
2281
2289
 
2282
2290
  // Per-element subject type for an ORPHANED task (no subject row) — the kind implies its aggregate even
2283
2291
  // when tracking is lost, so the fallback row still buckets correctly on the page.
@@ -2296,9 +2304,12 @@ export async function pollUserTasks(
2296
2304
  // when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
2297
2305
  // non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
2298
2306
  const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
2299
- if (!Object.hasOwn(USER_TASK_KIND_LABELS, elementId)) return null;
2307
+ if (userTaskKindLabel(elementId) === undefined) return null;
2300
2308
  const subj = subjectByInstance.get(processInstanceKey);
2301
- const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? "plan";
2309
+ // Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
2310
+ // instance. A delivery-human node's id is inlined (`delivery-human-task__<node>`), so its bucket is
2311
+ // derived from the predicate rather than the static per-element table.
2312
+ const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? (isDeliveryHumanElement(elementId) ? "delivery" : "plan");
2302
2313
  const subjectKey = subj?.key ?? processInstanceKey;
2303
2314
  let question: string | null = null;
2304
2315
  switch (elementId) {