@nanobpm/nano-workforce 0.159.0 → 0.159.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.159.1](https://github.com/nanobpm/nano-workforce/compare/v0.159.0...v0.159.1) (2026-08-29)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** read-after-write consistency for listStagedProposals (S2) ([#617](https://github.com/nanobpm/nano-workforce/issues/617)) ([bd0246c](https://github.com/nanobpm/nano-workforce/commit/bd0246cc81241041320aa483b0a0789f5e372031)), closes [#608](https://github.com/nanobpm/nano-workforce/issues/608)
6
+
1
7
  ## [0.159.0](https://github.com/nanobpm/nano-workforce/compare/v0.158.1...v0.159.0) (2026-08-29)
2
8
 
3
9
  ### Features
@@ -15,6 +15,7 @@ import {
15
15
  DELIVERY_PROPOSAL_TTL_MS,
16
16
  deliveryGraphProposals,
17
17
  getStagedProposal,
18
+ isLiveStaged,
18
19
  isProposalExpired,
19
20
  markProposalDismissed,
20
21
  markProposalDispatched,
@@ -82,6 +83,21 @@ test("isProposalExpired: past → true, future → false, blank/corrupt → true
82
83
  assertEquals(isProposalExpired("garbage", at), true);
83
84
  });
84
85
 
86
+ test("isLiveStaged: the ONE liveness predicate — only a `staged`, not-yet-expired row is live (#608)", () => {
87
+ const at = new Date("2024-06-01T00:00:00.000Z");
88
+ const live = row({ createdAt: "2024-05-31T23:00:00.000Z" }); // staged, TTL a day out → live
89
+ assertEquals(isLiveStaged(live, at), true);
90
+ // A future-created staged row is trivially live too (TTL further out).
91
+ assertEquals(isLiveStaged(row(), new Date()), true);
92
+ // Every non-`staged` status is NOT live, regardless of TTL.
93
+ for (const status of ["superseded", "dispatched", "expired", "dismissed"] as const) {
94
+ assertEquals(isLiveStaged({ ...live, status }, at), false);
95
+ }
96
+ // A `staged` row whose TTL has elapsed is NOT live (mirrors isProposalExpired, fail-closed).
97
+ assertEquals(isLiveStaged({ ...live, expires_at: "2024-05-30T00:00:00.000Z" }, at), false);
98
+ assertEquals(isLiveStaged({ ...live, expires_at: "" }, at), false);
99
+ });
100
+
85
101
  test("proposalReviewUrl: a navigational deep-link to the cockpit page — NOT a dispatch endpoint", () => {
86
102
  const url = proposalReviewUrl("abc123", "https://cockpit.example");
87
103
  assertEquals(url, "https://cockpit.example/app/pages/delivery-graphs#proposal-abc123");
@@ -207,9 +207,25 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
207
207
  return toWrite;
208
208
  }
209
209
 
210
- /** Load a proposal that is live and dispatchable RIGHT NOW: it exists, is `staged` (not superseded or
211
- * already dispatched), and has not aged out of its TTL. Returns null otherwise, so the cockpit
212
- * dispatch action refuses a stale/unknown/already-dispatched digest cleanly. */
210
+ /** The ONE definition of "a live, dispatchable-RIGHT-NOW staged proposal" (issue #608): the row is
211
+ * `staged` (not superseded / dispatched / expired / dismissed) AND has not aged out of its TTL. This is
212
+ * the SINGLE SOURCE OF TRUTH the digest read (`getStagedProposal`), the list read
213
+ * (`listStagedProposals`), and — through them — the cockpit App-View, the dispatch/preview/dismiss
214
+ * doors, and the MCP `listStagedProposals` tool all resolve liveness through, so no two readers can ever
215
+ * drift on which rows are live (AGENTS.md "Derivation over duplication"). A read-after-write from
216
+ * `compileDeliveryGraph` is trustworthy because this predicate is evaluated over the SAME durable
217
+ * `delivery_graph_proposals` store the compile door commits the row to (the app's single default
218
+ * `app.data` source) — a freshly staged row (`status: 'staged'`, `expires_at` a full TTL in the future)
219
+ * is live by construction, with no projection/read-model between the write and the read to lag behind. */
220
+ export function isLiveStaged(row: DeliveryGraphProposal, at: Date = new Date()): boolean {
221
+ return row.status === "staged" && !isProposalExpired(row.expires_at, at);
222
+ }
223
+
224
+ /** Load a proposal that is live and dispatchable RIGHT NOW: it exists and satisfies {@link isLiveStaged}
225
+ * (it is `staged` — not superseded or already dispatched — and has not aged out of its TTL). Returns
226
+ * null otherwise, so the cockpit dispatch action refuses a stale/unknown/already-dispatched digest
227
+ * cleanly. Reads the authoritative `delivery_graph_proposals` row by primary key from the same
228
+ * `app.data` store the compile door stages into — a read-your-writes lookup, no read-model in between. */
213
229
  export async function getStagedProposal(
214
230
  data: DataLayer,
215
231
  digest: string,
@@ -217,24 +233,28 @@ export async function getStagedProposal(
217
233
  ): Promise<DeliveryGraphProposal | null> {
218
234
  const row = await deliveryGraphProposals(data).get(digest);
219
235
  if (!row) return null;
220
- if (row.status !== "staged") return null;
221
- if (isProposalExpired(row.expires_at, at)) return null;
222
- return row;
236
+ return isLiveStaged(row, at) ? row : null;
223
237
  }
224
238
 
225
- /** Every LIVE staged proposal — `status = 'staged'` AND not aged out of its TTL — newest first. The
226
- * staged App-View (`pages/delivery-graphs/staged.mount.js`) polls this to render the Preview-DI +
227
- * Dispatch list. Mirrors `getStagedProposal`'s freshness guard (`isProposalExpired`) so an
228
- * expired-but-not-yet-swept row is never offered for preview/dispatch, unlike a raw
229
- * `status = 'staged'` datasource filter which cannot express a `expires_at > now` cutoff and so lingers
230
- * an aged-out row until the sweep realises the TTL. Read-only; no write. */
239
+ /** Every LIVE staged proposal — {@link isLiveStaged} (`status = 'staged'` AND not aged out of its TTL)
240
+ * newest first. The staged App-View (`pages/delivery-graphs/staged.mount.js`) and the MCP
241
+ * `listStagedProposals` tool both poll THIS door to render/answer the Preview-DI + Dispatch list.
242
+ *
243
+ * READ-AFTER-WRITE GUARANTEE (issue #608). The list is served by a fresh query against the authoritative
244
+ * `delivery_graph_proposals` table on the app's single default `app.data` source the SAME store, in
245
+ * the SAME scope, the `compileDeliveryGraph`/`stageProposal` write commits to. There is no
246
+ * projection/read-model or cache between the write and this read, so a digest `compileDeliveryGraph`
247
+ * just returned is listed on the very next call with NO intervening delay (the write is committed before
248
+ * the compile door responds). Liveness is decided by the shared {@link isLiveStaged} predicate — NOT a
249
+ * second `status = 'staged'` datasource filter, which cannot express an `expires_at > now` cutoff and so
250
+ * would linger an aged-out row until the poller's sweep realises the TTL. Read-only; no write. */
231
251
  export async function listStagedProposals(
232
252
  data: DataLayer,
233
253
  at: Date = new Date(),
234
254
  ): Promise<DeliveryGraphProposal[]> {
235
255
  const rows = await deliveryGraphProposals(data).find({ status: "staged" });
236
256
  return rows
237
- .filter((row) => !isProposalExpired(row.expires_at, at))
257
+ .filter((row) => isLiveStaged(row, at))
238
258
  .sort((a, b) => b.created_at.localeCompare(a.created_at));
239
259
  }
240
260
 
@@ -0,0 +1,132 @@
1
+ // Delivery-graph read-after-write regression net (epic #605 slice S2, issue #608).
2
+ //
3
+ // PINS the acceptance guarantee: immediately after `compileDeliveryGraph` returns a staged `digest`,
4
+ // `listStagedProposals` returns THAT digest — with NO intervening delay. In the evidence session
5
+ // (issue #608) a `compileDeliveryGraph` that returned `status:"ready"` with `digest:"ca8fb90a1b0c"`
6
+ // was followed by a `listStagedProposals` that answered `{"count":0,"proposals":[]}` — the read and
7
+ // the write had disagreed, so an agent could not confirm its own staging. The fix serves both from a
8
+ // single source of truth: the read (`listStagedProposals` / `getStagedProposal`, unified through the
9
+ // one `isLiveStaged` predicate) queries the SAME `delivery_graph_proposals` store, in the SAME scope,
10
+ // that the compile/stage write commits to — no read-model or cache in between.
11
+ //
12
+ // This case is RUNNABLE VIA THE SLICE S1 HARNESS (`e2e/support/mcp-harness.ts`): it imports
13
+ // `bootMcpHarness` and drives the REAL runtime-served `/app/mcp` surface over the client handshake —
14
+ // the exact transport an agent uses — so a regression that reintroduces the disagreement (a lagging
15
+ // projection, a divergent read filter, a supersede that drops the just-written row) fails the build.
16
+ // It does NOT re-implement the handshake (see the harness module header's EXTENSION SEAM).
17
+ //
18
+ // Run with `npm run e2e`.
19
+ import assert from "node:assert/strict";
20
+ import { after, before, describe, test } from "node:test";
21
+ import { assertObjectBodyAccepted, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
22
+
23
+ /** A minimal side-effecting graph — `compileDeliveryGraph` STAGES it (unlike the pure `previewDelivery
24
+ * Graph`), which is exactly the write whose visibility we verify. Two agent nodes → two side effects. */
25
+ const STAGES_A_PROPOSAL = {
26
+ name: "S2 read-after-write A",
27
+ nodes: [
28
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
29
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
30
+ ],
31
+ edges: [{ from: "open", to: "cut" }],
32
+ };
33
+
34
+ /** A dedicated logical key for the supersede test, distinct from the first test's key so the case is
35
+ * SELF-CONTAINED — it stages BOTH its own V1 and V2 in-test and asserts per-logical-key, rather than
36
+ * depending on an earlier test having staged a predecessor. */
37
+ const SUPERSEDE_KEY = "S2 supersede key";
38
+
39
+ /** V1 for the supersede test — the predecessor that staging V2 must retire. */
40
+ const SUPERSEDE_V1 = {
41
+ name: SUPERSEDE_KEY,
42
+ nodes: [
43
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
44
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
45
+ ],
46
+ edges: [{ from: "open", to: "cut" }],
47
+ };
48
+
49
+ /** V2 — a STRUCTURALLY different graph (an extra node → different compiled BPMN → different content
50
+ * digest) sharing the SAME `name` (logical key) as V1, so staging it supersedes V1. The read after it
51
+ * must show the SECOND digest (the one the compile just returned), never the superseded first. */
52
+ const SUPERSEDE_V2 = {
53
+ name: SUPERSEDE_KEY,
54
+ nodes: [
55
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "un-draft + merge #B" } },
56
+ { id: "notes", kind: "agent", agent: { jobType: "senior:demo", prompt: "draft the release notes" } },
57
+ { id: "cut", kind: "agent", agent: { jobType: "senior:demo", prompt: "cut the release" } },
58
+ ],
59
+ edges: [
60
+ { from: "open", to: "notes" },
61
+ { from: "notes", to: "cut" },
62
+ ],
63
+ };
64
+
65
+ interface StagedRow { digest: string; title: string | null }
66
+ interface ListBody { count: number; proposals: StagedRow[] }
67
+
68
+ /** Compile a graph over MCP and return the staged digest the door reports (asserting it staged). */
69
+ async function compileAndStage(h: McpHarness, graph: unknown): Promise<string> {
70
+ const res = await h.callTool("compileDeliveryGraph", { body: graph });
71
+ assertObjectBodyAccepted(res, "compileDeliveryGraph"); // the object body was NOT stringified
72
+ assert.ok(!res.isError, `compileDeliveryGraph must stage a valid graph: ${res.text}`);
73
+ const json = res.json as { status?: string; digest?: string } | undefined;
74
+ assert.equal(json?.status, "ready", `compileDeliveryGraph must report status:"ready": ${res.text}`);
75
+ assert.ok(typeof json?.digest === "string" && json.digest.length > 0, `a staged digest is required: ${res.text}`);
76
+ return json.digest;
77
+ }
78
+
79
+ /** The current live staged list, read over the SAME MCP surface. */
80
+ async function listStaged(h: McpHarness): Promise<ListBody> {
81
+ const res = await h.callTool("listStagedProposals", {});
82
+ assert.ok(!res.isError, `listStagedProposals must not error: ${res.text}`);
83
+ const json = res.json as ListBody | undefined;
84
+ assert.ok(json && Array.isArray(json.proposals), `listStagedProposals must return a proposals array: ${res.text}`);
85
+ return json;
86
+ }
87
+
88
+ describe("S2 — listStagedProposals read-after-write is trustworthy (#608)", () => {
89
+ let h: McpHarness;
90
+ before(async () => { h = await bootMcpHarness(); });
91
+ after(async () => { await h.stop(); });
92
+
93
+ test("a digest compileDeliveryGraph just returned is listed on the very next call — no delay", async () => {
94
+ const digest = await compileAndStage(h, STAGES_A_PROPOSAL);
95
+ // The immediate read — no sleep, no retry, no poll — must show the write.
96
+ const list = await listStaged(h);
97
+ const digests = list.proposals.map((p) => p.digest);
98
+ assert.ok(
99
+ digests.includes(digest),
100
+ `the digest ${digest} compileDeliveryGraph just returned must appear in listStagedProposals ` +
101
+ `immediately (got ${JSON.stringify(digests)})`,
102
+ );
103
+ });
104
+
105
+ test("supersede keeps read-after-write honest: the read shows the LATEST returned digest, not the superseded one", async () => {
106
+ // SELF-CONTAINED: stage V1 then V2 for the SAME logical key inside this test — V2 supersedes V1. The
107
+ // read must reflect the digest THIS compile returned (the live one) and the superseded predecessor
108
+ // must be gone, with no dependence on any other test having staged first.
109
+ const digestV1 = await compileAndStage(h, SUPERSEDE_V1);
110
+ const digestV2 = await compileAndStage(h, SUPERSEDE_V2);
111
+ assert.notEqual(digestV1, digestV2, "V1 and V2 must differ in content so V2 genuinely supersedes V1");
112
+ const list = await listStaged(h);
113
+ const digests = list.proposals.map((p) => p.digest);
114
+ assert.ok(
115
+ digests.includes(digestV2),
116
+ `after superseding, listStagedProposals must show the latest digest ${digestV2} (got ${JSON.stringify(digests)})`,
117
+ );
118
+ assert.ok(
119
+ !digests.includes(digestV1),
120
+ `the superseded predecessor digest ${digestV1} must be gone from listStagedProposals (got ${JSON.stringify(digests)})`,
121
+ );
122
+ // Exactly one live proposal FOR THIS logical key — filter by title so the assertion is per-logical-key
123
+ // and does not couple to how many other proposals exist in the shared store.
124
+ const forLogicalKey = list.proposals.filter((p) => p.title === SUPERSEDE_KEY);
125
+ assert.equal(
126
+ forLogicalKey.length,
127
+ 1,
128
+ `exactly one live staged proposal must remain for the logical key ${JSON.stringify(SUPERSEDE_KEY)} (got ${JSON.stringify(forLogicalKey.map((p) => p.digest))})`,
129
+ );
130
+ assert.equal(forLogicalKey[0]?.digest, digestV2, "the sole remaining live proposal must be the latest digest");
131
+ });
132
+ });
package/openapi.yaml CHANGED
@@ -4029,11 +4029,25 @@ paths:
4029
4029
  operationId: listStagedProposals
4030
4030
  summary: List the LIVE staged delivery-graph proposals awaiting dispatch (issue #511), newest first.
4031
4031
  description: >-
4032
- The read behind the staged-proposals App-View: every `staged` delivery-graph proposal that has
4033
- not aged out of its TTL, newest first, projected to the Preview-DI + Dispatch metadata (the
4034
- `graph`/`preview` payloads are omitted — the App-View recompiles by `digest` for the DI preview).
4035
- Mirrors the `previewProposalBpmn`/`dispatchDeliveryGraph` freshness guard so an expired-but-not-
4036
- yet-swept row is never listed. Read-only.
4032
+ The read behind the staged-proposals App-View AND the MCP `listStagedProposals` tool: every
4033
+ `staged` delivery-graph proposal that has not aged out of its TTL, newest first, projected to the
4034
+ Preview-DI + Dispatch metadata (the `graph`/`preview` payloads are omitted — the App-View
4035
+ recompiles by `digest` for the DI preview). Mirrors the
4036
+ `previewProposalBpmn`/`dispatchDeliveryGraph` freshness guard so an expired-but-not-yet-swept row
4037
+ is never listed. Read-only.
4038
+
4039
+
4040
+ FRESHNESS / CONSISTENCY (issue #608). This read is READ-AFTER-WRITE consistent with
4041
+ `compileDeliveryGraph`: it is served by a fresh query against the SAME durable
4042
+ `delivery_graph_proposals` store, in the SAME scope, that the compile/stage write commits to (the
4043
+ app's single default data source) — there is no projection, read-model, or cache between the
4044
+ write and this read. So a `digest` that `compileDeliveryGraph` just returned as `status: "ready"`
4045
+ is guaranteed to appear here on the very next call, with NO intervening delay and no polling
4046
+ needed (the row is committed before the compile door responds). The one caveat is scope: the MCP
4047
+ endpoint and the cockpit must resolve the SAME app deployment — point the MCP client at the same
4048
+ instance/mount whose `reviewUrl` the compile door returned, or the read and the write address
4049
+ different databases and disagree. Returns an empty list only when nothing is genuinely staged
4050
+ (or every staged row has aged out of its TTL).
4037
4051
  security:
4038
4052
  - hookSecret: []
4039
4053
  - {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.159.0",
3
+ "version": "0.159.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",