@nanobpm/nano-workforce 0.123.2 → 0.125.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDispatch.test.ts +143 -0
  4. package/app/deliveryGraphDispatch.ts +168 -0
  5. package/app/deliveryGraphProposals.test.ts +267 -0
  6. package/app/deliveryGraphProposals.ts +269 -0
  7. package/app/deliveryGraphRun.test.ts +6 -52
  8. package/app/deliveryGraphRun.ts +21 -76
  9. package/app/deliveryGraphText.ts +3 -3
  10. package/app/deliveryRunner.ts +4 -3
  11. package/app/featureReadModel.test.ts +97 -54
  12. package/app/featureReadModel.ts +152 -0
  13. package/app/service.ts +15 -0
  14. package/app/stage.ts +71 -77
  15. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  16. package/db/migrations/076_feature_read_model_declare_once.sql +53 -0
  17. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  18. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  19. package/docs/agent-guide.md +50 -58
  20. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  21. package/openapi.yaml +118 -161
  22. package/operations/compileDeliveryGraph.test.ts +100 -37
  23. package/operations/compileDeliveryGraph.ts +64 -18
  24. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  25. package/operations/dispatchDeliveryGraph.ts +79 -99
  26. package/operations/getAgentInstructions.test.ts +10 -6
  27. package/operations/previewDeliveryGraph.test.ts +90 -51
  28. package/operations/previewDeliveryGraph.ts +45 -18
  29. package/package.json +2 -2
  30. package/pages/cockpit/mount.js +19 -12
  31. package/pages/delivery-graphs/mount.js +37 -137
  32. package/pages/delivery-graphs.page.json +50 -3
  33. package/scripts/check-migrations.test.ts +31 -0
  34. package/scripts/check-migrations.ts +40 -6
  35. package/test/cockpit-embed-endpoints.test.ts +59 -36
  36. package/test/delivery-graphs-embed.test.ts +36 -34
  37. package/e2e/delivery-graph-start.e2e.ts +0 -145
  38. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  39. package/operations/startDeliveryGraph.ts +0 -222
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ # [0.125.0](https://github.com/nanobpm/nano-workforce/compare/v0.124.0...v0.125.0) (2026-08-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * **read-model:** declare feature_read_model once via Urban defineReadModel (ADR-0065 step 2) ([#472](https://github.com/nanobpm/nano-workforce/issues/472)) ([6424efb](https://github.com/nanobpm/nano-workforce/commit/6424efbeabec60366567e218f12edd996bb96780)), closes [#422](https://github.com/nanobpm/nano-workforce/issues/422)
7
+
8
+ # [0.124.0](https://github.com/nanobpm/nano-workforce/compare/v0.123.2...v0.124.0) (2026-08-23)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **cockpit:** anchor supply/transcript endpoints to import.meta.url so the shell finds the API ([#468](https://github.com/nanobpm/nano-workforce/issues/468)) ([4ff4b99](https://github.com/nanobpm/nano-workforce/commit/4ff4b99767b915902be19cdecd5973c1ce6efc4c)), closes [279/#280](https://github.com/nanobpm/nano-workforce/issues/280) [#467](https://github.com/nanobpm/nano-workforce/issues/467)
14
+ * **migrations:** grandfather prefix 075 collision to unblock main + releases ([#471](https://github.com/nanobpm/nano-workforce/issues/471)) ([508194e](https://github.com/nanobpm/nano-workforce/commit/508194e7be0f58ba3f12d2b67d4d6212d243c609)), closes [#458](https://github.com/nanobpm/nano-workforce/issues/458) [460/#463](https://github.com/nanobpm/nano-workforce/issues/463) [#357](https://github.com/nanobpm/nano-workforce/issues/357) [#470](https://github.com/nanobpm/nano-workforce/issues/470)
15
+
16
+
17
+ ### Features
18
+
19
+ * **delivery-graph:** make dispatch operator-only ([#460](https://github.com/nanobpm/nano-workforce/issues/460)) ([#463](https://github.com/nanobpm/nano-workforce/issues/463)) ([cd6d2b8](https://github.com/nanobpm/nano-workforce/commit/cd6d2b819b32c5c7daf047dbb6e077a26d4b09e6))
20
+
1
21
  ## [0.123.2](https://github.com/nanobpm/nano-workforce/compare/v0.123.1...v0.123.2) (2026-08-22)
2
22
 
3
23
 
package/README.md CHANGED
@@ -254,11 +254,15 @@ run an **arbitrary, heterogeneous, cross-repo, partly-human delivery graph** —
254
254
  the just-published version*. You author the graph as **JSON over a closed node vocabulary**
255
255
  (`agent` | `wait` | `human` | `connector`) — never BPMN or code — whose edges are
256
256
  **discovered facts** (`from: "<node>.<fact>"`). A deterministic compiler turns it into an
257
- engine-native process; a human approves the rendered preview before any side effect runs.
258
-
259
- Two doors: a **pure** `POST /app/api/actions/compile-delivery-graph` (validate + preview,
260
- side-effect-freehammer it while drafting) and a **gated, idempotent**
261
- `POST /app/api/actions/start/delivery-graph` (approve dispatch).
257
+ engine-native process; a human previews the staged proposal and dispatches it from the
258
+ cockpit before any side effect runs.
259
+
260
+ Two doors and the second is **operator-only** (issue #460): a
261
+ `POST /app/api/actions/compile-delivery-graph` on the agent surface that validates + previews
262
+ the graph and **stages** it as a proposal (the agent surface ends there — no run key or token
263
+ comes back), and a `POST /app/api/actions/delivery-graph/dispatch` the **cockpit** uses to
264
+ dispatch a staged proposal by its content `digest` (idempotent, at-most-once). There is no
265
+ agent `start` door to replay — the human clicking Dispatch in the cockpit is the approval.
262
266
 
263
267
  The agent guide served at `GET /app/api/agent` documents the full vocabulary, both
264
268
  operation contracts, and a complete worked example — see §9 there, or point your coding
@@ -0,0 +1,143 @@
1
+ // Unit coverage for `dispatchDeliveryGraphRun` (app/deliveryGraphDispatch.ts) — the retained
2
+ // delivery-graph DISPATCH core extracted out of the removed agent `start` door (ADR 0005 Decision 7,
3
+ // issue #460). It has NO approval gate: the authorization lives in the fact that only the cockpit
4
+ // dispatch seam reaches this code (never the agent surface). What it DOES keep is the durable
5
+ // at-most-once launch fence + idempotency short-circuit, so a double-dispatch never double-launches a
6
+ // graph's side effects. These tests drive it against an in-memory app/data/engine faithful to the run
7
+ // aggregate's PRIMARY KEY fence and the guarded raw UPDATE the claim issues.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import type { AppApi } from "@nanobpm/urban";
11
+ import { dispatchDeliveryGraphRun } from "./deliveryGraphDispatch.ts";
12
+ import { noopLog } from "../test/log.ts";
13
+
14
+ function makeApp() {
15
+ const tables = new Map<string, Record<string, unknown>[]>();
16
+ const started: { processDefinitionId: string }[] = [];
17
+ const table = (name: string, key: string) => {
18
+ const rows =
19
+ tables.get(name) ??
20
+ (() => {
21
+ const fresh: Record<string, unknown>[] = [];
22
+ tables.set(name, fresh);
23
+ return fresh;
24
+ })();
25
+ return {
26
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
27
+ find: (q: Record<string, unknown>) =>
28
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
29
+ all: () => Promise.resolve([...rows]),
30
+ insert: (r: Record<string, unknown>) => {
31
+ if (rows.some((existing) => existing[key] === r[key])) {
32
+ return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
33
+ }
34
+ rows.push(r);
35
+ return Promise.resolve(r);
36
+ },
37
+ update: (k: unknown, patch: Record<string, unknown>) => {
38
+ const row = rows.find((r) => r[key] === k);
39
+ if (row) Object.assign(row, patch);
40
+ return Promise.resolve(row);
41
+ },
42
+ delete: (k: unknown) => {
43
+ const i = rows.findIndex((r) => r[key] === k);
44
+ if (i >= 0) rows.splice(i, 1);
45
+ return Promise.resolve();
46
+ },
47
+ };
48
+ };
49
+ const app = {
50
+ data: {
51
+ table,
52
+ open: () => ({
53
+ exec: (sql: string, params: unknown[]) =>
54
+ Promise.resolve().then(() => {
55
+ const cols = [...sql.matchAll(/"(\w+)"\s*=\s*\?/g)].map((m) => m[1]);
56
+ const runKey = params[params.length - 1];
57
+ const rows = tables.get("delivery_graph_runs") ?? [];
58
+ const row = rows.find((r) => r["run_key"] === runKey);
59
+ if (row && row["status"] !== "running") {
60
+ for (let i = 0; i < cols.length - 1; i++) row[cols[i]] = params[i];
61
+ return { changed: 1 };
62
+ }
63
+ return { changed: 0 };
64
+ }),
65
+ }),
66
+ },
67
+ engine: {
68
+ deployResources: () => Promise.resolve([]),
69
+ createInstance: (req: { processDefinitionId: string }) => {
70
+ started.push(req);
71
+ return Promise.resolve({ processInstanceKey: "PI-1", processDefinitionId: req.processDefinitionId });
72
+ },
73
+ },
74
+ log: noopLog(),
75
+ } as unknown as AppApi;
76
+ return { app, started, runs: () => tables.get("delivery_graph_runs") ?? [] };
77
+ }
78
+
79
+ const HUMAN_ONLY = {
80
+ name: "manual gate",
81
+ nodes: [{ id: "ack", kind: "human", human: { prompt: "click done" } }],
82
+ };
83
+ const SIDE_EFFECTING = {
84
+ name: "release runbook",
85
+ nodes: [
86
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "merge #B" } },
87
+ { id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" } },
88
+ ],
89
+ edges: [{ from: "open-b", to: "publish" }],
90
+ };
91
+
92
+ test("dispatchDeliveryGraphRun: a human-only graph launches straight away (running), one engine instance", async () => {
93
+ const { app, started, runs } = makeApp();
94
+ const res = await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
95
+ assertEquals(res.ok, true);
96
+ if (!res.ok) return;
97
+ assertEquals(res.status, "running");
98
+ assertEquals(res.alreadyRunning, false);
99
+ assertEquals(res.sideEffecting, false);
100
+ assertEquals(started.length, 1);
101
+ assertEquals(runs()[0].status, "running");
102
+ });
103
+
104
+ test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approval token — the operator seam IS the approval", async () => {
105
+ const { app, started, runs } = makeApp();
106
+ const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
107
+ assertEquals(res.ok, true);
108
+ if (!res.ok) return;
109
+ assertEquals(res.status, "running");
110
+ assertEquals(res.sideEffecting, true);
111
+ assertEquals(started.length, 1);
112
+ assertEquals(runs()[0].status, "running");
113
+ });
114
+
115
+ test("dispatchDeliveryGraphRun: a re-dispatch of a still-running run short-circuits (alreadyRunning) — the side effect launches at most once", async () => {
116
+ const { app, started } = makeApp();
117
+ const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
118
+ assert(first.ok);
119
+ const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
120
+ assertEquals(second.ok, true);
121
+ if (!second.ok) return;
122
+ assertEquals(second.alreadyRunning, true);
123
+ assertEquals(started.length, 1); // never a second launch
124
+ });
125
+
126
+ test("dispatchDeliveryGraphRun: an explicit idempotency key forces a distinct run row", async () => {
127
+ const { app, started } = makeApp();
128
+ await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
129
+ await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { runKey: "second-run" });
130
+ assertEquals(started.length, 2);
131
+ });
132
+
133
+ test("dispatchDeliveryGraphRun: a malformed graph → ok:false with path-qualified errors, nothing launched", async () => {
134
+ const { app, started } = makeApp();
135
+ const res = await dispatchDeliveryGraphRun(app, { name: "empty", nodes: [] });
136
+ assertEquals(res.ok, false);
137
+ if (res.ok) return;
138
+ assert(Array.isArray(res.errors) && res.errors.length > 0);
139
+ for (const e of res.errors) {
140
+ assert(typeof e.path === "string" && typeof e.message === "string");
141
+ }
142
+ assertEquals(started.length, 0);
143
+ });
@@ -0,0 +1,168 @@
1
+ // app/deliveryGraphDispatch.ts — the delivery-graph DISPATCH core (ADR 0005 Decision 7, issue #460).
2
+ // This is the retained run-launch logic (compile → content-address → idempotent at-most-once claim →
3
+ // deploy+start the S4 runner → stamp the instance) extracted out of the removed agent `start` door so
4
+ // it is invoked ONLY from the cockpit dispatch action — never an agent-callable operation.
5
+ //
6
+ // The old `start` door gated dispatch on a REPLAYABLE `approvalToken` (the content digest handed back
7
+ // to the same caller), which let any holder of the API credential self-approve. Under issue #460 the
8
+ // human clicking Dispatch in the cockpit IS the approval — so there is NO approval gate here; the
9
+ // authorization lives in the fact that only the cockpit dispatch seam reaches this code, not the agent
10
+ // surface. What IS retained is the durable at-most-once launch fence (`claimRunForLaunch`) and the
11
+ // idempotency short-circuit, so a double-click or a re-dispatch never double-launches a graph's side
12
+ // effects.
13
+
14
+ import type { AppApi } from "@nanobpm/urban";
15
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
16
+ import { validateDeliveryGraph } from "./deliveryGraph.ts";
17
+ import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
18
+ import {
19
+ buildDeliveryGraphRunRow,
20
+ buildHumanLabels,
21
+ claimRunForLaunch,
22
+ computeRunKey,
23
+ DELIVERY_PHASE,
24
+ deliveryGraphRuns,
25
+ } from "./deliveryGraphRun.ts";
26
+ import { deliveryGraphDigest, runDeliveryGraph } from "./deliveryRunner.ts";
27
+
28
+ /** The outcome of a dispatch attempt — mirrors the retained run lifecycle. `ok:false` carries the
29
+ * path-qualified compile/validation errors (a staged graph should always recompile, but a corrupt
30
+ * stored graph is refused cleanly rather than throwing). */
31
+ export type DispatchDeliveryGraphResult =
32
+ | {
33
+ ok: true;
34
+ status: "running";
35
+ runKey: string;
36
+ digest: string;
37
+ sideEffecting: boolean;
38
+ alreadyRunning: boolean;
39
+ processInstanceKey?: string;
40
+ processDefinitionId?: string;
41
+ }
42
+ | { ok: false; errors: { path: string; message: string }[] };
43
+
44
+ /** Dispatch a delivery graph as a running engine-native process — the operator action. Re-validates
45
+ * and re-compiles the (already-staged) graph to derive its content digest + run-row shape, then
46
+ * launches it through the durable at-most-once fence. Idempotent: a re-dispatch of an already-running
47
+ * run short-circuits with `alreadyRunning` instead of double-launching. */
48
+ export async function dispatchDeliveryGraphRun(
49
+ app: Pick<AppApi, "data" | "engine" | "log">,
50
+ graph: unknown,
51
+ options: { runKey?: string | null; title?: string | null } = {},
52
+ ): Promise<DispatchDeliveryGraphResult> {
53
+ const validationErrors = validateDeliveryGraph(graph);
54
+ if (validationErrors.length > 0) {
55
+ return { ok: false, errors: validationErrors };
56
+ }
57
+ // The graph passed the semantic validator above, so it is safe to narrow to the typed contract.
58
+ // biome-ignore lint/plugin: validated external body narrowed to its contract after validateDeliveryGraph
59
+ const typedGraph = graph as DeliveryGraph;
60
+ const compiled = await compileDeliveryGraph(typedGraph);
61
+ if (!compiled.ok) {
62
+ return { ok: false, errors: compiled.errors };
63
+ }
64
+
65
+ const digest = deliveryGraphDigest(compiled.bpmn);
66
+ const runKey = computeRunKey(options.runKey, digest);
67
+ const sideEffecting = compiled.sideEffects.length > 0;
68
+ const explicitTitle = typeof options.title === "string" && options.title.trim() !== "" ? options.title.trim() : "";
69
+ const graphName = typeof typedGraph.name === "string" && typedGraph.name.trim() !== "" ? typedGraph.name.trim() : "";
70
+ const title = explicitTitle || graphName || runKey;
71
+ const runs = deliveryGraphRuns(app.data);
72
+
73
+ // Idempotency short-circuit — a re-dispatch onto a still-running run does NOT double-launch.
74
+ const existing = await runs.get(runKey);
75
+ if (existing && existing.status === "running") {
76
+ app.log.info("dispatch-delivery-graph short-circuit: already running", { runKey });
77
+ return {
78
+ ok: true,
79
+ status: "running",
80
+ runKey,
81
+ digest: existing.digest,
82
+ sideEffecting: existing.side_effecting === 1,
83
+ alreadyRunning: true,
84
+ processInstanceKey: existing.process_key ?? undefined,
85
+ processDefinitionId: existing.process_definition_id ?? undefined,
86
+ };
87
+ }
88
+
89
+ const rowBase = {
90
+ runKey,
91
+ digest,
92
+ sideEffecting,
93
+ nodeCount: compiled.resolved.nodes.length,
94
+ humanNodeCount: compiled.humanNodes.length,
95
+ sideEffectCount: compiled.sideEffects.length,
96
+ title,
97
+ humanLabels: buildHumanLabels(compiled),
98
+ createdAt: existing?.created_at,
99
+ };
100
+
101
+ // Claim the run durably BEFORE the side effect (the at-most-once dispatch fence). A losing racer
102
+ // never reaches `runDeliveryGraph` — it re-reads the winner's row and short-circuits.
103
+ const claim = buildDeliveryGraphRunRow({ ...rowBase, status: "running", phase: DELIVERY_PHASE.RUNNING, processKey: null });
104
+ const wonClaim = await claimRunForLaunch(app.data, Boolean(existing), claim);
105
+ if (!wonClaim) {
106
+ const won = await runs.get(runKey);
107
+ app.log.info("dispatch-delivery-graph short-circuit: launch claim raced a concurrent dispatch", { runKey });
108
+ return {
109
+ ok: true,
110
+ status: "running",
111
+ runKey,
112
+ digest: won?.digest ?? digest,
113
+ sideEffecting: won ? won.side_effecting === 1 : sideEffecting,
114
+ alreadyRunning: true,
115
+ processInstanceKey: won?.process_key ?? undefined,
116
+ processDefinitionId: won?.process_definition_id ?? undefined,
117
+ };
118
+ }
119
+ if (existing) {
120
+ const { run_key, created_at, ...patch } = claim;
121
+ await runs.update(runKey, patch);
122
+ }
123
+
124
+ // Launch — deploy + start the compiled definition. On ANY launch failure flip the claimed row to
125
+ // `failed` so no null-process_key `running` row is ever stranded.
126
+ const markClaimFailed = async () => {
127
+ const failed = buildDeliveryGraphRunRow({ ...rowBase, status: "failed", phase: DELIVERY_PHASE.FAILED, processKey: null });
128
+ const { run_key, created_at, ...patch } = failed;
129
+ await runs.update(runKey, patch);
130
+ };
131
+ let launched: Awaited<ReturnType<typeof runDeliveryGraph>>;
132
+ try {
133
+ launched = await runDeliveryGraph(app.engine, typedGraph, { runKey });
134
+ } catch (err) {
135
+ await markClaimFailed();
136
+ app.log.error("dispatch-delivery-graph launch threw", { runKey });
137
+ throw err;
138
+ }
139
+ if (!launched.ok) {
140
+ await markClaimFailed();
141
+ app.log.error("dispatch-delivery-graph launch failed", { runKey, count: launched.errors.length });
142
+ return { ok: false, errors: launched.errors };
143
+ }
144
+
145
+ // Stamp the started instance key onto the claimed row.
146
+ {
147
+ const running = buildDeliveryGraphRunRow({
148
+ ...rowBase,
149
+ status: "running",
150
+ phase: DELIVERY_PHASE.RUNNING,
151
+ processKey: launched.handle.processInstanceKey,
152
+ processDefinitionId: launched.handle.processDefinitionId,
153
+ });
154
+ const { run_key, created_at, ...patch } = running;
155
+ await runs.update(runKey, patch);
156
+ }
157
+ app.log.info("delivery graph dispatched", { runKey, processInstanceKey: launched.handle.processInstanceKey });
158
+ return {
159
+ ok: true,
160
+ status: "running",
161
+ runKey,
162
+ digest,
163
+ sideEffecting,
164
+ alreadyRunning: false,
165
+ processInstanceKey: launched.handle.processInstanceKey,
166
+ processDefinitionId: launched.handle.processDefinitionId,
167
+ };
168
+ }
@@ -0,0 +1,267 @@
1
+ // Unit coverage for the `staged` delivery-graph proposal aggregate (app/deliveryGraphProposals.ts,
2
+ // ADR 0005 Decision 7, issue #460). Two layers: the PURE helpers (logical key, TTL horizon, expiry,
3
+ // review-url, row builder) tested in isolation, and the I/O (`stageProposal` supersede-by-logical-key
4
+ // + idempotent re-stage; `getStagedProposal` staged-and-live gate; `markProposalDispatched`) exercised
5
+ // against the REAL provisioned SQLite data layer so the raw supersede UPDATE is validated, not modelled.
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ import { test } from "node:test";
10
+ import { assert, assertEquals } from "#test-assert";
11
+ import type { DataLayer } from "@nanobpm/urban";
12
+ import { bootTestApp } from "@nanobpm/urban-testkit";
13
+ import {
14
+ buildProposalRow,
15
+ DELIVERY_PROPOSAL_TTL_MS,
16
+ deliveryGraphProposals,
17
+ getStagedProposal,
18
+ isProposalExpired,
19
+ markProposalDispatched,
20
+ proposalExpiry,
21
+ proposalLogicalKey,
22
+ proposalReviewUrl,
23
+ stageProposal,
24
+ sweepExpiredProposals,
25
+ } from "./deliveryGraphProposals.ts";
26
+
27
+ const APP_ROOT = resolve(import.meta.dirname, "..");
28
+
29
+ async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
30
+ const dir = mkdtempSync(join(tmpdir(), "nwf-dgprop-"));
31
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
32
+ try {
33
+ await fn(app.db);
34
+ } finally {
35
+ await app.stop?.();
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ }
39
+
40
+ const row = (over: Partial<Parameters<typeof buildProposalRow>[0]> = {}) =>
41
+ buildProposalRow({
42
+ digest: "d1",
43
+ logicalKey: "runbook",
44
+ title: "runbook",
45
+ graphJson: JSON.stringify({ name: "runbook", nodes: [] }),
46
+ preview: { diagram: "flowchart", sideEffects: [], humanNodes: [] },
47
+ nodeCount: 1,
48
+ humanNodeCount: 0,
49
+ sideEffectCount: 0,
50
+ sideEffecting: false,
51
+ ...over,
52
+ });
53
+
54
+ // ── pure helpers ──────────────────────────────────────────────────────────────
55
+ test("proposalLogicalKey: a non-blank name wins; a blank/absent name falls back to the digest", () => {
56
+ assertEquals(proposalLogicalKey("runbook", "dX"), "runbook");
57
+ assertEquals(proposalLogicalKey(" runbook ", "dX"), "runbook");
58
+ assertEquals(proposalLogicalKey("", "dX"), "dX");
59
+ assertEquals(proposalLogicalKey(" ", "dX"), "dX");
60
+ assertEquals(proposalLogicalKey(null, "dX"), "dX");
61
+ assertEquals(proposalLogicalKey(undefined, "dX"), "dX");
62
+ });
63
+
64
+ test("proposalExpiry: is createdAt + TTL; a corrupt createdAt anchors to now", () => {
65
+ const created = "2024-01-01T00:00:00.000Z";
66
+ assertEquals(proposalExpiry(created), new Date(Date.parse(created) + DELIVERY_PROPOSAL_TTL_MS).toISOString());
67
+ const now = Date.now();
68
+ const fallback = Date.parse(proposalExpiry("not-a-date"));
69
+ assert(Math.abs(fallback - (now + DELIVERY_PROPOSAL_TTL_MS)) < 5000);
70
+ });
71
+
72
+ test("isProposalExpired: past → true, future → false, blank/corrupt → true (fail-closed)", () => {
73
+ const at = new Date("2024-06-01T00:00:00.000Z");
74
+ assertEquals(isProposalExpired("2024-05-31T23:59:59.000Z", at), true);
75
+ assertEquals(isProposalExpired("2024-06-01T00:00:01.000Z", at), false);
76
+ assertEquals(isProposalExpired(at.toISOString(), at), true); // at the horizon = expired
77
+ assertEquals(isProposalExpired(null, at), true);
78
+ assertEquals(isProposalExpired("", at), true);
79
+ assertEquals(isProposalExpired("garbage", at), true);
80
+ });
81
+
82
+ test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a dispatch endpoint", () => {
83
+ const url = proposalReviewUrl("abc123", "https://cockpit.example");
84
+ assertEquals(url, "https://cockpit.example/app/pages/delivery-graphs#proposal-abc123");
85
+ assert(!/\/actions\//.test(url), "reviewUrl points at a page, never an API action");
86
+ });
87
+
88
+ test("buildProposalRow: stamps status staged, boolean→0/1, and TTL from createdAt", () => {
89
+ const r = row({ sideEffecting: true, createdAt: "2024-01-01T00:00:00.000Z" });
90
+ assertEquals(r.status, "staged");
91
+ assertEquals(r.side_effecting, 1);
92
+ assertEquals(r.created_at, "2024-01-01T00:00:00.000Z");
93
+ assertEquals(r.expires_at, proposalExpiry("2024-01-01T00:00:00.000Z"));
94
+ });
95
+
96
+ // ── I/O: stage / supersede / get / dispatch ────────────────────────────────────
97
+ test("stageProposal: stages a proposal that getStagedProposal then returns as live", async () => {
98
+ await withData(async (data) => {
99
+ await stageProposal(data, row());
100
+ const live = await getStagedProposal(data, "d1");
101
+ assert(live);
102
+ assertEquals(live?.status, "staged");
103
+ });
104
+ });
105
+
106
+ test("stageProposal: a re-stage of an identical, STILL-LIVE digest is idempotent — one row, created_at (TTL anchor) preserved", async () => {
107
+ await withData(async (data) => {
108
+ const firstStage = new Date().toISOString(); // live: expires_at is in the future
109
+ await stageProposal(data, row({ createdAt: firstStage }));
110
+ await stageProposal(data, row({ createdAt: "2030-01-01T00:00:00.000Z" })); // a later re-stage
111
+ const rows = await deliveryGraphProposals(data).all();
112
+ assertEquals(rows.length, 1);
113
+ assertEquals(rows[0].created_at, firstStage); // first (live) stage wins the TTL anchor
114
+ });
115
+ });
116
+
117
+ test("stageProposal: a re-stage of an EXPIRED digest RE-ANCHORS the TTL so it is dispatchable again", async () => {
118
+ await withData(async (data) => {
119
+ // First stage long ago so its TTL has already elapsed (expires_at is in the past).
120
+ await stageProposal(data, row({ createdAt: "2024-01-01T00:00:00.000Z" }));
121
+ assertEquals(await getStagedProposal(data, "d1"), null); // aged out — not dispatchable
122
+
123
+ await stageProposal(data, row({ createdAt: "2030-01-01T00:00:00.000Z" })); // re-propose the same bytes
124
+ const rows = await deliveryGraphProposals(data).all();
125
+ assertEquals(rows.length, 1);
126
+ // The stale created_at must NOT be reused (that would keep expires_at in the past); the re-stage
127
+ // re-anchors the TTL to now, so the re-proposed digest is genuinely live and dispatchable again.
128
+ assert(!isProposalExpired(rows[0].expires_at), "re-staged expired proposal must have a future TTL");
129
+ const live = await getStagedProposal(data, "d1");
130
+ assert(live, "a re-staged (previously expired) proposal is dispatchable again");
131
+ assertEquals(live?.status, "staged");
132
+ });
133
+ });
134
+
135
+ test("stageProposal: a new digest for the SAME logical key supersedes the prior staged proposal", async () => {
136
+ await withData(async (data) => {
137
+ await stageProposal(data, row({ digest: "d1" }));
138
+ await stageProposal(data, row({ digest: "d2" })); // same logical_key "runbook", new digest
139
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "superseded");
140
+ assertEquals((await deliveryGraphProposals(data).get("d2"))?.status, "staged");
141
+ // The superseded digest is no longer live/dispatchable.
142
+ assertEquals(await getStagedProposal(data, "d1"), null);
143
+ assert(await getStagedProposal(data, "d2"));
144
+ });
145
+ });
146
+
147
+ test("stageProposal: proposals with DIFFERENT logical keys coexist — supersede is scoped per logical graph", async () => {
148
+ await withData(async (data) => {
149
+ await stageProposal(data, row({ digest: "d1", logicalKey: "runbook-a" }));
150
+ await stageProposal(data, row({ digest: "d2", logicalKey: "runbook-b" }));
151
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "staged");
152
+ assertEquals((await deliveryGraphProposals(data).get("d2"))?.status, "staged");
153
+ });
154
+ });
155
+
156
+ test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage whose supersede pass runs AFTER a newer stage neither clobbers it (zero) nor coexists with it (two)", async () => {
157
+ await withData(async (data) => {
158
+ const table = deliveryGraphProposals(data);
159
+ // A NEWER stage (d2) has already committed its row for logical_key "runbook" — the winner of a
160
+ // concurrent double-stage, with a later `updated_at`. Its TTL is live (createdAt defaults to now).
161
+ const newer = row({ digest: "d2" });
162
+ newer.updated_at = "2999-01-01T00:00:00.000Z";
163
+ await table.insert(newer);
164
+ // Now the OLDER racer (d1) runs its supersede pass LAST. An "only-flip-rows-older-than-me" pass would
165
+ // leave BOTH d1 and d2 staged (it won't flip the newer d2, and d2's own pass ran before d1 existed);
166
+ // an unordered supersede-all would flip d2 too, leaving ZERO. Reconciling to the newest sibling must
167
+ // supersede d1 (it has a newer staged sibling d2) and keep exactly d2 live.
168
+ await stageProposal(data, row({ digest: "d1" }));
169
+ assertEquals((await table.get("d2"))?.status, "staged", "the newer proposal must survive the older stage's supersede");
170
+ assertEquals((await table.get("d1"))?.status, "superseded", "the older stage must supersede itself when a newer staged sibling exists");
171
+ // EXACTLY ONE live proposal remains for the logical key — never zero, never two.
172
+ const stillStaged = (await table.all()).filter((r) => r.status === "staged" && r.logical_key === "runbook");
173
+ assertEquals(stillStaged.length, 1, "exactly one live proposal per logical graph");
174
+ assertEquals(stillStaged[0]?.digest, "d2", "the globally-newest stage is the one that survives");
175
+ });
176
+ });
177
+
178
+ test("getStagedProposal: an EXPIRED staged proposal is not live", async () => {
179
+ await withData(async (data) => {
180
+ await stageProposal(data, row());
181
+ const past = new Date(Date.now() - 1000);
182
+ // Query from a time AFTER its TTL horizon.
183
+ const future = new Date(Date.now() + DELIVERY_PROPOSAL_TTL_MS + 1000);
184
+ assert(await getStagedProposal(data, "d1", past));
185
+ assertEquals(await getStagedProposal(data, "d1", future), null);
186
+ });
187
+ });
188
+
189
+ test("markProposalDispatched: a dispatched proposal is no longer live", async () => {
190
+ await withData(async (data) => {
191
+ await stageProposal(data, row());
192
+ await markProposalDispatched(data, "d1");
193
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
194
+ assertEquals(await getStagedProposal(data, "d1"), null);
195
+ });
196
+ });
197
+
198
+ test("sweepExpiredProposals: flips aged-out staged proposals to `expired` so they drop out of the cockpit grid", async () => {
199
+ await withData(async (data) => {
200
+ // Two staged proposals with different logical keys so neither supersedes the other.
201
+ await stageProposal(data, row({ digest: "d1", logicalKey: "a", createdAt: "2024-01-01T00:00:00.000Z" }));
202
+ await stageProposal(data, row({ digest: "d2", logicalKey: "b" }));
203
+ // Sweep from a time past d1's TTL horizon but before d2's.
204
+ const at = new Date(Date.parse("2024-01-01T00:00:00.000Z") + DELIVERY_PROPOSAL_TTL_MS + 1000);
205
+ const swept = await sweepExpiredProposals(data, at);
206
+ assertEquals(swept, 1);
207
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "expired");
208
+ assertEquals((await deliveryGraphProposals(data).get("d2"))?.status, "staged");
209
+ // Idempotent: a re-sweep at the same instant flips nothing more.
210
+ assertEquals(await sweepExpiredProposals(data, at), 0);
211
+ });
212
+ });
213
+
214
+ test("sweepExpiredProposals: leaves superseded/dispatched proposals untouched (only `staged` is swept)", async () => {
215
+ await withData(async (data) => {
216
+ await stageProposal(data, row({ digest: "d1", createdAt: "2024-01-01T00:00:00.000Z" }));
217
+ await markProposalDispatched(data, "d1");
218
+ const at = new Date(Date.parse("2024-01-01T00:00:00.000Z") + DELIVERY_PROPOSAL_TTL_MS + 1000);
219
+ assertEquals(await sweepExpiredProposals(data, at), 0);
220
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
221
+ });
222
+ });
223
+
224
+ test("sweepExpiredProposals: a dispatch racing between the read and the write is NOT clobbered back to `expired`", async () => {
225
+ await withData(async (data) => {
226
+ // One aged-out staged proposal — the sweep's `find()` will see it as `staged`.
227
+ await stageProposal(data, row({ digest: "d1", createdAt: "2024-01-01T00:00:00.000Z" }));
228
+ const at = new Date(Date.parse("2024-01-01T00:00:00.000Z") + DELIVERY_PROPOSAL_TTL_MS + 1000);
229
+
230
+ // Wrap the data layer so that, in the window between the sweep's `find()` and its per-row guarded
231
+ // `exec`, the operator dispatches the proposal (status: staged -> dispatched). A blind
232
+ // update-by-key would clobber that dispatch back to `expired`; the guarded UPDATE
233
+ // (`WHERE status='staged'`) must instead no-op and leave the row `dispatched`.
234
+ let raced = false;
235
+ const racyData = new Proxy(data, {
236
+ get(target, prop, receiver) {
237
+ if (prop === "open") {
238
+ return () => {
239
+ const src = target.open();
240
+ return new Proxy(src, {
241
+ get(s, p) {
242
+ if (p === "exec") {
243
+ return async (sql: string, params?: unknown[]) => {
244
+ if (!raced) {
245
+ raced = true;
246
+ await markProposalDispatched(data, "d1");
247
+ }
248
+ return s.exec(sql, params);
249
+ };
250
+ }
251
+ const v = Reflect.get(s, p, s);
252
+ return typeof v === "function" ? v.bind(s) : v;
253
+ },
254
+ });
255
+ };
256
+ }
257
+ const v = Reflect.get(target, prop, target);
258
+ return typeof v === "function" ? v.bind(target) : v;
259
+ },
260
+ });
261
+
262
+ const swept = await sweepExpiredProposals(racyData as DataLayer, at);
263
+ assert(raced, "the racing dispatch should have fired");
264
+ assertEquals(swept, 0);
265
+ assertEquals((await deliveryGraphProposals(data).get("d1"))?.status, "dispatched");
266
+ });
267
+ });