@nanobpm/nano-workforce 0.106.2 → 0.107.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,17 @@
1
+ # [0.107.0](https://github.com/nanobpm/nano-workforce/compare/v0.106.3...v0.107.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * port nwf models to defineFlow (S5, [#320](https://github.com/nanobpm/nano-workforce/issues/320)) ([#353](https://github.com/nanobpm/nano-workforce/issues/353)) ([c0b2d9d](https://github.com/nanobpm/nano-workforce/commit/c0b2d9ddea4f5c56159f011ca70203aa3f3c91fe)), closes [nanobpm/nano-ide#314](https://github.com/nanobpm/nano-ide/issues/314) [355/#356](https://github.com/nanobpm/nano-workforce/issues/356) [nano-ide#405](https://github.com/nano-ide/issues/405)
7
+
8
+ ## [0.106.3](https://github.com/nanobpm/nano-workforce/compare/v0.106.2...v0.106.3) (2026-08-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **models:** regenerate stale retro.bpmn DI ([#365](https://github.com/nanobpm/nano-workforce/issues/365)) ([5619ee5](https://github.com/nanobpm/nano-workforce/commit/5619ee5805fb788ae5deebeb75bb0aa11f9c478d)), closes [355/#356](https://github.com/nanobpm/nano-workforce/issues/356)
14
+
1
15
  ## [0.106.2](https://github.com/nanobpm/nano-workforce/compare/v0.106.1...v0.106.2) (2026-08-20)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.106.2",
