@nanobpm/nano-workforce 0.123.1 → 0.124.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 (50) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDeploy.test.ts +209 -0
  4. package/app/deliveryGraphDispatch.test.ts +143 -0
  5. package/app/deliveryGraphDispatch.ts +168 -0
  6. package/app/deliveryGraphProposals.test.ts +267 -0
  7. package/app/deliveryGraphProposals.ts +269 -0
  8. package/app/deliveryGraphRun.test.ts +6 -52
  9. package/app/deliveryGraphRun.ts +21 -76
  10. package/app/deliveryGraphText.ts +3 -3
  11. package/app/deliveryRunner.ts +4 -3
  12. package/app/featureReadModel.test.ts +80 -12
  13. package/app/github.test.ts +34 -0
  14. package/app/github.ts +12 -3
  15. package/app/maybeEnsureFreshHeadRun.test.ts +150 -0
  16. package/app/mergeEscalationQuestion.test.ts +33 -0
  17. package/app/mergeProtocol.test.ts +25 -0
  18. package/app/mergeProtocol.ts +10 -4
  19. package/app/pollUserTasks.test.ts +27 -0
  20. package/app/service.ts +92 -13
  21. package/app/stage.test.ts +21 -7
  22. package/app/stage.ts +18 -5
  23. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  24. package/db/migrations/075_feature_read_model_attention_from_user_tasks.sql +113 -0
  25. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  26. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  27. package/docs/agent-guide.md +50 -58
  28. package/e2e/convergence-escalation.e2e.ts +10 -0
  29. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  30. package/e2e/retire-escalation-subsystem.e2e.ts +13 -0
  31. package/openapi.yaml +118 -161
  32. package/operations/compileDeliveryGraph.test.ts +100 -37
  33. package/operations/compileDeliveryGraph.ts +64 -18
  34. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  35. package/operations/dispatchDeliveryGraph.ts +79 -99
  36. package/operations/getAgentInstructions.test.ts +10 -6
  37. package/operations/previewDeliveryGraph.test.ts +90 -51
  38. package/operations/previewDeliveryGraph.ts +45 -18
  39. package/package.json +3 -3
  40. package/pages/cockpit/mount.js +19 -12
  41. package/pages/delivery-graphs/mount.js +37 -137
  42. package/pages/delivery-graphs.page.json +50 -3
  43. package/resources/processes/merge-loop.bpmn +1 -1
  44. package/scripts/check-migrations.test.ts +9 -0
  45. package/scripts/check-migrations.ts +11 -1
  46. package/test/cockpit-embed-endpoints.test.ts +59 -36
  47. package/test/delivery-graphs-embed.test.ts +36 -34
  48. package/e2e/delivery-graph-start.e2e.ts +0 -145
  49. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  50. package/operations/startDeliveryGraph.ts +0 -222
