@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 +16 -0
- package/app/agentic/cockpit/supply-boot.test.ts +49 -2
- package/app/agentic/cockpit/supply-boot.ts +44 -2
- package/app/agentic/cockpit/supply-render.test.ts +21 -0
- package/app/agentic/cockpit/supply-render.ts +19 -9
- package/app/agentic/cockpit/supply-view.test.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +9 -0
- package/app/deliveryGraph.test.ts +279 -0
- package/app/deliveryGraph.ts +381 -2
- package/app/deliveryGraphCompiler.test.ts +166 -0
- package/app/deliveryGraphCompiler.ts +279 -52
- package/app/deliveryGraphDeploy.test.ts +148 -0
- package/app/deliveryRunner.test.ts +35 -1
- package/app/deliveryRunner.ts +13 -3
- package/docs/adr/0005-agent-authored-delivery-graphs.md +25 -0
- package/openapi.yaml +43 -1
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +12 -0
- package/pages/cockpit/mount.js +47 -8
- package/resources/forms/delivery-human-generic.form +24 -0
- package/resources/processes/delivery-human.bpmn +4 -0
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") === "
|
|
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, "
|
|
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
|
|
459
|
-
this.#terminal =
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
drill.
|
|
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
|
+
});
|