@nanobpm/nano-workforce 0.111.0 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.112.0](https://github.com/nanobpm/nano-workforce/compare/v0.111.1...v0.112.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * **plan-fanout:** implement-stage escalation net + Tasks-inbox projection ([#358](https://github.com/nanobpm/nano-workforce/issues/358), [#360](https://github.com/nanobpm/nano-workforce/issues/360)) ([#387](https://github.com/nanobpm/nano-workforce/issues/387)) ([c884074](https://github.com/nanobpm/nano-workforce/commit/c884074ca9266d5a17c44d3a1286bb667b43a73a))
7
+
8
+ ## [0.111.1](https://github.com/nanobpm/nano-workforce/compare/v0.111.0...v0.111.1) (2026-08-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge:** gate red declared-required checks in classifyMergeability ([#393](https://github.com/nanobpm/nano-workforce/issues/393)) ([f40412a](https://github.com/nanobpm/nano-workforce/commit/f40412a07a51f3274a30274f266a099e3036741a)), closes [#392](https://github.com/nanobpm/nano-workforce/issues/392) [#348](https://github.com/nanobpm/nano-workforce/issues/348)
14
+
1
15
  # [0.111.0](https://github.com/nanobpm/nano-workforce/compare/v0.110.0...v0.111.0) (2026-08-20)
2
16
 
3
17
 
@@ -461,3 +461,49 @@ function collectCycle(adjacency: Map<string, Set<string>>, errors: DeliveryGraph
461
461
  if (state.get(node) !== DONE) visit(node, []);
462
462
  }
463
463
  }
