@nanobpm/nano-workforce 0.130.0 → 0.131.1

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,19 @@
1
+ ## [0.131.1](https://github.com/nanobpm/nano-workforce/compare/v0.131.0...v0.131.1) (2026-08-24)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** seed human-task prompt/context so its form is not contextless ([#502](https://github.com/nanobpm/nano-workforce/issues/502)) ([d2991ea](https://github.com/nanobpm/nano-workforce/commit/d2991ea0ec54e36711c27f58b8901ca85b7179e6)), closes [#499](https://github.com/nanobpm/nano-workforce/issues/499) [#499](https://github.com/nanobpm/nano-workforce/issues/499)
6
+
7
+ ## [0.131.0](https://github.com/nanobpm/nano-workforce/compare/v0.130.0...v0.131.0) (2026-08-23)
8
+
9
+ ### Features
10
+
11
+ * **delivery-graph:** conditional (guarded) edges → exclusive gateways (ADR 0005 S7) ([#495](https://github.com/nanobpm/nano-workforce/issues/495)) ([48e4fc3](https://github.com/nanobpm/nano-workforce/commit/48e4fc3787067c175dbebdfdd81954dc79e0b167)), closes [nanobpm/nano-workforce#492](https://github.com/nanobpm/nano-workforce/issues/492)
12
+
13
+ ### Bug Fixes
14
+
15
+ * **cockpit:** suppress idle-worker drill + waiting-for-output note (blank terminal) ([#497](https://github.com/nanobpm/nano-workforce/issues/497)) ([a721c45](https://github.com/nanobpm/nano-workforce/commit/a721c450faf0f3b43ee2d03b2eed312122c3b624)), closes [#496](https://github.com/nanobpm/nano-workforce/issues/496)
16
+
1
17
  ## [0.130.0](https://github.com/nanobpm/nano-workforce/compare/v0.129.1...v0.130.0) (2026-08-23)
2
18
 
3
19
  ### Features
@@ -128,14 +128,30 @@ test("drilling into a worker subscribes its relay stream on connect", async () =
128
128
 
129
129
  test("clicking a rendered worker drill button drills its stream", async () => {
130
130
  const r = rig();
131
+ // Only a worker that currently holds a job renders an inline drill button (an idle worker's stream
132
+ // has no producer — drilling it would blank). Give wk-a a job so its drill affordance is present.
133
+ // A busy worker relays on the jobKey-scoped `job:<jobKey>` stream (the supply endpoint repoints its
134
+ // `stream` there — see supply-view.ts), so the fixture uses that scoped id, not the bare instance id.
135
+ const drillable = { instance: "wk-a", identity: "leaf-1", stream: "job:j-a", family: "senior", host: "h1", jobKeys: ["j-a"], live: true, staleMs: 0 };
136
+ r.report = { count: 1, workers: [drillable], leaves: [{ token: "leaf-1", workers: [drillable] }] };
131
137
  const cockpit = bootSupplyCockpit(r.env);
132
138
  await cockpit.refresh();
133
- const button = r.host.byClass("cockpit-worker-drill").find((b) => b.getAttribute("data-stream") === "wk-a");
139
+ const button = r.host.byClass("cockpit-worker-drill").find((b) => b.getAttribute("data-stream") === "job:j-a");
134
140
  button?.dispatch("click");
135
- assert.equal(cockpit.currentStream, "wk-a");
141
+ assert.equal(cockpit.currentStream, "job:j-a");
136
142
  assert.equal(r.sockets.length, 1);
137
143
  });
138
144
 
145
+ test("an idle worker (no current job) renders NO inline drill button — its stream has no producer", async () => {
146
+ const r = rig(); // the default served fixture worker wk-a has jobKeys: [] → idle
147
+ const cockpit = bootSupplyCockpit(r.env);
148
+ await cockpit.refresh();
149
+ const drills = r.host.byClass("cockpit-worker-drill");
150
+ assert.equal(drills.length, 0, "no drill button is rendered for an idle worker");
151
+ // The worker is still openable (its name button) and simply has nothing live to stream.
152
+ assert.equal(r.host.byData("worker", "wk-a").length, 1);
153
+ });
154
+
139
155
  test("relay output is written to the drilled worker's terminal", async () => {
140
156
  const r = rig();
141
157
  const cockpit = bootSupplyCockpit(r.env);
@@ -146,6 +162,37 @@ test("relay output is written to the drilled worker's terminal", async () => {
146
162
  assert.deepEqual(r.terminalWrites, ["boot\n"]);
147
163
  });
148
164
 
165
+ test("a live drill shows a 'waiting for output' note until the first frame, then clears it", async () => {
166
+ const r = rig();
167
+ const cockpit = bootSupplyCockpit(r.env);
168
+ await cockpit.refresh();
169
+ const note = () => r.host.byClass("cockpit-terminal-note")[0];
170
+
171
+ cockpit.drill("wk-a");
172
+ r.sockets[0]?.fireOpen();
173
+ assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "note armed on a connected-but-quiet stream");
174
+ assert.match(note()?.textContent ?? "", /waiting for live output/i);
175
+
176
+ r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "wk-a", offset: 0, chunk: "hi\n" } });
177
+ assert.equal(note()?.getAttribute("data-terminal-note"), "none", "note cleared the instant the first frame is written");
178
+ assert.equal(note()?.textContent, "");
179
+ });
180
+
181
+ test("switching to a new stream re-arms the 'waiting' note (the prior stream's cleared note does not persist)", async () => {
182
+ const r = rig();
183
+ const cockpit = bootSupplyCockpit(r.env);
184
+ await cockpit.refresh();
185
+ const note = () => r.host.byClass("cockpit-terminal-note")[0];
186
+
187
+ cockpit.drill("wk-a");
188
+ r.sockets[0]?.fireOpen();
189
+ r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "wk-a", offset: 0, chunk: "x" } });
190
+ assert.equal(note()?.getAttribute("data-terminal-note"), "none");
191
+
192
+ cockpit.drill("wk-b");
193
+ assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "the new drill re-arms the waiting note");
194
+ });
195
+
149
196
  test("the terminal survives a list refresh — it is not re-mounted and keeps streaming", async () => {
150
197
  const r = rig();
151
198
  const cockpit = bootSupplyCockpit(r.env);
@@ -134,6 +134,7 @@ class SupplyCockpit implements SupplyCockpitHandle {
134
134
  readonly #pastRegion: ElementLike | undefined;
135
135
  readonly #terminalHost: ElementLike;
136
136
  readonly #terminalTitle: ElementLike;
137
+ readonly #terminalNote: ElementLike;
137
138
  readonly #terminalPanel: ElementLike;
138
139
  readonly #refreshMs: number;
139
140
  readonly #pastFetchTimeoutMs: number;
@@ -246,6 +247,14 @@ class SupplyCockpit implements SupplyCockpitHandle {
246
247
  this.#terminalHost.className = "cockpit-terminal-host";
247
248
  this.#terminalHost.setAttribute("data-terminal", "host");
248
249
  this.#terminalPanel.appendChild(this.#terminalHost);
250
+ // A status note under the terminal, shown while a LIVE drill has connected but no output has
251
+ // arrived yet (a quiet job between frames): without it the panel is an indistinguishable blank
252
+ // black rectangle, so the operator can't tell "connected, waiting" from "broken". Cleared the
253
+ // instant the first frame is written, and on every mode change.
254
+ this.#terminalNote = env.doc.createElement("p");
255
+ this.#terminalNote.className = "cockpit-terminal-note";
256
+ this.#terminalNote.setAttribute("data-terminal-note", "none");
257
+ this.#terminalPanel.appendChild(this.#terminalNote);
249
258
  shell.appendChild(this.#listRegion);
250
259
  if (this.#pastRegion !== undefined) shell.appendChild(this.#pastRegion);
251
260
  shell.appendChild(this.#terminalPanel);
@@ -272,6 +281,20 @@ class SupplyCockpit implements SupplyCockpitHandle {
272
281
  if (mode === "live") this.#terminalTitle.textContent = "Worker terminal — live";
273
282
  else if (mode === "replay") this.#terminalTitle.textContent = "Worker terminal — replay (past session)";
274
283
  else this.#terminalTitle.textContent = "Worker terminal";
284
+ // Any mode change replaces what's behind the panel, so the prior "waiting for output" note is
285
+ // stale — clear it. A live drill re-arms it (below) once its fresh terminal is mounted.
286
+ this.#setNote(undefined);
287
+ }
288
+
289
+ /** Show (or clear) the terminal status note — the "connected, waiting for output" affordance. */
290
+ #setNote(text: string | undefined): void {
291
+ if (text === undefined) {
292
+ this.#terminalNote.textContent = "";
293
+ this.#terminalNote.setAttribute("data-terminal-note", "none");
294
+ return;
295
+ }
296
+ this.#terminalNote.textContent = text;
297
+ this.#terminalNote.setAttribute("data-terminal-note", "waiting");
275
298
  }
276
299
 
277
300
  async refresh(): Promise<void> {
@@ -455,8 +478,24 @@ class SupplyCockpit implements SupplyCockpitHandle {
455
478
  try {
456
479
  // Fresh terminal for the newly selected worker.
457
480
  this.#terminalHost.replaceChildren();
458
- const sink = this.#env.createTerminal(this.#terminalHost);
459
- this.#terminal = sink;
481
+ const rawSink = this.#env.createTerminal(this.#terminalHost);
482
+ this.#terminal = rawSink;
483
+
484
+ // Wrap the sink so the FIRST byte of live output clears the "waiting for output" note. A drill
485
+ // that connects to a quiet stream (a job between frames) otherwise shows a blank panel with no
486
+ // signal it is working; the note stays until output flows, then vanishes on the first write.
487
+ // Only `write` is proxied — TerminalSession never disposes the sink (teardown goes through
488
+ // #terminal, which holds the raw sink), so the wrapper needs nothing else.
489
+ let cleared = false;
490
+ const sink: TerminalSink = {
491
+ write: (chunk) => {
492
+ if (!cleared) {
493
+ cleared = true;
494
+ this.#setNote(undefined);
495
+ }
496
+ rawSink.write(chunk);
497
+ },
498
+ };
460
499
 
461
500
  let session: TerminalSession | undefined;
462
501
  const client = new RelayChannelClient({
@@ -477,6 +516,9 @@ class SupplyCockpit implements SupplyCockpitHandle {
477
516
  client.open();
478
517
  this.#drill = { stream, client };
479
518
  this.#setMode("live", stream);
519
+ // Arm the "waiting for output" note (after #setMode, which clears it): shown until the first
520
+ // frame is written, so a connected-but-quiet stream reads as "waiting", not "broken".
521
+ this.#setNote("Connected — waiting for live output…");
480
522
  } catch (err) {
481
523
  // Building the new terminal failed AFTER the prior drill + terminal were already torn down
482
524
  // above. Leaving #mode/#shownStream at their prior value would keep the panel showing a stale
@@ -64,6 +64,27 @@ test("worker name buttons open the worker detail route and the inline terminal d
64
64
  assert.deepEqual(drilled, ["wk-a"]);
65
65
  });
66
66
 
67
+ test("suppresses the inline drill button for an IDLE worker (no current job → producerless stream)", () => {
68
+ const host = new FakeElement("body");
69
+ renderSupply(host, doc, supplyView(sample), { onDrill: () => {}, onOpenWorker: () => {} });
70
+
71
+ // wk-a holds job-1 → drillable; wk-b is idle (jobKeys: []) → no drill affordance.
72
+ const drills = host.byClass("cockpit-worker-drill");
73
+ assert.equal(drills.length, 1, "exactly one drill button — only for the worker with a live job");
74
+ assert.equal(drills[0]?.getAttribute("data-instance"), "wk-a");
75
+ // The idle worker is still openable via its name button; it just cannot be drilled.
76
+ const idleDrill = host.byData("worker", "wk-b")[0]?.byClass("cockpit-worker-drill") ?? [];
77
+ assert.equal(idleDrill.length, 0, "the idle worker row has no drill button");
78
+ assert.ok(
79
+ host.byClass("cockpit-worker").find((b) => b.getAttribute("data-instance") === "wk-b"),
80
+ "the idle worker still has its name button (detail page remains reachable)",
81
+ );
82
+ // The idle worker's name button carries `data-stream` unconditionally — same as the drillable
83
+ // worker and the browser mirror — even though its drill affordance is suppressed.
84
+ const idleName = host.byClass("cockpit-worker").find((b) => b.getAttribute("data-instance") === "wk-b");
85
+ assert.equal(idleName?.getAttribute("data-stream"), "wk-b", "idle worker's name button still carries data-stream");
86
+ });
87
+
67
88
  test("does NOT render any demand matrix, missing-agent reds, or diversity light", () => {
68
89
  const host = new FakeElement("body");
69
90
  renderSupply(host, doc, supplyView(sample));
@@ -57,21 +57,31 @@ function workerRow(doc: DocumentLike, worker: SupplyWorkerView, options: RenderS
57
57
  const button = el(doc, "button", "cockpit-worker", worker.instance);
58
58
  button.setAttribute("type", "button");
59
59
  button.setAttribute("data-instance", worker.instance);
60
+ // The name button always carries `data-stream` — even for an idle (non-drillable) worker — matching
61
+ // the row's own `data-stream` and the browser mirror (`pages/cockpit/mount.js`). Only the inline
62
+ // drill affordance is gated by `drillable`; the stream identity of the worker is not.
63
+ button.setAttribute("data-stream", worker.stream);
60
64
  const onOpenWorker = options.onOpenWorker;
65
+ const onDrill = options.onDrill;
61
66
  if (onOpenWorker !== undefined) {
62
67
  button.addEventListener("click", () => onOpenWorker(worker.instance));
63
68
  }
64
69
  nameCell.appendChild(button);
65
- const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
66
- drill.setAttribute("type", "button");
67
- drill.setAttribute("data-instance", worker.instance);
68
- drill.setAttribute("data-stream", worker.stream);
69
- button.setAttribute("data-stream", worker.stream);
70
- const onDrill = options.onDrill;
71
- if (onDrill !== undefined) {
72
- drill.addEventListener("click", () => onDrill(worker.stream));
70
+ // The inline live-terminal drill ONLY for a worker that currently holds a job. An idle worker's
71
+ // `stream` is its bare instance id, which no producer writes to, so drilling it would open a
72
+ // permanently blank "live" terminal (the H6 blank-terminal defect). Suppress the affordance
73
+ // entirely when there is nothing live to stream; the operator can still open the worker's detail
74
+ // page (and its captured past sessions) via the name button.
75
+ if (worker.drillable) {
76
+ const drill = el(doc, "button", "cockpit-worker-drill", "terminal");
77
+ drill.setAttribute("type", "button");
78
+ drill.setAttribute("data-instance", worker.instance);
79
+ drill.setAttribute("data-stream", worker.stream);
80
+ if (onDrill !== undefined) {
81
+ drill.addEventListener("click", () => onDrill(worker.stream));
82
+ }
83
+ nameCell.appendChild(drill);
73
84
  }
74
- nameCell.appendChild(drill);
75
85
  row.appendChild(nameCell);
76
86
 
77
87
  row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
@@ -46,6 +46,21 @@ test("defaults absent family/host to a stable dash and counts + sorts jobKeys",
46
46
  assert.equal(w?.jobs, 2);
47
47
  });
48
48
 
49
+ test("marks a worker drillable iff it currently holds a job (an idle worker has no live stream)", () => {
50
+ const view = supplyView(
51
+ report({
52
+ workers: [
53
+ { instance: "busy", identity: "t", stream: "job:9", jobKeys: ["9"], live: true, staleMs: 0 },
54
+ { instance: "idle", identity: "t", stream: "idle", jobKeys: [], live: true, staleMs: 0 },
55
+ ],
56
+ }),
57
+ );
58
+ const busy = view.workers.find((w) => w.instance === "busy");
59
+ const idle = view.workers.find((w) => w.instance === "idle");
60
+ assert.equal(busy?.drillable, true, "a worker with a current job has a live terminal to drill");
61
+ assert.equal(idle?.drillable, false, "an idle worker's instance stream has no producer — not drillable");
62
+ });
63
+
49
64
  test("sorts leaves by token and workers by instance, with per-leaf live counts", () => {
50
65
  const view = supplyView(
51
66
  report({
@@ -111,6 +111,14 @@ export interface SupplyWorkerView {
111
111
  readonly jobKeys: readonly string[];
112
112
  /** The number of current jobs. */
113
113
  readonly jobs: number;
114
+ /**
115
+ * Whether this worker has a LIVE terminal to drill into. True only while it holds a current job:
116
+ * a worker relays its terminal on the jobKey-scoped `job:<jobKey>` stream, and the supply endpoint
117
+ * repoints {@link stream} at it. An IDLE worker (no jobs) has its `stream` default back to the bare
118
+ * instance id — a stream NO producer ever writes to — so drilling it opens a permanently blank
119
+ * "live" terminal. The renderer suppresses the drill affordance when this is false.
120
+ */
121
+ readonly drillable: boolean;
114
122
  /**
115
123
  * The engine context for each of this worker's current jobs (H6), sorted by jobKey — so the operator
116
124
  * sees which process instance / plan the terminal belongs to. Empty when nothing correlates.
@@ -200,6 +208,7 @@ function workerView(
200
208
  host: worker.host ?? "—",
201
209
  jobKeys,
202
210
  jobs: jobKeys.length,
211
+ drillable: jobKeys.length > 0,
203
212
  correlations,
204
213
  liveness: liveness(worker, staleAfterMs),
205
214
  staleMs: worker.staleMs,
@@ -355,3 +355,282 @@ test("invalid-fact-type: an emitted fact missing its `type` is rejected", () =>
355
355
  const err = hasCode(errors, "invalid-fact-type");
356
356
  assertEquals(err.path, "nodes[0].emits[0].type");
357
357
  });
358
+
359
+ // ── S7: guarded (conditional) edges — the exclusive-gateway extension (ADR 0005 S7) ────────────────
360
+ // A guarded split node emits a scalar outcome fact and routes on it: exactly one out-edge's `when`
361
+ // value matches at runtime (or the `default` else-branch fires). These cases exercise the new
362
+ // validation surface — guard shape, scalar-fact resolution, exhaustiveness, no-mixing, and the
363
+ // exclusive-merge parity the compiler relies on.
364
+
365
+ /** Mode A (adopt), the ADR's motivating guarded split: `bump` emits a scalar `result`; a guard routes
366
+ * the breaking outcome through `migrate`, the default (green) straight to `release`, and the branches
367
+ * re-converge at `release` (an exclusive merge). Exhaustive via its `default`. */
368
+ const GUARDED_ADOPT = {
369
+ name: "adopt",
370
+ nodes: [
371
+ { id: "bump", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "result", type: "string" }] },
372
+ { id: "migrate", kind: "agent", agent: { jobType: "senior:feature" } },
373
+ { id: "release", kind: "connector", connector: { target: "npm:publish" } },
374
+ ],
375
+ edges: [
376
+ { from: "bump", to: "migrate", when: "bump.result", equals: "breaking" },
377
+ { from: "bump", to: "release", default: true },
378
+ { from: "migrate", to: "release" },
379
+ ],
380
+ };
381
+
382
+ test("S7 happy: a well-formed guarded split (with a default) validates with no errors", () => {
383
+ assertEquals(validateDeliveryGraph(GUARDED_ADOPT), []);
384
+ });
385
+
386
+ test("S7 happy: a boolean fact guarded on BOTH values is exhaustive without a default", () => {
387
+ const errors = validateDeliveryGraph({
388
+ nodes: [
389
+ { id: "gate", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "ok", type: "boolean" }] },
390
+ { id: "yes", kind: "agent", agent: { jobType: "j" } },
391
+ { id: "no", kind: "agent", agent: { jobType: "j" } },
392
+ ],
393
+ edges: [
394
+ { from: "gate", to: "yes", when: "gate.ok", equals: true },
395
+ { from: "gate", to: "no", when: "gate.ok", equals: false },
396
+ ],
397
+ });
398
+ assertEquals(errors, []);
399
+ });
400
+
401
+ test("S7 non-exhaustive-split: a string guard without a default is rejected", () => {
402
+ const errors = validateDeliveryGraph({
403
+ nodes: [
404
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
405
+ { id: "migrate", kind: "agent", agent: { jobType: "j" } },
406
+ ],
407
+ edges: [{ from: "bump", to: "migrate", when: "bump.result", equals: "breaking" }],
408
+ });
409
+ hasCode(errors, "non-exhaustive-split");
410
+ });
411
+
412
+ test("S7 mixed-fan-out: a node whose out-edges MIX a guard with a plain edge is rejected", () => {
413
+ const errors = validateDeliveryGraph({
414
+ nodes: [
415
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
416
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
417
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
418
+ ],
419
+ edges: [
420
+ { from: "bump", to: "a", when: "bump.result", equals: "x" },
421
+ { from: "bump", to: "b" },
422
+ ],
423
+ });
424
+ hasCode(errors, "mixed-fan-out");
425
+ });
426
+
427
+ test("S7 bad-when: a guard on an UNDECLARED fact is rejected", () => {
428
+ const errors = validateDeliveryGraph({
429
+ nodes: [
430
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
431
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
432
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
433
+ ],
434
+ edges: [
435
+ { from: "bump", to: "a", when: "bump.nope", equals: "x" },
436
+ { from: "bump", to: "b", default: true },
437
+ ],
438
+ });
439
+ const err = hasCode(errors, "bad-when");
440
+ assertEquals(err.path, "edges[0].when");
441
+ });
442
+
443
+ test("S7 bad-when: a guard on a NON-SCALAR (artifact) fact is rejected", () => {
444
+ const errors = validateDeliveryGraph({
445
+ nodes: [
446
+ { id: "bump", kind: "human", human: {}, emits: [{ name: "art", type: "artifact" }] },
447
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
448
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
449
+ ],
450
+ edges: [
451
+ { from: "bump", to: "a", when: "bump.art", equals: "x" },
452
+ { from: "bump", to: "b", default: true },
453
+ ],
454
+ });
455
+ hasCode(errors, "bad-when");
456
+ });
457
+
458
+ test("S7 bad-when: a guard referencing a fact of a DIFFERENT node than the edge producer is rejected", () => {
459
+ const errors = validateDeliveryGraph({
460
+ nodes: [
461
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
462
+ { id: "other", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
463
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
464
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
465
+ ],
466
+ edges: [
467
+ { from: "bump", to: "a", when: "other.result", equals: "x" },
468
+ { from: "bump", to: "b", default: true },
469
+ ],
470
+ });
471
+ hasCode(errors, "bad-when");
472
+ });
473
+
474
+ test("S7 guard-missing-equals: `when` without `equals` is rejected", () => {
475
+ const errors = validateDeliveryGraph({
476
+ nodes: [
477
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
478
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
479
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
480
+ ],
481
+ edges: [
482
+ { from: "bump", to: "a", when: "bump.result" },
483
+ { from: "bump", to: "b", default: true },
484
+ ],
485
+ });
486
+ hasCode(errors, "guard-missing-equals");
487
+ });
488
+
489
+ test("S7 guard-type-mismatch: an `equals` whose type differs from the fact's declared type is rejected", () => {
490
+ const errors = validateDeliveryGraph({
491
+ nodes: [
492
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "n", type: "number" }] },
493
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
494
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
495
+ ],
496
+ edges: [
497
+ { from: "bump", to: "a", when: "bump.n", equals: "not-a-number" },
498
+ { from: "bump", to: "b", default: true },
499
+ ],
500
+ });
501
+ hasCode(errors, "guard-type-mismatch");
502
+ });
503
+
504
+ test("S7 guard-default-conflict: an edge with both `default` and `when` is rejected", () => {
505
+ const errors = validateDeliveryGraph({
506
+ nodes: [
507
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
508
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
509
+ ],
510
+ edges: [{ from: "bump", to: "a", when: "bump.result", equals: "x", default: true }],
511
+ });
512
+ hasCode(errors, "guard-default-conflict");
513
+ });
514
+
515
+ test("S7 multiple-defaults: more than one `default` out-edge on a split is rejected", () => {
516
+ const errors = validateDeliveryGraph({
517
+ nodes: [
518
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
519
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
520
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
521
+ { id: "c", kind: "agent", agent: { jobType: "j" } },
522
+ ],
523
+ edges: [
524
+ { from: "bump", to: "a", when: "bump.result", equals: "x" },
525
+ { from: "bump", to: "b", default: true },
526
+ { from: "bump", to: "c", default: true },
527
+ ],
528
+ });
529
+ hasCode(errors, "multiple-defaults");
530
+ });
531
+
532
+ test("S7 exclusive-merge-parity: a parallel AND-join fed by an exclusive-split branch is rejected (the deadlock shape)", () => {
533
+ // `indep` always fires; `x` fires only on the "a" branch of `split`. A plain fan-in of {indep, x}
534
+ // into `sink` would be a parallel AND-join that waits forever for `x` when the else-branch is taken.
535
+ const errors = validateDeliveryGraph({
536
+ nodes: [
537
+ { id: "split", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
538
+ { id: "x", kind: "agent", agent: { jobType: "j" } },
539
+ { id: "y", kind: "agent", agent: { jobType: "j" } },
540
+ { id: "indep", kind: "agent", agent: { jobType: "j" } },
541
+ { id: "sink", kind: "agent", agent: { jobType: "j" } },
542
+ ],
543
+ edges: [
544
+ { from: "split", to: "x", when: "split.result", equals: "a" },
545
+ { from: "split", to: "y", default: true },
546
+ { from: "x", to: "sink" },
547
+ { from: "indep", to: "sink" },
548
+ ],
549
+ });
550
+ hasCode(errors, "exclusive-merge-parity");
551
+ });
552
+
553
+ test("S7 default:false is not a default — a `default: false` sibling of a guard is a plain edge and MIXES the fan-out", () => {
554
+ // `default` is a flag: only `true` marks the else-branch. `default: false` must NOT be treated as
555
+ // present (else it silently escapes both the guarded and the plain classification and bypasses the
556
+ // no-mixing rule). Here it must fall through to `plain` and trip mixed-fan-out.
557
+ const errors = validateDeliveryGraph({
558
+ nodes: [
559
+ { id: "bump", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
560
+ { id: "a", kind: "agent", agent: { jobType: "j" } },
561
+ { id: "b", kind: "agent", agent: { jobType: "j" } },
562
+ ],
563
+ edges: [
564
+ { from: "bump", to: "a", when: "bump.result", equals: "x" },
565
+ { from: "bump", to: "b", default: false },
566
+ ],
567
+ });
568
+ hasCode(errors, "mixed-fan-out");
569
+ });
570
+
571
+ test("S7 exclusive-merge-parity: terminal nodes that MIX a conditional tail with an always-firing tail are rejected (the End-sink deadlock/double-fire shape)", () => {
572
+ // `split` fans an exhaustive XOR to two leaves (`cond`/`other` — exactly one fires); `indep` always
573
+ // fires. All three are graph leaves, so the End sink joins them. A parallel join there deadlocks on
574
+ // the untaken branch; an exclusive merge double-fires when both `indep` and a branch arrive. The
575
+ // validator must reject the mix so the compiler's End-gateway choice is sound.
576
+ const errors = validateDeliveryGraph({
577
+ nodes: [
578
+ { id: "split", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
579
+ { id: "cond", kind: "agent", agent: { jobType: "j" } },
580
+ { id: "other", kind: "agent", agent: { jobType: "j" } },
581
+ { id: "feed", kind: "agent", agent: { jobType: "j" } },
582
+ { id: "indep", kind: "agent", agent: { jobType: "j" } },
583
+ ],
584
+ edges: [
585
+ { from: "split", to: "cond", when: "split.result", equals: "a" },
586
+ { from: "split", to: "other", default: true },
587
+ { from: "feed", to: "indep" },
588
+ ],
589
+ });
590
+ hasCode(errors, "exclusive-merge-parity");
591
+ });
592
+
593
+ test("S7 default-only node is NOT an exclusive split — a lone `default: true` out-edge always fires and must not mark downstream leaves conditional", () => {
594
+ // `fork` unconditionally fans to `p` and `q` (a parallel fork). `q` has a SINGLE out-edge marked
595
+ // `default: true` with no guarded `when` sibling — semantically that edge always fires, so `q` is
596
+ // NOT an exclusive split. Leaves {p, z} are both always-firing and join cleanly at the End sink.
597
+ // Deriving `splitNodes` from `when`-guarded edges only (not a lone `default`) keeps `q` off the
598
+ // split set; treating a default-only node as a split spuriously marks `z` conditional and trips a
599
+ // false End-sink exclusive-merge-parity error.
600
+ const errors = validateDeliveryGraph({
601
+ nodes: [
602
+ { id: "fork", kind: "agent", agent: { jobType: "j" } },
603
+ { id: "p", kind: "agent", agent: { jobType: "j" } },
604
+ { id: "q", kind: "agent", agent: { jobType: "j" } },
605
+ { id: "z", kind: "agent", agent: { jobType: "j" } },
606
+ ],
607
+ edges: [
608
+ { from: "fork", to: "p" },
609
+ { from: "fork", to: "q" },
610
+ { from: "q", to: "z", default: true },
611
+ ],
612
+ });
613
+ assertEquals(errors, []);
614
+ });
615
+
616
+ test("S7 single-target guarded fan-out is NOT an exclusive split — a node whose guarded + default edges all converge on ONE downstream node has no real fan-out and must not mark that node conditional", () => {
617
+ // `gate` has a guarded edge and a `default` edge that BOTH target `conv` — one distinct downstream
618
+ // node, so there is no structural fan-out: `conv` fires whenever `gate` does. Deriving `splitNodes`
619
+ // from "has a guarded edge" alone wrongly adds `gate` to the split set, marking `conv` conditional;
620
+ // then leaves {conv, indep} look like a mixed conditional/always-firing End sink and trip a false
621
+ // exclusive-merge-parity error. A split must fan to >=2 distinct targets to be exclusive.
622
+ const errors = validateDeliveryGraph({
623
+ nodes: [
624
+ { id: "gate", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "result", type: "string" }] },
625
+ { id: "conv", kind: "agent", agent: { jobType: "j" } },
626
+ { id: "feed", kind: "agent", agent: { jobType: "j" } },
627
+ { id: "indep", kind: "agent", agent: { jobType: "j" } },
628
+ ],
629
+ edges: [
630
+ { from: "gate", to: "conv", when: "gate.result", equals: "a" },
631
+ { from: "gate", to: "conv", default: true },
632
+ { from: "feed", to: "indep" },
633
+ ],
634
+ });
635
+ assertEquals(errors, []);
636
+ });