3
+ "version": "0.107.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -62,6 +62,7 @@
62
62
  "devDependencies": {
63
63
  "@biomejs/biome": "^2.4.11",
64
64
  "@nanobpm/urban-testkit": "^0.5.0",
65
+ "@nanobpm/workflow": "^0.12.0",
65
66
  "@semantic-release/changelog": "^6.0.3",
66
67
  "@semantic-release/git": "^10.0.1",
67
68
  "@semantic-release/npm": "^13.1.5",
@@ -146,27 +146,27 @@
146
146
  <dc:Bounds x="616" y="80" width="100" height="80" />
147
147
  </bpmndi:BPMNShape>
148
148
  <bpmndi:BPMNShape id="BPMNShape_gw-deviations" bpmnElement="gw-deviations" isMarkerVisible="true">
149
- <dc:Bounds x="776" y="95" width="50" height="50" />
149
+ <dc:Bounds x="816" y="95" width="50" height="50" />
150
150
  <bpmndi:BPMNLabel>
151
- <dc:Bounds x="770" y="65" width="62" height="14" />
151
+ <dc:Bounds x="801" y="76" width="81" height="14" />
152
152
  </bpmndi:BPMNLabel>
153
153
  </bpmndi:BPMNShape>
154
154
  <bpmndi:BPMNShape id="BPMNShape_conformance-escalation" bpmnElement="conformance-escalation">
155
- <dc:Bounds x="751" y="240" width="100" height="80" />
155
+ <dc:Bounds x="966" y="240" width="100" height="80" />
156
156
  </bpmndi:BPMNShape>
157
157
  <bpmndi:BPMNShape id="BPMNShape_record-conformance-ack" bpmnElement="record-conformance-ack">
158
- <dc:Bounds x="911" y="240" width="100" height="80" />
158
+ <dc:Bounds x="1166" y="240" width="100" height="80" />
159
159
  </bpmndi:BPMNShape>
160
160
  <bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
161
- <dc:Bounds x="1056" y="80" width="100" height="80" />
161
+ <dc:Bounds x="1366" y="80" width="100" height="80" />
162
162
  </bpmndi:BPMNShape>
163
163
  <bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
164
- <dc:Bounds x="1256" y="80" width="100" height="80" />
164
+ <dc:Bounds x="1566" y="80" width="100" height="80" />
165
165
  </bpmndi:BPMNShape>
166
166
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
167
- <dc:Bounds x="1456" y="102" width="36" height="36" />
167
+ <dc:Bounds x="1766" y="102" width="36" height="36" />
168
168
  <bpmndi:BPMNLabel>
169
- <dc:Bounds x="1434" y="143" width="80" height="14" />
169
+ <dc:Bounds x="1744" y="143" width="80" height="14" />
170
170
  </bpmndi:BPMNLabel>
171
171
  </bpmndi:BPMNShape>
172
172
  <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
@@ -183,32 +183,36 @@
183
183
  </bpmndi:BPMNEdge>
184
184
  <bpmndi:BPMNEdge id="BPMNEdge_f_toDeviationsGw" bpmnElement="f_toDeviationsGw">
185
185
  <di:waypoint x="716" y="120" />
186
- <di:waypoint x="776" y="120" />
186
+ <di:waypoint x="816" y="120" />
187
187
  </bpmndi:BPMNEdge>
188
188
  <bpmndi:BPMNEdge id="BPMNEdge_f_noDeviations" bpmnElement="f_noDeviations">
189
- <di:waypoint x="826" y="120" />
190
- <di:waypoint x="1056" y="120" />
189
+ <di:waypoint x="866" y="120" />
190
+ <di:waypoint x="1366" y="120" />
191
+ </bpmndi:BPMNEdge>
192
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
193
+ <di:waypoint x="1466" y="120" />
194
+ <di:waypoint x="1566" y="120" />
195
+ </bpmndi:BPMNEdge>
196
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
197
+ <di:waypoint x="1666" y="120" />
198
+ <di:waypoint x="1766" y="120" />
191
199
  </bpmndi:BPMNEdge>
192
200
  <bpmndi:BPMNEdge id="BPMNEdge_f_deviations" bpmnElement="f_deviations">
193
- <di:waypoint x="801" y="145" />
194
- <di:waypoint x="801" y="240" />
201
+ <di:waypoint x="841" y="145" />
202
+ <di:waypoint x="841" y="280" />
203
+ <di:waypoint x="966" y="280" />
204
+ <bpmndi:BPMNLabel>
205
+ <dc:Bounds x="846" y="206" width="74" height="14" />
206
+ </bpmndi:BPMNLabel>
195
207
  </bpmndi:BPMNEdge>
196
208
  <bpmndi:BPMNEdge id="BPMNEdge_f_toConformanceAck" bpmnElement="f_toConformanceAck">
197
- <di:waypoint x="851" y="280" />
198
- <di:waypoint x="911" y="280" />
209
+ <di:waypoint x="1066" y="280" />
210
+ <di:waypoint x="1166" y="280" />
199
211
  </bpmndi:BPMNEdge>
200
212
  <bpmndi:BPMNEdge id="BPMNEdge_f_ackToSynthesize" bpmnElement="f_ackToSynthesize">
201
- <di:waypoint x="1011" y="280" />
202
- <di:waypoint x="1106" y="280" />
203
- <di:waypoint x="1106" y="160" />
204
- </bpmndi:BPMNEdge>
205
- <bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
206
- <di:waypoint x="1156" y="120" />
207
- <di:waypoint x="1256" y="120" />
208
- </bpmndi:BPMNEdge>
209
- <bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
210
- <di:waypoint x="1356" y="120" />
211
- <di:waypoint x="1456" y="120" />
213
+ <di:waypoint x="1266" y="280" />
214
+ <di:waypoint x="1416" y="280" />
215
+ <di:waypoint x="1416" y="160" />
212
216
  </bpmndi:BPMNEdge>
213
217
  </bpmndi:BPMNPlane>
214
218
  </bpmndi:BPMNDiagram>
@@ -0,0 +1,123 @@
1
+ # Derivation-parity suite (`defineFlow` ports of the nwf goldens)
2
+
3
+ Epic **nanobpm/nano-ide#314**, slice **S5 / #320**: port the seven hand-authored
4
+ nano-workforce BPMN goldens in `resources/processes/*.bpmn` to the code-first
5
+ `@nanobpm/workflow` `defineFlow` surface, and prove each derived model is
6
+ **structurally equal** to its golden with the S0 parity harness
7
+ (`@nanobpm/workflow/test-support` — `normalize` / `assertDerivationParity`).
8
+
9
+ - `flows.ts` — the code-first ports (one `defineFlow` per model) plus a `PORTS`
10
+ registry that pairs each model with its port or its documented blocker.
11
+ - `derivation-parity.test.ts` — runs `assertDerivationParity` for every ported
12
+ model, reports blocked models as skipped with their reason, and proves the
13
+ blocker against the goldens themselves.
14
+
15
+ Run it with the repo suite (`npm test`) or directly:
16
+
17
+ ```sh
18
+ node --experimental-strip-types --test test/derivation-parity/derivation-parity.test.ts
19
+ ```
20
+
21
+ ## Status
22
+
23
+ Per the human decision on the **#320 escalation** — _(c)+(a): land the
24
+ structurally-derivable goldens now at full whole-model parity, park the rest
25
+ behind an upstream construct, and do **not** relax to node-surface parity_:
26
+
27
+ | Model | Top-level start/end | Status |
28
+ | ------------------ | ------------------- | ------ |
29
+ | `retro` | 1 / 1 | ⛔ parked — class 3 (general service-task ioMapping) |
30
+ | `convergence-loop` | 1 / 1 | ⛔ parked — class 2 (arbitrary graph) |
31
+ | `spine-demo` | 1 / 2 | ⛔ parked — class 1 (multi start/end) |
32
+ | `readiness-gate` | 1 / 5 | ⛔ parked — class 1 (multi start/end) |
33
+ | `feature` | 2 / 2 | ⛔ parked — class 1 (multi start/end) |
34
+ | `merge-loop` | 1 / 2 | ⛔ parked — class 1 (multi start/end) |
35
+ | `plan-fanout` | 3 / 3 | ⛔ parked — class 1 (multi start/end) |
36
+
37
+ `retro` was a green full-parity port — a linear single-start/single-end agent
38
+ pipeline — until the conformance work (#355/#356) added a conformance-escalation
39
+ subgraph to its golden, which introduced a service task carrying a general
40
+ `<zeebe:ioMapping>` the stock `task` builder cannot emit (class 3). All seven
41
+ goldens are now parked, across **three** distinct blocker classes, each awaiting
42
+ an upstream `@nanobpm/workflow` (nano-ide) construct + re-release (never a golden
43
+ edit, never relaxed acceptance).
44
+
45
+ ## The blockers
46
+
47
+ ### Class 1 — multiple top-level start/end events (5 models)
48
+
49
+ The published builder surface **`@nanobpm/workflow@0.12.0`** derives **exactly
50
+ one** top-level `<bpmn:startEvent id="Start">` and **exactly one**
51
+ `<bpmn:endEvent id="End">`, converging every top-level dangling branch into that
52
+ single end (`Compiler.compile` in the package's `declarative.ts`). There is no
53
+ terminal / explicit-end construct and no way to author multiple top-level start
54
+ events.
55
+
56
+ Five goldens have **multiple** top-level start and/or end events, so their
57
+ derived model can never be structurally equal under `normalize` (which
58
+ distinguishes `N` end events each with `in=1` from one end event with `in=N`).
59
+ The suite's `class-1 blocked goldens genuinely have multiple top-level start/end
60
+ events` diagnostic pins this against the goldens themselves.
61
+
62
+ ### Class 2 — arbitrary control-flow graph (`convergence-loop`)
63
+
64
+ `convergence-loop` has a single start/end (it clears class 1) but its topology is
65
+ **not expressible** with `@nanobpm/workflow@0.12.0`'s structured-only builder
66
+ (`loop` / `switch` / `branch`). Single start/end is _necessary but not
67
+ sufficient_. Three golden features have no structured-builder derivation, each
68
+ pinned by a diagnostic in `derivation-parity.test.ts`:
69
+
70
+ 1. **Task-level back-edge merge.** The loop head `review-round` is a
71
+ `serviceTask` that merges **three** back-edges directly (`in=3`). But `loop()`
72
+ always inserts an exclusive-gateway loop head that absorbs the back-edge, so
73
+ the body task stays `in=1` — empirically demonstrated by the `loop() inserts a
74
+ gateway head` test.
75
+ 2. **Heterogeneous multi-way gateway.** `gw-status` is a single exclusive gateway
76
+ with **four** heterogeneous-condition out-edges (two `=x = "v"` equalities, one
77
+ complex boolean, one default). No `switch` (equalities + default) or `branch`
78
+ (one condition + default) emits that.
79
+ 3. **Shared merge+split gateway.** `gw-escalated` is a single exclusive gateway
80
+ that is at once a **five-way merge and a two-way split**, reached by back-edges
81
+ from five distinct points.
82
+
83
+ The fix is an **arbitrary-graph / explicit-join (named-target)** builder upstream
84
+ in `@nanobpm/workflow` — a **superset** of the class-1 gap.
85
+
86
+ ### Class 3 — general service-task ioMapping (`retro`)
87
+
88
+ `retro` clears classes 1 and 2 (single start/end, structured topology), but its
89
+ golden's `record-conformance-ack` service task carries a **general**
90
+ `<zeebe:ioMapping>` — inputs `=planKey`→`planKey` and
91
+ `=if (is defined(note)) then note else null`→`note`. `@nanobpm/workflow@0.12.0`'s
92
+ `task` builder only emits an ioMapping as a side effect of a `prompt.append` (a
93
+ single `appendPrompt` input); there is no way to declare arbitrary input/output
94
+ mappings on a service task. Every other element of the golden IS expressible
95
+ (`w.task`+prompt, `w.branch` for the `deviations?` gateway, `w.human` for the
96
+ `conformance-escalation` userTask, envelopes) — this one service-task ioMapping
97
+ is the sole gap. The `retro golden needs a general service-task ioMapping the
98
+ stock builder cannot emit` diagnostic pins both halves (the golden needs it; the
99
+ stock builder cannot produce it).
100
+
101
+ The fix is a general `io: { input?, output? }` on the external `task`/`run`
102
+ builder upstream in `@nanobpm/workflow` (**nano-ide#405**).
103
+
104
+ ## Resuming this slice
105
+
106
+ The follow-up upstream slices (opened in **nanobpm/nano-ide** per decision path
107
+ (a)) must add:
108
+
109
+ - a terminal / explicit-end (+ multi-start) construct (unblocks class 1), **and**
110
+ - an arbitrary-graph / explicit-join (named-target) builder (unblocks class 2 —
111
+ `convergence-loop`), **and**
112
+ - a general service-task `io` mapping (unblocks class 3 — `retro`; nano-ide#405).
113
+
114
+ Then, on a resumed run here:
115
+
116
+ 1. Bump the `@nanobpm/workflow` dependency to the release that carries the
117
+ construct(s).
118
+ 2. In `flows.ts`, replace each parked entry's `blockedReason` with a real
119
+ `flow: defineFlow(...)` port (author each model against exactly the features
120
+ it uses — see the per-model feature map in the #320 brief).
121
+
122
+ No golden `.bpmn` file may be edited to force a match — the derivation must
123
+ reproduce the checked-in golden.
@@ -0,0 +1,177 @@
1
+ // Derivation-parity suite for epic nanobpm/nano-ide#314 (S5, sub-issue #320).
2
+ //
3
+ // For every nano-workforce golden in `resources/processes/*.bpmn`, assert that
4
+ // its code-first `defineFlow` port (see `./flows.ts`) derives a BPMN model that
5
+ // is STRUCTURALLY EQUAL to the checked-in golden, using the S0 parity harness
6
+ // (`@nanobpm/workflow/test-support`). The harness normalizes both models (strips
7
+ // DI, canonicalizes ids/ordering) and diffs their semantic structure — nodes,
8
+ // sequence flows, message subscriptions, timer/boundary definitions, user tasks,
9
+ // and linked resources — with a legible red/green diff on mismatch.
10
+ //
11
+ // Ported models run a real `assertDerivationParity`; parked models (see
12
+ // `./flows.ts`) are reported as skipped WITH their precise reason, in two
13
+ // blocker classes — class 1: multiple top-level start/end events; class 2:
14
+ // arbitrary control-flow graph (`convergence-loop`). Companion diagnostics prove
15
+ // each blocker is real against the goldens themselves. No golden is modified to
16
+ // force a match — the derivation must reproduce the checked-in file.
17
+
18
+ import { test } from "node:test";
19
+ import { readFileSync } from "node:fs";
20
+ import { assert, assertEquals } from "#test-assert";
21
+ import { assertDerivationParity, normalize } from "@nanobpm/workflow/test-support";
22
+ import { declarativeToBpmn, defineFlow } from "@nanobpm/workflow";
23
+ import { PORTS } from "./flows.ts";
24
+
25
+ const ROOT = decodeURIComponent(new URL("../../", import.meta.url).pathname);
26
+ const goldenPath = (model: string): string => `${ROOT}resources/processes/${model}.bpmn`;
27
+
28
+ test("derivation parity — nano-workforce corpus", async (t) => {
29
+ for (const port of PORTS) {
30
+ const golden = goldenPath(port.model);
31
+ const flow = port.flow;
32
+ if (flow) {
33
+ await t.test(`${port.model} derives its golden`, () => {
34
+ assertDerivationParity(flow, golden);
35
+ });
36
+ } else {
37
+ await t.test(`${port.model} (pending port)`, { skip: port.blockedReason ?? "pending" }, () => {});
38
+ }
39
+ }
40
+ });
41
+
42
+ // Every entry either derives its golden or documents why it cannot — so the
43
+ // corpus is fully accounted for and no model is silently dropped.
44
+ test("every corpus model is either ported or has a documented blocker", () => {
45
+ const expected = [
46
+ "retro",
47
+ "spine-demo",
48
+ "readiness-gate",
49
+ "feature",
50
+ "convergence-loop",
51
+ "merge-loop",
52
+ "plan-fanout",
53
+ ];
54
+ assertEquals(
55
+ PORTS.map((p) => p.model),
56
+ expected,
57
+ "PORTS must cover all seven goldens in the epic's authoring order",
58
+ );
59
+ for (const port of PORTS) {
60
+ assert(
61
+ port.flow !== undefined || (port.blockedReason && port.blockedReason.length > 0),
62
+ `${port.model} must either be ported (flow) or carry a blockedReason`,
63
+ );
64
+ }
65
+ });
66
+
67
+ // The blockers are not guesses — prove each against the goldens themselves.
68
+ //
69
+ // CLASS 1 — five goldens have MORE THAN ONE top-level start and/or end event,
70
+ // which the published `@nanobpm/workflow@0.12.0` compiler (a single
71
+ // `<startEvent id="Start">` + single `<endEvent id="End">`) cannot derive.
72
+ test("class-1 blocked goldens genuinely have multiple top-level start/end events", () => {
73
+ const countTag = (xml: string, tag: string): number =>
74
+ (xml.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
75
+
76
+ const multiStartEndBlocked = new Set(["spine-demo", "readiness-gate", "feature", "merge-loop", "plan-fanout"]);
77
+ for (const model of multiStartEndBlocked) {
78
+ const xml = readFileSync(goldenPath(model), "utf8");
79
+ const starts = countTag(xml, "startEvent");
80
+ const ends = countTag(xml, "endEvent");
81
+ assert(
82
+ starts > 1 || ends > 1,
83
+ `${model} is marked compiler-blocked but has ${starts} start(s)/${ends} end(s) — reclassify it`,
84
+ );
85
+ }
86
+
87
+ // The two single-start/single-end goldens (retro, convergence-loop) clear
88
+ // class 1; retro is class-3 blocked (a general service-task ioMapping),
89
+ // convergence-loop is class-2 blocked — both below.
90
+ for (const model of ["retro", "convergence-loop"]) {
91
+ const xml = readFileSync(goldenPath(model), "utf8");
92
+ assertEquals(countTag(xml, "startEvent"), 1, `${model} should have one start event`);
93
+ assertEquals(countTag(xml, "endEvent"), 1, `${model} should have one end event`);
94
+ }
95
+ });
96
+
97
+ // CLASS 3 — retro clears classes 1 & 2 (single start/end, structured topology)
98
+ // but its golden carries a service task with a GENERAL <zeebe:ioMapping> — inputs
99
+ // whose target is NOT `appendPrompt` — which @nanobpm/workflow@0.12.0's `task`
100
+ // builder cannot emit (it only produces an ioMapping via a `prompt.append`, i.e.
101
+ // a lone `appendPrompt` input). Prove both halves: the golden needs it, and the
102
+ // stock builder cannot produce it (awaits nano-ide#405).
103
+ test("retro golden needs a general service-task ioMapping the stock builder cannot emit", () => {
104
+ const xml = readFileSync(goldenPath("retro"), "utf8");
105
+ // (a) The golden has a service task carrying an ioMapping input to a non-prompt
106
+ // target (`record-conformance-ack`: =planKey→planKey, note→note).
107
+ assert(
108
+ /target="planKey"/.test(xml) && /target="note"/.test(xml),
109
+ "retro golden should carry general ioMapping inputs (planKey, note) on record-conformance-ack",
110
+ );
111
+ // (b) The stock `task` builder only ever emits `appendPrompt` as an ioMapping
112
+ // target — never a general input like `note` — so the golden is not
113
+ // derivable until the upstream `io` construct lands.
114
+ const probe = defineFlow("io-probe", (w) => {
115
+ w.task("agent", {
116
+ jobType: "senior:retro",
117
+ prompt: { resourceId: "retro.md", bindingType: "latest", append: "=retroDigest" },
118
+ });
119
+ });
120
+ const derived = declarativeToBpmn(probe);
121
+ assert(/target="appendPrompt"/.test(derived), "prompt.append should emit an appendPrompt ioMapping input");
122
+ assert(
123
+ !/target="note"/.test(derived) && !/target="planKey"/.test(derived),
124
+ "the stock task builder cannot emit a general (non-appendPrompt) ioMapping input",
125
+ );
126
+ });
127
+
128
+ // CLASS 2 — convergence-loop has a single start/end (clears class 1) but an
129
+ // ARBITRARY control-flow graph the structured-only builder cannot emit. Prove
130
+ // the three specific features against the golden itself.
131
+ test("convergence-loop golden has arbitrary-graph features the structured builder cannot emit", () => {
132
+ const xml = readFileSync(goldenPath("convergence-loop"), "utf8");
133
+ const between = (id: string, closeTag: string, tag: string): number => {
134
+ // Count <bpmn:<tag>> occurrences inside the element `id`, whose end is its
135
+ // own </bpmn:<closeTag>> (not the first nested close tag).
136
+ const open = xml.indexOf(`id="${id}"`);
137
+ assert(open >= 0, `convergence-loop golden is missing element id="${id}"`);
138
+ const rest = xml.slice(open);
139
+ const close = rest.indexOf(`</bpmn:${closeTag}>`);
140
+ assert(close >= 0, `convergence-loop golden element id="${id}" is missing its closing </bpmn:${closeTag}>`);
141
+ const body = rest.slice(0, close);
142
+ return (body.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
143
+ };
144
+ // (a) the loop head is a serviceTask that MERGES three back-edges directly.
145
+ assertEquals(between("review-round", "serviceTask", "incoming"), 3, "review-round should merge 3 flows on the task itself");
146
+ // (b) a single exclusive gateway forks FOUR heterogeneous-condition out-edges.
147
+ assertEquals(between("gw-status", "exclusiveGateway", "outgoing"), 4, "gw-status should be a 4-way exclusive gateway");
148
+ // (c) a single exclusive gateway is at once a 5-way merge and a 2-way split.
149
+ assertEquals(between("gw-escalated", "exclusiveGateway", "incoming"), 5, "gw-escalated should merge 5 flows");
150
+ assertEquals(between("gw-escalated", "exclusiveGateway", "outgoing"), 2, "gw-escalated should also split 2 ways");
151
+ });
152
+
153
+ // CLASS 2, empirical — demonstrate WHY the structured builder cannot reproduce
154
+ // (a): a `loop()` whose body starts with a task derives an exclusive-gateway
155
+ // loop head that absorbs the back-edge (in>=2), leaving the task itself at
156
+ // in=1. The golden instead merges its back-edges directly into `review-round`
157
+ // (in=3) with no loop-head gateway — a shape the builder cannot express.
158
+ test("loop() inserts a gateway head, so back-edges cannot merge into a task", () => {
159
+ const probe = defineFlow("loop-head-probe", (w) => {
160
+ w.loop((b) => {
161
+ b.task("review-round", { jobType: "senior:pr-review" });
162
+ b.branch("done", { then: (g) => g.break() });
163
+ });
164
+ });
165
+ const model = normalize(declarativeToBpmn(probe));
166
+ const inDegree = (n: string): number => Number(/<in=(\d+)/.exec(n)?.[1] ?? "0");
167
+ const gateways = model.nodes.filter((n) => n.startsWith("exclusiveGateway"));
168
+ const tasks = model.nodes.filter((n) => n.startsWith("serviceTask"));
169
+ assert(
170
+ gateways.some((n) => inDegree(n) >= 2),
171
+ "loop() should derive an exclusive-gateway head that absorbs the back-edge (in>=2)",
172
+ );
173
+ assert(
174
+ tasks.every((n) => inDegree(n) <= 1),
175
+ "the loop-body task cannot itself be the back-edge merge (it stays in<=1)",
176
+ );
177
+ });
@@ -0,0 +1,149 @@
1
+ // Code-first (`defineFlow`) ports of the hand-authored nano-workforce BPMN
2
+ // goldens in `resources/processes/*.bpmn`, for epic nanobpm/nano-ide#314 (S5,
3
+ // sub-issue #320). Each port must derive a BPMN model that is STRUCTURALLY equal
4
+ // to its golden under the S0 derivation-parity harness
5
+ // (`@nanobpm/workflow/test-support` — `normalize` / `assertDerivationParity`),
6
+ // proving the code-first and model-first representations agree.
7
+ //
8
+ // STATUS (per the human decision on the nano-ide#320 escalation — (c)+(a): land
9
+ // the structurally-derivable goldens at full whole-model parity, park the rest
10
+ // pending an upstream construct, and do NOT relax to node-surface parity):
11
+ //
12
+ // • All seven goldens are currently `blockedReason`-parked, in THREE distinct
13
+ // classes, each awaiting an upstream `@nanobpm/workflow` (nano-ide) construct
14
+ // + re-release — never a golden edit and never relaxed acceptance:
15
+ //
16
+ // (1) MULTI top-level start/end (spine-demo, readiness-gate, feature,
17
+ // merge-loop, plan-fanout). `@nanobpm/workflow@0.12.0` derives EXACTLY
18
+ // ONE `<bpmn:startEvent id="Start">` + ONE `<bpmn:endEvent id="End">`,
19
+ // converging every dangler into that single end (see `Compiler.compile`
20
+ // in the package's `declarative.ts`). Needs a terminal/explicit-end
21
+ // (+ multi-start) construct.
22
+ //
23
+ // (2) ARBITRARY control-flow graph (convergence-loop). Single start/end —
24
+ // so it clears class (1) — but its topology is NOT expressible with the
25
+ // structured-only builder (`loop`/`switch`/`branch`), empirically proven
26
+ // (see `derivation-parity.test.ts`): its loop head `review-round` is a
27
+ // serviceTask that MERGES three back-edges directly (in=3), whereas
28
+ // `loop()` always inserts an exclusive-gateway loop head (the task stays
29
+ // in=1); `gw-status` is a single exclusive gateway with FOUR
30
+ // heterogeneous-condition out-edges (two `=x = "v"`, one complex boolean,
31
+ // one default) which no `switch`/`branch` emits; and `gw-escalated` is a
32
+ // single gateway that is simultaneously a five-way merge and a two-way
33
+ // split. Single start/end is necessary but NOT sufficient. Needs an
34
+ // arbitrary-graph / explicit-join (named-target) builder — a SUPERSET of
35
+ // the class-(1) gap.
36
+ //
37
+ // (3) GENERAL service-task ioMapping (retro). retro WAS a green full-parity
38
+ // port (a linear gather → synthesize → record agent pipeline) until the
39
+ // conformance work (nano-workforce #355/#356) added a conformance-
40
+ // escalation subgraph to its golden. Every new element ports with the
41
+ // stock builder (`w.branch` for the `deviations?` gateway, `w.human` for
42
+ // the `conformance-escalation` userTask, `w.task`+prompt/envelopes for
43
+ // the service tasks) EXCEPT `record-conformance-ack`: a service task with
44
+ // a general <zeebe:ioMapping> (inputs `=planKey`→planKey and
45
+ // `=if (is defined(note)) then note else null`→note). @nanobpm/workflow@
46
+ // 0.12.0's `task` builder only emits an ioMapping via a `prompt.append`
47
+ // (a single `appendPrompt` input), so this task is not derivable. Needs a
48
+ // general `io` on the task/run builder upstream (nano-ide#405).
49
+ //
50
+ // A resumed run flips any parked model to a real `flow` once the corresponding
51
+ // upstream construct lands and `@nanobpm/workflow` is bumped past 0.12.0.
52
+
53
+ import type { DeclarativeFlow } from "@nanobpm/workflow";
54
+
55
+ /** One model's port entry: the golden basename plus EITHER the derived flow
56
+ * (when it can be reproduced) OR the reason it is blocked — never both and never
57
+ * neither. Modelled as a discriminated union so a partial/contradictory entry
58
+ * (both `flow` and `blockedReason`, or neither) fails to compile. */
59
+ export type PortEntry =
60
+ | {
61
+ /** The golden model basename under `resources/processes/<model>.bpmn`. */
62
+ readonly model: string;
63
+ /** The derived `defineFlow` — a structurally-faithful port exists. */
64
+ readonly flow: DeclarativeFlow;
65
+ readonly blockedReason?: never;
66
+ }
67
+ | {
68
+ /** The golden model basename under `resources/processes/<model>.bpmn`. */
69
+ readonly model: string;
70
+ readonly flow?: never;
71
+ /** Why whole-model parity is not yet achievable — precisely. */
72
+ readonly blockedReason: string;
73
+ };
74
+
75
+ // ── retro (PARKED — class 3) ─────────────────────────────────────────────────
76
+ // retro WAS a green full-parity port — a linear gather → synthesize → record
77
+ // agent pipeline. The conformance work (nano-workforce #355/#356) then added a
78
+ // conformance-escalation subgraph to the golden: a `deviations?` exclusive
79
+ // gateway, a `conformance-escalation` userTask, and `senior:conformance` /
80
+ // `pr.conformance-record` / `pr.conformance-ack` service tasks. All of those ARE
81
+ // expressible with the stock builder (`w.branch`, `w.human`, `w.task`+prompt,
82
+ // envelopes) EXCEPT `record-conformance-ack`: it carries a general
83
+ // <zeebe:ioMapping> (inputs `=planKey`→planKey and
84
+ // `=if (is defined(note)) then note else null`→note), which
85
+ // @nanobpm/workflow@0.12.0's `task` builder cannot emit — it only produces an
86
+ // ioMapping via a `prompt.append` (a single `appendPrompt` input). So retro
87
+ // regresses to a parked model pending the upstream construct: a general `io` on
88
+ // the external `task`/`run` builder (nano-ide#405). It flips back to a green
89
+ // port once that lands and @nanobpm/workflow is bumped past 0.12.0.
90
+
91
+ const RETRO_IO_BLOCK =
92
+ "blocked (general service-task ioMapping): retro's golden gained a " +
93
+ "conformance-escalation subgraph whose `record-conformance-ack` service task " +
94
+ "carries a general <zeebe:ioMapping> (inputs =planKey→planKey and " +
95
+ "=if (is defined(note)) then note else null→note). @nanobpm/workflow@0.12.0's " +
96
+ "`task` builder only emits an ioMapping via a `prompt.append` (a single " +
97
+ "appendPrompt input), so this task is not derivable. Every other element of " +
98
+ "the golden IS expressible (w.task+prompt, w.branch, w.human, envelopes). " +
99
+ "Awaits a general `io` on the task/run builder upstream in @nanobpm/workflow " +
100
+ "(nano-ide#405).";
101
+
102
+ /** The single-top-level-end/start compiler limitation, reused as the
103
+ * `blockedReason` for every golden that has more than one top-level start
104
+ * and/or end event. */
105
+ const MULTI_START_END_BLOCK =
106
+ "blocked: @nanobpm/workflow@0.12.0 derives a single top-level start/end and " +
107
+ "converges all danglers into one <endEvent id=\"End\">; this golden has " +
108
+ "multiple top-level start and/or end events, which the published compiler " +
109
+ "cannot reproduce. Awaits an upstream terminal/explicit-end (+ multi-start) " +
110
+ "construct in @nanobpm/workflow (nano-ide).";
111
+
112
+ /** All seven ports, keyed by model, in the epic's stated authoring order. */
113
+ export const PORTS: readonly PortEntry[] = [
114
+ { model: "retro", blockedReason: RETRO_IO_BLOCK },
115
+ {
116
+ model: "spine-demo",
117
+ blockedReason: `${MULTI_START_END_BLOCK} (spine-demo: 1 start, 2 ends)`,
118
+ },
119
+ {
120
+ model: "readiness-gate",
121
+ blockedReason: `${MULTI_START_END_BLOCK} (readiness-gate: 1 start, 5 ends)`,
122
+ },
123
+ {
124
+ model: "feature",
125
+ blockedReason: `${MULTI_START_END_BLOCK} (feature: 2 starts, 2 ends)`,
126
+ },
127
+ {
128
+ model: "convergence-loop",
129
+ blockedReason:
130
+ "blocked (arbitrary control-flow graph): single top-level start/end, but " +
131
+ "its topology is not expressible with @nanobpm/workflow@0.12.0's " +
132
+ "structured-only builder (loop/switch/branch). Proven in the test suite: " +
133
+ "the loop head `review-round` is a serviceTask that merges 3 back-edges " +
134
+ "directly (in=3), but loop() always inserts an exclusive-gateway head " +
135
+ "(task stays in=1); `gw-status` is one gateway with 4 heterogeneous-" +
136
+ "condition out-edges (no switch/branch emits that); `gw-escalated` is one " +
137
+ "gateway that is at once a 5-way merge and a 2-way split. Awaits an " +
138
+ "arbitrary-graph / explicit-join (named-target) builder upstream in " +
139
+ "@nanobpm/workflow (nano-ide) — a superset of the multi-start/end gap.",
140
+ },
141
+ {
142
+ model: "merge-loop",
143
+ blockedReason: `${MULTI_START_END_BLOCK} (merge-loop: 1 start, 2 ends)`,
144
+ },
145
+ {
146
+ model: "plan-fanout",
147
+ blockedReason: `${MULTI_START_END_BLOCK} (plan-fanout: 3 starts, 3 ends)`,
148
+ },
149
+ ];