@@ -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
+ });
@@ -0,0 +1,269 @@
1
+ // app/deliveryGraphProposals.ts — the `staged` delivery-graph proposal AGGREGATE (ADR 0005 Decision 7,
2
+ // issue #460). The seam that makes dispatch OPERATOR-ONLY: the agent-facing compile door persists a
3
+ // valid compiled graph HERE as a `staged` proposal and hands the agent only a preview + a navigational
4
+ // `reviewUrl`. There is no run key, token, or dispatch handle in that response — nothing the agent can
5
+ // replay to start a run. A human previews the staged proposal in the cockpit and dispatches it.
6
+ //
7
+ // This dissolves the self-approval hole the old `approvalToken` left open: the token was a REPLAYABLE
8
+ // content digest returned to the same caller, so any holder of the API credential self-approved. By
9
+ // removing the dispatch affordance from the agent surface entirely (capability by absence, not by an
10
+ // auth check), there is nothing to replay — the boundary becomes self-documenting.
11
+ //
12
+ // The pure helpers (`proposalLogicalKey`, `proposalExpiry`, `isProposalExpired`, `buildProposalPreview`,
13
+ // `buildProposalRow`) are DB-free so they unit-test in isolation; `stageProposal` / `getStagedProposal`
14
+ // supply the I/O and the supersede-by-logical-key + TTL semantics.
15
+
16
+ import type { DataLayer } from "@nanobpm/urban";
17
+ import type {
18
+ CompileDeliveryGraphResult,
19
+ DeliveryHumanStop,
20
+ DeliverySideEffect,
21
+ } from "../nano-generated/api-io.d.ts";
22
+ import { publicBaseUrl } from "./blackboard.ts";
23
+
24
+ const now = () => new Date().toISOString();
25
+
26
+ /** The staged-proposal TTL (24h). A staged proposal an operator never dispatches ages out of the
27
+ * cockpit list at `created_at + TTL`, so the surface only ever shows live, dispatchable proposals. */
28
+ export const DELIVERY_PROPOSAL_TTL_MS = 24 * 60 * 60 * 1000;
29
+
30
+ /** The NAVIGATIONAL cockpit deep-link to a staged proposal — a pointer the agent can hand the human,
31
+ * NOT a dispatch handle. Points at the Delivery Graphs cockpit page (where the staged-proposals grid
32
+ * lives), fragment-scoped to the proposal's digest so the operator can find it. */
33
+ export function proposalReviewUrl(digest: string, base: string = publicBaseUrl()): string {
34
+ return `${base}/app/pages/delivery-graphs#proposal-${encodeURIComponent(digest)}`;
35
+ }
36
+
37
+ /** The proposal lifecycle. `staged` = awaiting operator review/dispatch; `superseded` = replaced by a
38
+ * newer digest for the same logical graph; `dispatched` = the operator launched it; `expired` = it aged
39
+ * out of its TTL before an operator dispatched it. `superseded`/`dispatched`/`expired` all drop out of
40
+ * the cockpit's staged list (which filters to `status = 'staged'`). */
41
+ export const DELIVERY_PROPOSAL_STATUSES = ["staged", "superseded", "dispatched", "expired"] as const;
42
+ export type DeliveryProposalStatus = typeof DELIVERY_PROPOSAL_STATUSES[number];
43
+
44
+ /** One staged delivery-graph proposal — the durable row keyed by content `digest`. `side_effecting`
45
+ * is a SQLite boolean (0/1); `graph`/`preview` are JSON text columns. */
46
+ export interface DeliveryGraphProposal {
47
+ digest: string;
48
+ logical_key: string;
49
+ title: string | null;
50
+ /** The original `DeliveryGraph` JSON — retained so the cockpit dispatch action can run the previewed
51
+ * digest without the agent re-submitting anything. */
52
+ graph: string;
53
+ /** The rendered preview JSON (`{ diagram, sideEffects, humanNodes }`), stamped at stage time so the
54
+ * cockpit list renders without recompiling. */
55
+ preview: string;
56
+ node_count: number;
57
+ human_node_count: number;
58
+ side_effect_count: number;
59
+ side_effecting: number;
60
+ status: DeliveryProposalStatus;
61
+ created_at: string;
62
+ updated_at: string;
63
+ expires_at: string;
64
+ }
65
+
66
+ /** The rendered preview a staged proposal carries — the operator-facing view of WHAT the graph does
67
+ * (its diagram, the side effects a dispatch authorises, and where it parks on a person). It carries no
68
+ * dispatch handle by construction. */
69
+ export interface DeliveryProposalPreview {
70
+ diagram: string;
71
+ sideEffects: DeliverySideEffect[];
72
+ humanNodes: DeliveryHumanStop[];
73
+ }
74
+
75
+ /** The `delivery_graph_proposals` aggregate accessor — the durable staged-proposal store keyed by
76
+ * content `digest`. */
77
+ export const deliveryGraphProposals = (data: DataLayer) =>
78
+ data.table<DeliveryGraphProposal>("delivery_graph_proposals", "digest");
79
+
80
+ /** The LOGICAL graph identity used to supersede: the graph's `name` (trimmed, when non-blank), else
81
+ * the content `digest`. A re-compile of a CHANGED graph (new digest) with the SAME name replaces the
82
+ * prior staged proposal; an unnamed graph is its own logical key (it supersedes only an identical
83
+ * recompile of itself). */
84
+ export function proposalLogicalKey(name: string | undefined | null, digest: string): string {
85
+ const trimmed = typeof name === "string" ? name.trim() : "";
86
+ return trimmed || digest;
87
+ }
88
+
89
+ /** The TTL horizon for a proposal staged at `createdAt` — `createdAt + DELIVERY_PROPOSAL_TTL_MS`. */
90
+ export function proposalExpiry(createdAtIso: string): string {
91
+ const created = Date.parse(createdAtIso);
92
+ const base = Number.isNaN(created) ? Date.now() : created;
93
+ return new Date(base + DELIVERY_PROPOSAL_TTL_MS).toISOString();
94
+ }
95
+
96
+ /** Whether a staged proposal has aged out (its `expires_at` is at or before `at`). A corrupt/blank
97
+ * `expires_at` is treated as expired so a bad row can never linger undismissable in the cockpit. */
98
+ export function isProposalExpired(expiresAtIso: string | null | undefined, at: Date = new Date()): boolean {
99
+ if (typeof expiresAtIso !== "string" || expiresAtIso.trim() === "") return true;
100
+ const expires = Date.parse(expiresAtIso);
101
+ if (Number.isNaN(expires)) return true;
102
+ return expires <= at.getTime();
103
+ }
104
+
105
+ /** Extract the operator-facing preview from a successful compile — the diagram, the side effects a
106
+ * dispatch authorises, and the human stop-points. No BPMN, no digest handle beyond the content
107
+ * address; a preview, not a dispatch affordance. */
108
+ export function buildProposalPreview(compiled: CompileDeliveryGraphResult): DeliveryProposalPreview {
109
+ return {
110
+ diagram: compiled.diagram,
111
+ sideEffects: compiled.sideEffects,
112
+ humanNodes: compiled.humanNodes,
113
+ };
114
+ }
115
+
116
+ /** Build the durable proposal row for a compiled graph at stage time. `graph` is the original
117
+ * `DeliveryGraph` JSON (serialised) the cockpit dispatch action re-runs; `createdAt` is preserved
118
+ * across an idempotent re-stage of a still-live proposal so the TTL stays anchored to the first stage
119
+ * (omit it — defaulting to now — to re-anchor the TTL when re-staging an already-expired digest). */
120
+ export function buildProposalRow(input: {
121
+ digest: string;
122
+ logicalKey: string;
123
+ title: string | null;
124
+ graphJson: string;
125
+ preview: DeliveryProposalPreview;
126
+ nodeCount: number;
127
+ humanNodeCount: number;
128
+ sideEffectCount: number;
129
+ sideEffecting: boolean;
130
+ createdAt?: string;
131
+ }): DeliveryGraphProposal {
132
+ const at = now();
133
+ const createdAt = input.createdAt ?? at;
134
+ return {
135
+ digest: input.digest,
136
+ logical_key: input.logicalKey,
137
+ title: input.title,
138
+ graph: input.graphJson,
139
+ preview: JSON.stringify(input.preview),
140
+ node_count: input.nodeCount,
141
+ human_node_count: input.humanNodeCount,
142
+ side_effect_count: input.sideEffectCount,
143
+ side_effecting: input.sideEffecting ? 1 : 0,
144
+ status: "staged",
145
+ created_at: createdAt,
146
+ updated_at: at,
147
+ expires_at: proposalExpiry(createdAt),
148
+ };
149
+ }
150
+
151
+ /** Persist a compiled graph as a `staged` proposal and SUPERSEDE any prior staged proposal for the
152
+ * same logical graph. Idempotent on `digest` (a re-stage of an identical, still-live digest refreshes
153
+ * `updated_at` but preserves `created_at`, so the TTL stays anchored to the first stage; a re-stage of
154
+ * a digest that has already aged out re-anchors the TTL to now so it is dispatchable again). After the
155
+ * upsert, every
156
+ * OTHER `staged` proposal sharing this `logical_key` is flipped to `superseded` — so the cockpit shows
157
+ * exactly one live proposal per logical graph (the latest digest the operator would dispatch). The
158
+ * supersede RECONCILES to the globally-newest staged row (`updated_at`, `digest`-tie-broken) rather than
159
+ * flipping only rows older than the just-written one, so concurrent stages of two different digests
160
+ * converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order. */
161
+ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<DeliveryGraphProposal> {
162
+ const table = deliveryGraphProposals(data);
163
+ const existing = await table.get(row.digest);
164
+ const toWrite = existing
165
+ ? buildProposalRow({
166
+ digest: row.digest,
167
+ logicalKey: row.logical_key,
168
+ title: row.title,
169
+ graphJson: row.graph,
170
+ preview: JSON.parse(row.preview),
171
+ nodeCount: row.node_count,
172
+ humanNodeCount: row.human_node_count,
173
+ sideEffectCount: row.side_effect_count,
174
+ sideEffecting: row.side_effecting === 1,
175
+ // Re-stage: if the existing row is STILL LIVE, preserve its original stage time so the TTL
176
+ // stays anchored to the first stage. But if it has already aged out of its TTL (or was
177
+ // dispatched/superseded long ago), reusing the stale `created_at` would yield a past
178
+ // `expires_at`, leaving the "re-staged" row immediately non-dispatchable (`getStagedProposal`
179
+ // rejects it as expired) while the preview claims it is staged. In that case re-anchor the TTL
180
+ // to now (omit `createdAt`) so a re-proposed digest is genuinely live again.
181
+ createdAt: isProposalExpired(existing.expires_at) ? undefined : existing.created_at,
182
+ })
183
+ : row;
184
+ if (existing) {
185
+ const { digest, ...patch } = toWrite;
186
+ await table.update(row.digest, patch);
187
+ } else {
188
+ await table.insert(toWrite);
189
+ }
190
+ // Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
191
+ // `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
192
+ // `digest` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT — it never
193
+ // references the just-written digest, so it converges to a single live row regardless of the
194
+ // interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
195
+ // just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
196
+ // committed (the older pass won't flip the newer row, and the newer pass ran before the older row
197
+ // existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
198
+ // both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
199
+ // statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
200
+ await data
201
+ .open()
202
+ .exec(
203
+ `UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."digest" > "delivery_graph_proposals"."digest")))`,
204
+ [now(), row.logical_key],
205
+ );
206
+ return toWrite;
207
+ }
208
+
209
+ /** Load a proposal that is live and dispatchable RIGHT NOW: it exists, is `staged` (not superseded or
210
+ * already dispatched), and has not aged out of its TTL. Returns null otherwise, so the cockpit
211
+ * dispatch action refuses a stale/unknown/already-dispatched digest cleanly. */
212
+ export async function getStagedProposal(
213
+ data: DataLayer,
214
+ digest: string,
215
+ at: Date = new Date(),
216
+ ): Promise<DeliveryGraphProposal | null> {
217
+ const row = await deliveryGraphProposals(data).get(digest);
218
+ if (!row) return null;
219
+ if (row.status !== "staged") return null;
220
+ if (isProposalExpired(row.expires_at, at)) return null;
221
+ return row;
222
+ }
223
+
224
+ /** Mark a staged proposal `dispatched` once the operator launches it — it drops out of the cockpit's
225
+ * staged list (the run then shows in the in-flight grid). */
226
+ export async function markProposalDispatched(data: DataLayer, digest: string): Promise<void> {
227
+ await deliveryGraphProposals(data).update(digest, { status: "dispatched", updated_at: now() });
228
+ }
229
+
230
+ /** Retire a proposal by flipping it to `expired` — its `graph` payload is unusable (e.g. corrupt JSON
231
+ * detected at dispatch), so it can never launch. Reuses the terminal `expired` status the sweep already
232
+ * uses, so a fail-closed retirement drops the row out of the cockpit's staged grid instead of leaving an
233
+ * undismissable `staged` row that fails every dispatch attempt the same way. */
234
+ export async function markProposalExpired(data: DataLayer, digest: string): Promise<void> {
235
+ await deliveryGraphProposals(data).update(digest, { status: "expired", updated_at: now() });
236
+ }
237
+
238
+ /** Age out every `staged` proposal whose TTL has elapsed by flipping it to `expired`, so it drops out
239
+ * of the cockpit's staged grid (which filters to `status = 'staged'`). The grid's datasource filter can
240
+ * only express equality/set-membership — not an `expires_at > now` comparison — so an expired-but-still-
241
+ * `staged` row would otherwise linger in the list indefinitely, only to fail dispatch with "no live
242
+ * staged proposal". This reconciliation sweep (driven by the poller) is the single writer that realises
243
+ * the TTL, reusing the canonical `isProposalExpired` predicate so there is no second definition of
244
+ * "expired". Returns the number of proposals swept. Idempotent: a proposal already terminal (superseded/
245
+ * dispatched/expired) is left untouched.
246
+ *
247
+ * The expiry flip is a GUARDED UPDATE (`... WHERE digest=? AND status='staged'`), not a blind
248
+ * update-by-key. The initial `find()` and the per-row write are separate statements, so a proposal can
249
+ * be dispatched (or superseded) in the window between them; a blind `table.update(digest, …)` would
250
+ * clobber that newer terminal status back to `expired`, silently re-hiding a run the operator just
251
+ * launched. The `status='staged'` guard makes the write a no-op when the row has already moved on, and
252
+ * we count only rows that actually changed (`res.changed`) so the returned tally stays honest. */
253
+ export async function sweepExpiredProposals(data: DataLayer, at: Date = new Date()): Promise<number> {
254
+ const table = deliveryGraphProposals(data);
255
+ const staged = await table.find({ status: "staged" });
256
+ const ts = now();
257
+ const db = data.open();
258
+ let swept = 0;
259
+ for (const row of staged) {
260
+ if (isProposalExpired(row.expires_at, at)) {
261
+ const res = await db.exec(
262
+ `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
263
+ [ts, row.digest],
264
+ );
265
+ swept += res.changed;
266
+ }
267
+ }
268
+ return swept;
269
+ }
@@ -1,9 +1,9 @@
1
- // Unit coverage for the S5 dispatch-door aggregate (ADR 0005 Decision 7) — the pure decision helpers
2
- // (the idempotency key, the approval gate, the parked human-label map, the derived parked-node phase),
3
- // plus the durable at-most-once launch-claim fence (`claimRunForLaunch`) exercised against the real
4
- // provisioned SQLite data layer so its actual `status <> 'running'` compare-and-swap SQL is validated,
5
- // not just modelled. The integration test (operations/startDeliveryGraph.integration.test.ts) proves
6
- // the COMPOSED behaviour at the edge.
1
+ // Unit coverage for the delivery-graph run aggregate (ADR 0005 Decision 7) — the pure decision helpers
2
+ // (the idempotency key, the parked human-label map, the derived parked-node phase), plus the durable
3
+ // at-most-once launch-claim fence (`claimRunForLaunch`) exercised against the real provisioned SQLite
4
+ // data layer so its actual `status <> 'running'` compare-and-swap SQL is validated, not just modelled.
5
+ // The COMPOSED dispatch behaviour at the edge is proven by operations/dispatchDeliveryGraph.test.ts
6
+ // and app/deliveryGraphDispatch.test.ts (the operator dispatch action).
7
7
  import { mkdtempSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
@@ -21,8 +21,6 @@ import {
21
21
  DELIVERY_PHASE,
22
22
  deliveryGraphRuns,
23
23
  humanTaskElementId,
24
- isDeliveryGraphApproved,
25
- parkRunFencedAgainstLaunch,
26
24
  parseHumanLabels,
27
25
  } from "./deliveryGraphRun.ts";
28
26
  import { pollDeliveryGraphPhase } from "./service.ts";
@@ -124,36 +122,6 @@ test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches
124
122
  });
125
123
  });
126
124
 
127
- // ── parkRunFencedAgainstLaunch: the approval-park write never clobbers a launched claim ────────────
128
- test("parkRunFencedAgainstLaunch: an approval-park write onto a launched `running` claim is a no-op — the at-most-once dispatch fence survives (no clobber back to awaiting-approval, no nulled process_key)", async () => {
129
- await withData(async (data) => {
130
- const runs = deliveryGraphRuns(data);
131
- // A concurrent APPROVED submit already launched: the row is `running` with a live instance key.
132
- await runs.insert({ ...claimRow("running"), process_key: "PI-1" });
133
- // A racing UNAPPROVED submit that read the pre-launch row now tries to (re-)park it. The guarded
134
- // write must refuse to overwrite the launched claim — otherwise a later re-submit double-launches.
135
- await parkRunFencedAgainstLaunch(data, true, claimRow("awaiting-approval"));
136
- const row = await runs.get("rk");
137
- assertEquals(row?.status, "running"); // NOT clobbered back to awaiting-approval
138
- assertEquals(row?.process_key, "PI-1"); // instance key preserved
139
- });
140
- });
141
-
142
- test("parkRunFencedAgainstLaunch: a first park INSERTs the row; a park onto a still-parked row idempotently re-parks it (metadata refreshed, status stays awaiting-approval)", async () => {
143
- await withData(async (data) => {
144
- const runs = deliveryGraphRuns(data);
145
- // First unapproved submit: no row yet → INSERT.
146
- await parkRunFencedAgainstLaunch(data, false, claimRow("awaiting-approval"));
147
- assertEquals((await runs.get("rk"))?.status, "awaiting-approval");
148
- // A second unapproved submit onto the existing parked row: guarded UPDATE re-parks it (status is
149
- // not `running`, so it applies) without duplicating the row.
150
- await parkRunFencedAgainstLaunch(data, true, { ...claimRow("awaiting-approval"), digest: "d2" });
151
- const row = await runs.get("rk");
152
- assertEquals(row?.status, "awaiting-approval");
153
- assertEquals(row?.digest, "d2");
154
- });
155
- });
156
-
157
125
  // ── computeRunKey ─────────────────────────────────────────────────────────────
158
126
  test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back to the digest", () => {
159
127
  assertEquals(computeRunKey("run-1", "digestX"), "run-1");
@@ -164,20 +132,6 @@ test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back
164
132
  assertEquals(computeRunKey(undefined, "digestX"), "digestX");
165
133
  });
166
134
 
167
- // ── isDeliveryGraphApproved ───────────────────────────────────────────────────
168
- test("isDeliveryGraphApproved: a non-side-effecting graph needs no approval", () => {
169
- assertEquals(isDeliveryGraphApproved(false, null, "d"), true);
170
- assertEquals(isDeliveryGraphApproved(false, "wrong", "d"), true);
171
- });
172
-
173
- test("isDeliveryGraphApproved: a side-effecting graph dispatches ONLY with the matching content token", () => {
174
- assertEquals(isDeliveryGraphApproved(true, "d", "d"), true);
175
- assertEquals(isDeliveryGraphApproved(true, " d ", "d"), true); // trimmed
176
- assertEquals(isDeliveryGraphApproved(true, "wrong", "d"), false);
177
- assertEquals(isDeliveryGraphApproved(true, null, "d"), false);
178
- assertEquals(isDeliveryGraphApproved(true, "", "d"), false);
179
- });
180
-
181
135
  // ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
182
136
  test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", async () => {
183
137
  const graph = {