464
+
465
+ /** Build the `nodeId → declared-fact-names` map for a graph that has ALREADY passed
466
+ * {@link validateDeliveryGraph} (every id/emit is well-formed by then). This is the same map the
467
+ * validator builds internally for edge resolution; exported so a downstream consumer (the S1
468
+ * compiler) derives it from ONE canonical place rather than re-deriving — and thus resolves edge
469
+ * `from` endpoints identically (no drift). Nodes without a valid string id, and duplicate ids, are
470
+ * skipped exactly as the validator does (first id wins). */
471
+ export function deliveryNodeFacts(graph: DeliveryGraphLike): Map<string, Set<string>> {
472
+ const nodeFacts = new Map<string, Set<string>>();
473
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
474
+ for (const rawNode of nodes) {
475
+ if (!isRecord(rawNode)) continue;
476
+ const id = rawNode.id;
477
+ if (typeof id !== "string" || id.length === 0 || nodeFacts.has(id)) continue;
478
+ const facts = new Set<string>();
479
+ if (Array.isArray(rawNode.emits)) {
480
+ for (const rawFact of rawNode.emits) {
481
+ if (isRecord(rawFact) && typeof rawFact.name === "string" && rawFact.name.length > 0) {
482
+ facts.add(rawFact.name);
483
+ }
484
+ }
485
+ }
486
+ nodeFacts.set(id, facts);
487
+ }
488
+ return nodeFacts;
489
+ }
490
+
491
+ /** The minimal read surface {@link deliveryNodeFacts} / {@link resolveDeliveryFrom} need — a graph
492
+ * with a `nodes` array. Kept structural so both the untyped request body and the generated
493
+ * `DeliveryGraph` type satisfy it. */
494
+ export interface DeliveryGraphLike {
495
+ readonly nodes?: unknown;
496
+ }
497
+
498
+ /** Resolve an edge `from` endpoint (`<nodeId>` or `<nodeId>.<fact>`) against a graph's node/fact map,
499
+ * for a graph that has ALREADY passed {@link validateDeliveryGraph} (so the reference is known
500
+ * resolvable and unambiguous). Returns the upstream `nodeId` and, when the `from` was qualified, the
501
+ * referenced `fact`. Shares the exact disambiguation rule the validator uses (a node id may contain
502
+ * dots; a fact name cannot), so the compiler builds the SAME DAG the validator checked — no drift. */
503
+ export function resolveDeliveryFrom(
504
+ from: string,
505
+ nodeFacts: ReadonlyMap<string, ReadonlySet<string>>,
506
+ ): { nodeId: string; fact?: string } {
507
+ const { nodeId, fact } = resolveFrom(from, nodeFacts);
508
+ return fact !== undefined ? { nodeId, fact } : { nodeId };
509
+ }
@@ -0,0 +1,275 @@
1
+ // Unit coverage for the deterministic delivery-graph compiler `compileDeliveryGraph` (ADR 0005,
2
+ // slice S1). The compiler is the TRUSTED inner loop: it validates (via S0's `validateDeliveryGraph`),
3
+ // compiles to a native BPMN artifact, and renders a preview — with ZERO side effects. These tests
4
+ // exercise, directly and with no HTTP:
5
+ // • the happy path (a fully-worked release runbook → ok:true with every preview field),
6
+ // • DETERMINISM (same JSON → byte-identical bpmn/diagram/resolved — the core trust property),
7
+ // • rejection of every malformed class (unknown-kind / dangling / bad-from / cycle) as ok:false
8
+ // with path-qualified errors forwarded verbatim from the validator,
9
+ // • the trust bound — only allowlisted kinds are instantiated (callActivity/userTask, allowlisted
10
+ // calledElement targets; a non-allowlisted kind never reaches compilation),
11
+ // • fan-in / fan-out / multi-root / multi-leaf → explicit parallel gateways,
12
+ // • humanNodes[] and sideEffects[] extraction.
13
+ import { test } from "node:test";
14
+ import { assert, assertEquals } from "#test-assert";
15
+ import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
16
+
17
+ /** Compile and assert success, returning the narrowed ok-result. */
18
+ function compileOk(graph: unknown) {
19
+ const r = compileDeliveryGraph(graph);
20
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
21
+ return r;
22
+ }
23
+
24
+ /** Compile and assert failure, returning the errors. */
25
+ function compileFail(graph: unknown) {
26
+ const r = compileDeliveryGraph(graph);
27
+ assert(!r.ok, `expected ok:false, got ${JSON.stringify(r)}`);
28
+ return r.errors;
29
+ }
30
+
31
+ // The ADR's motivating case: an agent merges PR #B, a `pr` wait node watches it merge and emits
32
+ // `mergedSha`, a human does the manual OTP publish emitting `resolvedArtifact`, and a connector
33
+ // consumes the published artifact.
34
+ const RELEASE_RUNBOOK = {
35
+ name: "release runbook",
36
+ nodes: [
37
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
38
+ {
39
+ id: "watch-b",
40
+ kind: "wait",
41
+ wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } },
42
+ emits: [{ name: "mergedSha", type: "string" }],
43
+ },
44
+ {
45
+ id: "publish",
46
+ kind: "human",
47
+ human: { prompt: "run the manual OTP publish", formKey: "publish-form" },
48
+ emits: [{ name: "resolvedArtifact", type: "artifact" }],
49
+ },
50
+ { id: "consume", kind: "connector", connector: { target: "npm:install", dedupeKey: "consume-1" } },
51
+ ],
52
+ edges: [
53
+ { from: "open-b", to: "watch-b" },
54
+ { from: "watch-b.mergedSha", to: "publish" },
55
+ { from: "publish.resolvedArtifact", to: "consume" },
56
+ ],
57
+ };
58
+
59
+ test("happy path: a well-formed graph compiles to a full preview with no side effects", () => {
60
+ const r = compileOk(RELEASE_RUNBOOK);
61
+ assertEquals(r.ok, true);
62
+ assert(r.bpmn.includes("<bpmn:process id=\"delivery-graph\""), "bpmn carries the compiled process");
63
+ assert(r.diagram.startsWith("flowchart TD"), "diagram is a mermaid flowchart");
64
+ assertEquals(r.resolved.name, "release runbook");
65
+ assertEquals(r.resolved.nodes.length, 4);
66
+ assertEquals(r.resolved.edges.length, 3);
67
+ assertEquals(r.humanNodes.length, 1);
68
+ // agent + connector are side-effecting; wait + human are not.
69
+ assertEquals(r.sideEffects.length, 2);
70
+ });
71
+
72
+ test("determinism: the same JSON always yields byte-identical bpmn/diagram/resolved", () => {
73
+ const a = compileOk(RELEASE_RUNBOOK);
74
+ const b = compileOk(RELEASE_RUNBOOK);
75
+ assertEquals(a.bpmn, b.bpmn);
76
+ assertEquals(a.diagram, b.diagram);
77
+ assertEquals(JSON.stringify(a.resolved), JSON.stringify(b.resolved));
78
+ // Node ORDER in the input must not change the artifact (nodes are sorted by id).
79
+ const shuffled = { ...RELEASE_RUNBOOK, nodes: [...RELEASE_RUNBOOK.nodes].reverse() };
80
+ const c = compileOk(shuffled);
81
+ assertEquals(c.bpmn, a.bpmn);
82
+ assertEquals(c.diagram, a.diagram);
83
+ });
84
+
85
+ test("trust bound: only allowlisted kinds are instantiated — no other BPMN activity type appears", () => {
86
+ const r = compileOk(RELEASE_RUNBOOK);
87
+ // Every node compiles to a callActivity (agent/wait/connector) or a userTask (human) — nothing else.
88
+ assertEquals((r.bpmn.match(/<bpmn:callActivity /g) ?? []).length, 3);
89
+ assertEquals((r.bpmn.match(/<bpmn:userTask /g) ?? []).length, 1);
90
+ assert(!r.bpmn.includes("<bpmn:scriptTask"), "no script task is ever emitted");
91
+ assert(!r.bpmn.includes("<bpmn:serviceTask"), "no bespoke service task is ever emitted");
92
+ // Every call activity delegates to an allowlisted engine-native body.
93
+ const called = [...r.bpmn.matchAll(/processId="([^"]+)"/g)].map((m) => m[1]);
94
+ assertEquals(new Set(called), new Set(["delivery-node-agent", "readiness-gate", "delivery-node-connector"]));
95
+ });
96
+
97
+ test("rejects unknown kind (by construction) with a path-qualified error, nothing compiled", () => {
98
+ const errors = compileFail({
99
+ nodes: [{ id: "x", kind: "deploy", deploy: { target: "prod" } }],
100
+ });
101
+ const e = errors.find((err) => err.path === "nodes[0].kind");
102
+ assert(e !== undefined, `expected a nodes[0].kind error, got ${JSON.stringify(errors)}`);
103
+ assert(e.message.length > 0);
104
+ });
105
+
106
+ test("rejects a dependency cycle with a path-qualified error", () => {
107
+ const errors = compileFail({
108
+ nodes: [
109
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
110
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
111
+ ],
112
+ edges: [
113
+ { from: "a", to: "b" },
114
+ { from: "b", to: "a" },
115
+ ],
116
+ });
117
+ assert(errors.some((e) => /cycle/i.test(e.message)), `expected a cycle error, got ${JSON.stringify(errors)}`);
118
+ });
119
+
120
+ test("rejects a dangling edge and a bad fact reference, each path-qualified", () => {
121
+ const dangling = compileFail({
122
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }],
123
+ edges: [{ from: "a", to: "ghost" }],
124
+ });
125
+ assert(dangling.some((e) => e.path === "edges[0].to"));
126
+
127
+ const badFrom = compileFail({
128
+ nodes: [
129
+ { id: "a", kind: "wait", wait: { kind: "http", target: "u" }, emits: [{ name: "x", type: "string" }] },
130
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
131
+ ],
132
+ edges: [{ from: "a.nope", to: "b" }],
133
+ });
134
+ assert(badFrom.some((e) => e.path === "edges[0].from"));
135
+ });
136
+
137
+ test("fan-in: a node with two producers gets a parallel JOIN gateway", () => {
138
+ const r = compileOk({
139
+ nodes: [
140
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
141
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
142
+ { id: "c", kind: "agent", agent: { jobType: "j" } },
143
+ ],
144
+ edges: [
145
+ { from: "a", to: "c" },
146
+ { from: "b", to: "c" },
147
+ ],
148
+ });
149
+ assert(r.bpmn.includes("<bpmn:parallelGateway"), "a join gateway is emitted");
150
+ const cNode = r.resolved.nodes.find((n) => n.id === "c");
151
+ assertEquals(cNode?.dependsOn, ["a", "b"]);
152
+ });
153
+
154
+ test("fan-out: a node with two consumers gets a parallel FORK gateway", () => {
155
+ const r = compileOk({
156
+ nodes: [
157
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
158
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
159
+ { id: "c", kind: "agent", agent: { jobType: "j" } },
160
+ ],
161
+ edges: [
162
+ { from: "a", to: "b" },
163
+ { from: "a", to: "c" },
164
+ ],
165
+ });
166
+ assert(r.bpmn.includes('name="fan out of a"'), "a fork gateway for node a is emitted");
167
+ });
168
+
169
+ test("multiple roots fork from Start and multiple leaves join into End", () => {
170
+ const r = compileOk({
171
+ nodes: [
172
+ { id: "r1", kind: "agent", agent: { jobType: "j" } },
173
+ { id: "r2", kind: "agent", agent: { jobType: "j" } },
174
+ ],
175
+ edges: [],
176
+ });
177
+ assert(r.bpmn.includes('id="gwf_start"'), "a start fork gateway for multiple roots");
178
+ assert(r.bpmn.includes('id="gwj_end"'), "an end join gateway for multiple leaves");
179
+ });
180
+
181
+ test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing", () => {
182
+ const r = compileOk({
183
+ nodes: [
184
+ {
185
+ id: "publish",
186
+ kind: "human",
187
+ human: { prompt: "OTP publish", formKey: "f1" },
188
+ emits: [{ name: "resolvedArtifact", type: "artifact" }],
189
+ },
190
+ { id: "ack", kind: "human" },
191
+ ],
192
+ edges: [{ from: "publish", to: "ack" }],
193
+ });
194
+ const publish = r.humanNodes.find((h) => h.nodeId === "publish");
195
+ assertEquals(publish?.prompt, "OTP publish");
196
+ assertEquals(publish?.formKey, "f1");
197
+ assertEquals(publish?.emits.length, 1);
198
+ const ack = r.humanNodes.find((h) => h.nodeId === "ack");
199
+ assertEquals(ack?.emits.length, 0);
200
+ assertEquals(ack?.prompt, undefined);
201
+ });
202
+
203
+ test("sideEffects: agent + connector only; connector carries its dedupeKey", () => {
204
+ const r = compileOk(RELEASE_RUNBOOK);
205
+ const agent = r.sideEffects.find((s) => s.nodeId === "open-b");
206
+ assertEquals(agent?.kind, "agent");
207
+ assert(agent?.description.includes("senior:feature"));
208
+ const connector = r.sideEffects.find((s) => s.nodeId === "consume");
209
+ assertEquals(connector?.kind, "connector");
210
+ assertEquals(connector?.dedupeKey, "consume-1");
211
+ // The wait + human nodes are NOT side effects.
212
+ assert(!r.sideEffects.some((s) => s.nodeId === "watch-b"));
213
+ assert(!r.sideEffects.some((s) => s.nodeId === "publish"));
214
+ });
215
+
216
+ test("resolved edges carry the resolved fromNode and the referenced fact", () => {
217
+ const r = compileOk(RELEASE_RUNBOOK);
218
+ const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
219
+ assertEquals(factEdge?.fromNode, "watch-b");
220
+ assertEquals(factEdge?.fromFact, "mergedSha");
221
+ const plainEdge = r.resolved.edges.find((e) => e.from === "open-b");
222
+ assertEquals(plainEdge?.fromNode, "open-b");
223
+ assertEquals(plainEdge?.fromFact, undefined);
224
+ });
225
+
226
+ test("BPMN is structurally coherent: one start, one end, every flow endpoint declared", () => {
227
+ const r = compileOk(RELEASE_RUNBOOK);
228
+ assertEquals((r.bpmn.match(/<bpmn:startEvent /g) ?? []).length, 1);
229
+ assertEquals((r.bpmn.match(/<bpmn:endEvent /g) ?? []).length, 1);
230
+ // Every sequenceFlow source/target id is declared as an element id in the document.
231
+ const declaredIds = new Set([...r.bpmn.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]));
232
+ for (const m of r.bpmn.matchAll(/sourceRef="([^"]+)" targetRef="([^"]+)"/g)) {
233
+ assert(declaredIds.has(m[1]), `sourceRef ${m[1]} is declared`);
234
+ assert(declaredIds.has(m[2]), `targetRef ${m[2]} is declared`);
235
+ }
236
+ });
237
+
238
+ test("duplicate fact-qualified edges between the same node pair collapse to ONE sequence flow", () => {
239
+ // `src` emits two facts, both feeding `b` (`src.x -> b` and `src.y -> b`). Adjacency is de-duped by
240
+ // node id, so no fork/join gateway is inserted — the producer wires straight to the consumer. The
241
+ // compiler must therefore collapse the two edges into a SINGLE sequenceFlow so `b` is not scheduled
242
+ // twice (multiple outgoing flows without a diverging gateway is invalid/double-executing BPMN).
243
+ const r = compileOk({
244
+ nodes: [
245
+ {
246
+ id: "src",
247
+ kind: "wait",
248
+ wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" } },
249
+ emits: [
250
+ { name: "x", type: "string" },
251
+ { name: "y", type: "string" },
252
+ ],
253
+ },
254
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
255
+ ],
256
+ edges: [
257
+ { from: "src.x", to: "b" },
258
+ { from: "src.y", to: "b" },
259
+ ],
260
+ });
261
+ // src ("src") sorts after b? No: "b" < "src" → b is n0, src is n1. src has one consumer (b, deduped)
262
+ // so no fork; b has one producer (src, deduped) so no join. The single collapsed edge is src → b.
263
+ const flows = [...r.bpmn.matchAll(/sourceRef="([^"]+)" targetRef="([^"]+)"/g)];
264
+ const srcToB = flows.filter(([, s, t]) => s === "n1" && t === "n0");
265
+ assertEquals(srcToB.length, 1, `expected exactly one src→b flow, got ${JSON.stringify(srcToB.map((m) => m[0]))}`);
266
+ // No parallel gateway is introduced for this de-duplicated pair.
267
+ assert(!r.bpmn.includes("<bpmn:parallelGateway"), "no gateway for a single de-duplicated producer/consumer pair");
268
+ });
269
+
270
+ test("a non-object / empty body is a clean ok:false, never a throw", () => {
271
+ assert(!compileDeliveryGraph(undefined).ok);
272
+ assert(!compileDeliveryGraph(null).ok);
273
+ assert(!compileDeliveryGraph({}).ok);
274
+ assert(!compileDeliveryGraph({ nodes: [] }).ok);
275
+ });