@nanobpm/nano-workforce 0.142.0 → 0.144.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.144.0](https://github.com/nanobpm/nano-workforce/compare/v0.143.0...v0.144.0) (2026-08-25)
2
+
3
+ ### Features
4
+
5
+ * **stepper:** derived stepper v1 — one pipeline projection for feature + delivery graph ([#541](https://github.com/nanobpm/nano-workforce/issues/541)) ([#546](https://github.com/nanobpm/nano-workforce/issues/546)) ([0aa365a](https://github.com/nanobpm/nano-workforce/commit/0aa365af158df3de3c79f8a42226a2694a75af52)), closes [#540](https://github.com/nanobpm/nano-workforce/issues/540) [#538](https://github.com/nanobpm/nano-workforce/issues/538) [205/#386](https://github.com/205/nano-workforce/issues/386) [#542](https://github.com/nanobpm/nano-workforce/issues/542)
6
+
7
+ ## [0.143.0](https://github.com/nanobpm/nano-workforce/compare/v0.142.0...v0.143.0) (2026-08-25)
8
+
9
+ ### Features
10
+
11
+ * **delivery-graph:** idempotency preflight on agent nodes ([#551](https://github.com/nanobpm/nano-workforce/issues/551)) ([#552](https://github.com/nanobpm/nano-workforce/issues/552)) ([f16fa6d](https://github.com/nanobpm/nano-workforce/commit/f16fa6d32336fdd025b810ba763403b352986937)), closes [979/#980](https://github.com/979/nano-workforce/issues/980) [#506](https://github.com/nanobpm/nano-workforce/issues/506)
12
+
1
13
  ## [0.142.0](https://github.com/nanobpm/nano-workforce/compare/v0.141.0...v0.142.0) (2026-08-25)
2
14
 
3
15
  ### Features
@@ -0,0 +1,392 @@
1
+ // Read-model coverage for the delivery-graph progress projection — the member-PR rollup, the coarse-key
2
+ // stage/stage_state derivation, and the promoted `pipeline` render binding — authored via Urban's
3
+ // ADR-0065 declare-once primitives (app/deliveryGraphReadModel.ts). ADR 0006 §4b, issue #541 / S7; the
4
+ // exemplars are app/featureReadModel.test.ts and app/planReadModel.test.ts.
5
+ //
6
+ // Guards:
7
+ // 1. DRIFT GUARD — migration 087 embeds the rollup VIEW DDL VERBATIM from `rollup.viewDdl()` and each
8
+ // derived column VERBATIM from `deliveryGraphReadModel.sqlSelectFor(...)`, so the checked-in VIEWs
9
+ // cannot drift from the declarations.
10
+ // 2. FRAMEWORK PARITY GUARD — `assertRollupParity` / `assertReadModelParity` prove the SQL and TS
11
+ // lowerings each declaration compiles to agree.
12
+ // 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (087 applied to an in-memory DB): the coarse-key
13
+ // matrix, the member-PR temper, the terminal-fold bypass, and the companion `park_label`.
14
+ // 4. PARITY vs TODAY'S PHASE — for representative `deriveDeliveryPhase` outputs (what the plain Phase
15
+ // text cell showed), the derived stepper matches, and the actionable park label is retained.
16
+ // 5. ONE PROJECTION — the VIEW's (stage, state) equals `reduceFrontier` of the single derived branch,
17
+ // tying the render half to the canonical axis (app/stepAxis.ts).
18
+ // 6. PAGE BINDINGS — the delivery-graph pages bind the derived VIEW + the `pipeline` kind, not a plain
19
+ // `phase` text cell on the raw table.
20
+
21
+ import { readFileSync } from "node:fs";
22
+ import { DatabaseSync } from "node:sqlite";
23
+ import { test } from "node:test";
24
+ import { fileURLToPath } from "node:url";
25
+ import { assertReadModelParity, assertRollupParity, type ParityDb, type ParitySample, type ProcessInstanceState, type RollupInputs } from "@nanobpm/urban";
26
+ import { assert, assertEquals } from "#test-assert";
27
+ import { deriveDeliveryPhase } from "./deliveryGraphRun.ts";
28
+ import {
29
+ DELIVERY_GRAPH_READ_MODEL_BASE_ALIAS,
30
+ DELIVERY_GRAPH_READ_MODEL_DERIVED,
31
+ DELIVERY_GRAPH_ROLLUPS,
32
+ deliveryGraphPrCounts,
33
+ deliveryGraphReadModel,
34
+ PR_COUNTS_LOOKUP,
35
+ } from "./deliveryGraphReadModel.ts";
36
+ import { reduceFrontier, type StepKey } from "./stepAxis.ts";
37
+ import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.ts";
38
+
39
+ const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
40
+ const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
41
+
42
+ const READ_MODEL_MIGRATION = "087_delivery_graph_read_model.sql";
43
+
44
+ // A minimal in-memory DB carrying the base `delivery_graph_runs` / `pull_requests` shapes the VIEW
45
+ // reads, plus stand-ins for the managed `<table>__tracking` derived VIEWs urban provisions at mount
46
+ // (each re-exports `base.*` plus the terminal-folded `derived_status`). `derived_status_override` models
47
+ // the reconciler's derive edge (a terminated instance ⇒ `failed`/`abandoned` while base `status` stays
48
+ // frozen). Then migration 087 (the rollup VIEW + the read model VIEW) is applied.
49
+ function viewDb(): DatabaseSync {
50
+ const db = new DatabaseSync(":memory:");
51
+ db.exec(
52
+ `CREATE TABLE delivery_graph_runs (
53
+ run_key TEXT PRIMARY KEY, process_key TEXT, process_definition_id TEXT, digest TEXT,
54
+ status TEXT, side_effecting INTEGER, node_count INTEGER, human_node_count INTEGER,
55
+ side_effect_count INTEGER, title TEXT, phase TEXT, phase_node_id TEXT, human_labels TEXT,
56
+ created_at TEXT, updated_at TEXT, derived_status_override TEXT);
57
+ CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, root_request_key TEXT, status TEXT,
58
+ derived_status_override TEXT);`,
59
+ );
60
+ db.exec(
61
+ `CREATE VIEW delivery_graph_runs__tracking AS
62
+ SELECT d.*, COALESCE(d.derived_status_override, d.status) AS derived_status FROM delivery_graph_runs d;
63
+ CREATE VIEW pull_requests__tracking AS
64
+ SELECT p.*, COALESCE(p.derived_status_override, p.status) AS derived_status FROM pull_requests p;`,
65
+ );
66
+ db.exec(MIG(READ_MODEL_MIGRATION));
67
+ return db;
68
+ }
69
+
70
+ interface SampleRun {
71
+ status: string;
72
+ phase?: string | null;
73
+ phase_node_id?: string | null;
74
+ derived_status_override?: string | null;
75
+ }
76
+
77
+ function addRun(db: DatabaseSync, run_key: string, run: SampleRun): void {
78
+ db.prepare(
79
+ `INSERT INTO delivery_graph_runs
80
+ (run_key, process_key, process_definition_id, digest, status, side_effecting, node_count,
81
+ human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at,
82
+ updated_at, derived_status_override)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
84
+ ).run(
85
+ run_key,
86
+ `pk-${run_key}`,
87
+ "delivery-graph",
88
+ `digest-${run_key}`,
89
+ run.status,
90
+ 0,
91
+ 3,
92
+ 1,
93
+ 0,
94
+ `Graph ${run_key}`,
95
+ run.phase ?? null,
96
+ run.phase_node_id ?? null,
97
+ null,
98
+ "2026-01-01T00:00:00Z",
99
+ "2026-01-01T00:00:00Z",
100
+ run.derived_status_override ?? null,
101
+ );
102
+ }
103
+
104
+ function addPr(db: DatabaseSync, pr_key: string, root_request_key: string | null, status: string, derived_status_override: string | null = null): void {
105
+ db.prepare("INSERT INTO pull_requests (pr_key, root_request_key, status, derived_status_override) VALUES (?, ?, ?, ?)").run(
106
+ pr_key,
107
+ root_request_key,
108
+ status,
109
+ derived_status_override,
110
+ );
111
+ }
112
+
113
+ function projection(db: DatabaseSync, run_key: string): { stage: string; stage_state: string | null; park_label: string | null; status: string } {
114
+ const r = db
115
+ .prepare("SELECT stage, stage_state, park_label, status FROM delivery_graph_read_model WHERE run_key = ?")
116
+ .get(run_key) as { stage: string; stage_state: string | null; park_label: string | null; status: string };
117
+ return { stage: r.stage, stage_state: r.stage_state, park_label: r.park_label, status: r.status };
118
+ }
119
+
120
+ // A `ParityDb` over node:sqlite's `DatabaseSync` for the framework parity guards.
121
+ function parityDb(db: DatabaseSync): ParityDb {
122
+ return {
123
+ exec: (sql) => db.exec(sql),
124
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) => db.prepare(sql).all(...(params as never[])) as T[],
125
+ run: (sql, params: unknown[] = []) => {
126
+ const r = db.prepare(sql).run(...(params as never[]));
127
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
128
+ },
129
+ };
130
+ }
131
+
132
+ // ── 1. DRIFT GUARD ────────────────────────────────────────────────────────────────────────────────
133
+
134
+ test("DRIFT GUARD: migration 087 embeds the rollup VIEW DDL VERBATIM from rollup.viewDdl() (the VIEW cannot drift from defineRollup)", () => {
135
+ const sql = MIG(READ_MODEL_MIGRATION);
136
+ for (const rollup of DELIVERY_GRAPH_ROLLUPS) {
137
+ assert(
138
+ sql.includes(rollup.viewDdl()),
139
+ `migration ${READ_MODEL_MIGRATION} no longer embeds the declaration's VIEW DDL for rollup "${rollup.decl.name}" — ` +
140
+ `regenerate it from app/deliveryGraphReadModel.ts. Expected to contain:\n${rollup.viewDdl()}`,
141
+ );
142
+ assert(new RegExp(`DROP VIEW IF EXISTS ${rollup.decl.name};`).test(sql), `087 must DROP "${rollup.decl.name}" first`);
143
+ }
144
+ });
145
+
146
+ test("DRIFT GUARD: migration 087 embeds each derived column VERBATIM from deliveryGraphReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
147
+ const sql = MIG(READ_MODEL_MIGRATION);
148
+ const alias = DELIVERY_GRAPH_READ_MODEL_BASE_ALIAS;
149
+ for (const c of DELIVERY_GRAPH_READ_MODEL_DERIVED) {
150
+ const emitted = deliveryGraphReadModel.sqlSelectFor(c, { baseAlias: alias });
151
+ assert(
152
+ sql.includes(`${emitted} AS ${c}`),
153
+ `migration ${READ_MODEL_MIGRATION} no longer embeds the declaration's SQL for "${c}" — regenerate it from ` +
154
+ `app/deliveryGraphReadModel.ts. Expected to contain:\n ${emitted} AS ${c}`,
155
+ );
156
+ }
157
+ assert(/DROP VIEW IF EXISTS delivery_graph_read_model;/.test(sql), "087 must DROP the VIEW first");
158
+ assert(/CREATE VIEW delivery_graph_read_model AS/.test(sql), "087 must (re)create delivery_graph_read_model");
159
+ // Base identity pass-throughs — DERIVED from the REAL `delivery_graph_runs` schema (the migration
160
+ // chain applied to a throwaway DB), NOT a hand-kept list that could silently omit a column: the VIEW
161
+ // must re-export EVERY base column so the static pages↔schema contract guard sees them (and a future
162
+ // regeneration can't drop one without failing here). `status` is the one exception — it is exposed as
163
+ // the effective COALESCE below rather than a bare pass-through — so it is asserted separately.
164
+ const schemaDb = new DatabaseSync(":memory:");
165
+ applyMigrationSet(schemaDb, readMigrationSetFromDisk());
166
+ const baseColumns = (schemaDb.prepare("PRAGMA table_info(delivery_graph_runs)").all() as { name: string }[]).map((r) => r.name);
167
+ schemaDb.close();
168
+ assert(baseColumns.length > 0, "the migration chain must create the delivery_graph_runs base table");
169
+ for (const base of baseColumns) {
170
+ if (base === "status") continue;
171
+ assert(sql.includes(`dg.${base} AS ${base}`), `087 must pass base column "${base}" through the VIEW (derived from the real delivery_graph_runs schema)`);
172
+ }
173
+ assert(sql.includes("COALESCE(dg.derived_status, dg.status) AS status"), "087 must expose the effective status so the pages' Active/History filter tracks a terminated run");
174
+ assert(sql.includes("AS park_label"), "087 must carry the hand-authored park_label companion column");
175
+ // FROM/JOIN relations are DERIVED from the declaration (baseTable + lookup rollup name + join keys).
176
+ const alias2 = DELIVERY_GRAPH_READ_MODEL_BASE_ALIAS;
177
+ assert(sql.includes(`FROM ${deliveryGraphReadModel.decl.baseTable} ${alias2}`), `087's FROM must be the declaration's baseTable "${deliveryGraphReadModel.decl.baseTable}"`);
178
+ for (const lk of deliveryGraphReadModel.decl.lookups) {
179
+ const on = lk.on.map((k) => `${alias2}.${k.base} = ${lk.as}.${k.rollup}`).join(" AND ");
180
+ const join = `LEFT JOIN ${lk.rollup.decl.name} ${lk.as} ON ${on}`;
181
+ assert(sql.includes(join), `087 must LEFT JOIN the declaration's "${lk.rollup.decl.name}" lookup exactly as "${join}"`);
182
+ }
183
+ });
184
+
185
+ // ── 2. FRAMEWORK PARITY GUARD ──────────────────────────────────────────────────────────────────────
186
+
187
+ test("FRAMEWORK PARITY GUARD: delivery_graph_pr_counts VIEW and TS reduce agree (assertRollupParity)", () => {
188
+ const sampleSets: RollupInputs[] = [
189
+ {
190
+ pull_requests__tracking: [
191
+ { pr_key: "p0", root_request_key: "run-a", derived_status: "converging" },
192
+ { pr_key: "p1", root_request_key: "run-a", derived_status: "merged" },
193
+ { pr_key: "p2", root_request_key: "run-a", derived_status: "waiting_review" },
194
+ { pr_key: "p3", root_request_key: "run-b", derived_status: "abandoned" },
195
+ { pr_key: "p4", root_request_key: "run-b", derived_status: "converged" },
196
+ { pr_key: "p5", root_request_key: null, derived_status: "converging" },
197
+ ],
198
+ },
199
+ { pull_requests__tracking: [] },
200
+ ];
201
+ for (const rollup of DELIVERY_GRAPH_ROLLUPS) {
202
+ const db = new DatabaseSync(":memory:");
203
+ assertRollupParity(rollup, parityDb(db), sampleSets);
204
+ db.close();
205
+ }
206
+ });
207
+
208
+ test("FRAMEWORK PARITY GUARD: deliveryGraphReadModel's SQL and TS lowerings agree over the status × PR-in-flight matrix (assertReadModelParity)", () => {
209
+ const samples: ParitySample[] = [];
210
+ for (const status of ["awaiting-approval", "running", "done", "failed", "abandoned"]) {
211
+ for (const derived_status of [status, "failed"]) {
212
+ for (const prs_in_flight of [0, 1, 3]) {
213
+ samples.push({
214
+ baseRow: { run_key: "self", status, derived_status },
215
+ lookups: { [PR_COUNTS_LOOKUP]: [{ root_request_key: "self", prs_in_flight }] },
216
+ });
217
+ }
218
+ }
219
+ }
220
+ const db = new DatabaseSync(":memory:");
221
+ assertReadModelParity(deliveryGraphReadModel, parityDb(db), samples, { sql: { baseAlias: DELIVERY_GRAPH_READ_MODEL_BASE_ALIAS } });
222
+ db.close();
223
+ });
224
+
225
+ // ── 3. END-TO-END BEHAVIOUR on the real migration VIEW ────────────────────────────────────────────
226
+
227
+ test("the migration 087 VIEW maps the run lifecycle onto the coarse STAGE_KEYS bracket + render state", () => {
228
+ const db = viewDb();
229
+ // awaiting-approval (reserved legacy pre-dispatch rows) → the initial Requested bracket.
230
+ addRun(db, "await", { status: "awaiting-approval", phase: "Awaiting approval" });
231
+ // running, dispatch begun, no PR frontier → the deterministic initial Implementing.
232
+ addRun(db, "run-plain", { status: "running", phase: "Running" });
233
+ // terminal done → Done / ok (settles outright).
234
+ addRun(db, "done", { status: "done", phase: "Completed" });
235
+ // terminal failed / abandoned → the Done tail bracket / failed.
236
+ addRun(db, "failed", { status: "failed", phase: "Failed" });
237
+ addRun(db, "aband", { status: "abandoned", phase: "Failed" });
238
+
239
+ assertEquals(projection(db, "await"), { stage: "Requested", stage_state: null, park_label: null, status: "awaiting-approval" });
240
+ assertEquals(projection(db, "run-plain"), { stage: "Implementing", stage_state: null, park_label: null, status: "running" });
241
+ assertEquals(projection(db, "done"), { stage: "Done", stage_state: "ok", park_label: null, status: "done" });
242
+ assertEquals(projection(db, "failed"), { stage: "Done", stage_state: "failed", park_label: null, status: "failed" });
243
+ assertEquals(projection(db, "aband"), { stage: "Done", stage_state: "failed", park_label: null, status: "abandoned" });
244
+ db.close();
245
+ });
246
+
247
+ test("a running run with a member PR still in flight (root_request_key = run_key) tempers to Converging; all-terminal members stay Implementing", () => {
248
+ const db = viewDb();
249
+ addRun(db, "run-c", { status: "running", phase: "Running" });
250
+ addPr(db, "pr-open", "run-c", "converging");
251
+ addPr(db, "pr-merged", "run-c", "merged");
252
+
253
+ addRun(db, "run-i", { status: "running", phase: "Running" });
254
+ addPr(db, "pr-done", "run-i", "merged");
255
+ addPr(db, "pr-gone", "run-i", "abandoned");
256
+
257
+ assertEquals(projection(db, "run-c").stage, "Converging");
258
+ assertEquals(projection(db, "run-i").stage, "Implementing");
259
+ db.close();
260
+ });
261
+
262
+ test("an out-of-band-terminated member PR (derived_status='abandoned', base frozen) is NOT held in the live frontier", () => {
263
+ const db = viewDb();
264
+ addRun(db, "run-x", { status: "running", phase: "Running" });
265
+ // Base status frozen at 'converging' but the reconciler's derive edge reports 'abandoned' (resolved).
266
+ addPr(db, "pr-stale", "run-x", "converging", "abandoned");
267
+ assertEquals(projection(db, "run-x").stage, "Implementing");
268
+ db.close();
269
+ });
270
+
271
+ test("a DERIVE-ONLY terminated run (base status frozen at 'running', derived_status='failed') renders Done/failed, not wedged Implementing", () => {
272
+ const db = viewDb();
273
+ addRun(db, "run-t", { status: "running", phase: "Running", derived_status_override: "failed" });
274
+ const p = projection(db, "run-t");
275
+ assertEquals(p.stage, "Done");
276
+ assertEquals(p.stage_state, "failed");
277
+ // The effective status the pages filter on tracks the derive edge (so it drops to History).
278
+ assertEquals(p.status, "failed");
279
+ db.close();
280
+ });
281
+
282
+ test("park_label carries the actionable 'Parked on human node: <label>' text (only when parked); the pipeline stage is unaffected", () => {
283
+ const db = viewDb();
284
+ addRun(db, "run-park", { status: "running", phase: "Parked on human node: manual OTP publish", phase_node_id: "delivery-human-task__n3" });
285
+ const p = projection(db, "run-park");
286
+ assertEquals(p.park_label, "Parked on human node: manual OTP publish");
287
+ // A pre-PR parked frontier still pins the scalar activeField to the current bracket (Implementing).
288
+ assertEquals(p.stage, "Implementing");
289
+ // A non-park phase leaves park_label null.
290
+ addRun(db, "run-np", { status: "running", phase: "Running" });
291
+ assertEquals(projection(db, "run-np").park_label, null);
292
+ db.close();
293
+ });
294
+
295
+ // ── 4. PARITY vs TODAY'S PHASE (the acceptance parity) ────────────────────────────────────────────
296
+
297
+ test("the derived stepper matches TODAY's plain `phase` text (deriveDeliveryPhase) for representative runs, retaining the actionable park label", () => {
298
+ const db = viewDb();
299
+ const humanLabels = { "delivery-human-task__n3": "manual OTP publish" };
300
+ // Each fixture is what `pollDeliveryGraphPhase` records today; assert the derived stepper equals the
301
+ // step that plain phase text conveyed, and the park label is preserved when parked.
302
+ const cases: Array<{
303
+ key: string;
304
+ state: ProcessInstanceState | null;
305
+ tasks: Array<{ elementId?: string }>;
306
+ expectStage: StepKey;
307
+ expectState: string | null;
308
+ parked: boolean;
309
+ }> = [
310
+ { key: "running", state: "ACTIVE", tasks: [], expectStage: "Implementing", expectState: null, parked: false },
311
+ { key: "parked", state: "ACTIVE", tasks: [{ elementId: "delivery-human-task__n3" }], expectStage: "Implementing", expectState: null, parked: true },
312
+ { key: "completed", state: "COMPLETED", tasks: [], expectStage: "Done", expectState: "ok", parked: false },
313
+ { key: "terminated", state: "TERMINATED", tasks: [], expectStage: "Done", expectState: "failed", parked: false },
314
+ ];
315
+ for (const c of cases) {
316
+ const proj = deriveDeliveryPhase(c.state, c.tasks, humanLabels);
317
+ addRun(db, c.key, { status: proj.status, phase: proj.phase, phase_node_id: proj.phase_node_id });
318
+ const p = projection(db, c.key);
319
+ assertEquals(p.stage, c.expectStage, `stage for today's phase "${proj.phase}"`);
320
+ assertEquals(p.stage_state, c.expectState, `state for today's phase "${proj.phase}"`);
321
+ assertEquals(p.park_label, c.parked ? proj.phase : null, `park_label for today's phase "${proj.phase}"`);
322
+ }
323
+ db.close();
324
+ });
325
+
326
+ // ── 5. ONE PROJECTION — the render half agrees with the canonical axis reducer ────────────────────
327
+
328
+ test("the VIEW's (stage, state) equals reduceFrontier of the single derived branch (feature + delivery-graph collapse onto one axis)", () => {
329
+ const db = viewDb();
330
+ const settle = (status: string): string | null => (status === "done" ? "done" : status === "failed" || status === "abandoned" ? status : null);
331
+ addRun(db, "b-run", { status: "running", phase: "Running" });
332
+ addRun(db, "b-done", { status: "done", phase: "Completed" });
333
+ addRun(db, "b-failed", { status: "failed", phase: "Failed" });
334
+ for (const key of ["b-run", "b-done", "b-failed"]) {
335
+ const p = projection(db, key);
336
+ const status = key.slice(2);
337
+ const reduced = reduceFrontier([{ nodeId: key, step: p.stage as StepKey, terminal: settle(status) }]);
338
+ assertEquals(p.stage, reduced.step, `single-branch reduce step for ${key}`);
339
+ assertEquals(p.stage_state, reduced.state, `single-branch reduce state for ${key}`);
340
+ }
341
+ db.close();
342
+ });
343
+
344
+ // ── 6. PAGE BINDINGS ──────────────────────────────────────────────────────────────────────────────
345
+
346
+ function pipelineColumnsOf(page: unknown): Array<Record<string, unknown>> {
347
+ const cols: Array<Record<string, unknown>> = [];
348
+ const walk = (node: unknown): void => {
349
+ if (Array.isArray(node)) return node.forEach(walk);
350
+ if (node && typeof node === "object") {
351
+ const o = node as Record<string, unknown>;
352
+ if (o.kind === "pipeline") cols.push(o);
353
+ for (const v of Object.values(o)) walk(v);
354
+ }
355
+ };
356
+ walk(page);
357
+ return cols;
358
+ }
359
+
360
+ function datasourceTables(page: unknown): string[] {
361
+ const tables: string[] = [];
362
+ const walk = (node: unknown): void => {
363
+ if (Array.isArray(node)) return node.forEach(walk);
364
+ if (node && typeof node === "object") {
365
+ const o = node as Record<string, unknown>;
366
+ if (o.kind === "datasource" && typeof o.table === "string") tables.push(o.table);
367
+ for (const v of Object.values(o)) walk(v);
368
+ }
369
+ };
370
+ walk(page);
371
+ return tables;
372
+ }
373
+
374
+ for (const pageName of ["delivery-graphs.page.json", "delivery-graph-detail.page.json", "overview.page.json"]) {
375
+ test(`${pageName} binds the derived delivery_graph_read_model VIEW and a pipeline stepper (not a plain phase cell on the raw table)`, () => {
376
+ const page = PAGE(pageName);
377
+ const tables = datasourceTables(page);
378
+ assert(tables.includes("delivery_graph_read_model"), `${pageName} must bind delivery_graph_read_model`);
379
+ assert(!tables.includes("delivery_graph_runs"), `${pageName} must NOT bind the raw delivery_graph_runs table for the run grid`);
380
+
381
+ const pipelines = pipelineColumnsOf(page);
382
+ assert(pipelines.length >= 1, `${pageName} must render a pipeline stepper for the delivery graph`);
383
+ const p = pipelines.find((c) => c.activeField === "stage");
384
+ assert(p !== undefined, `${pageName} pipeline must bind activeField "stage"`);
385
+ assertEquals(p.stateField, "stage_state");
386
+ // v1 does NOT populate the aggregate's notInPathField (§4b §287-291).
387
+ assert(p.notInPathField === undefined, `${pageName} S7 pipeline must not bind notInPathField (deferred with the set-valued render)`);
388
+ // The six canonical STAGE_KEYS brackets, seeded from the axis.
389
+ const stages = p.stages as Array<{ key: string }>;
390
+ assertEquals(stages.map((s) => s.key), ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"]);
391
+ });
392
+ }
@@ -0,0 +1,166 @@
1
+ // app/deliveryGraphReadModel.ts — the delivery-graph progress projection onto the canonical step axis
2
+ // (ADR 0006 §4b, S7). DECLARED ONCE and compiled to BOTH backends via Urban's ADR-0065 primitives
3
+ // (`defineRollup` + `defineReadModel`, `@nanobpm/urban`), exactly like the feature/plan read models
4
+ // (app/featureReadModel.ts, app/planReadModel.ts are the exemplars).
5
+ //
6
+ // WHAT S7 UNIFIES. Before §4b the delivery-graph surface rendered `delivery_graph_runs.phase` — a bare
7
+ // text projection the user-task park poll (`pollDeliveryGraphPhase` → `deriveDeliveryPhase`) recomputes
8
+ // ("Running" / "Parked on human node: <label>" / "Completed" / "Failed") — on a DIFFERENT renderer from
9
+ // feature's `pipeline` stepper. S7 collapses feature + delivery-graph onto the ONE step axis
10
+ // (app/stepAxis.ts) rendered by the ONE `pipeline` kind. This model supplies the delivery-graph half:
11
+ // the `pipeline` column's `activeField` (`stage`, a `STAGE_KEYS` value) and `stateField` (`stage_state`)
12
+ // derived from the run's lifecycle, with the actionable park text carried alongside on a companion
13
+ // `park_label` field (so promoting the stepper does not drop the `Parked on human node: <label>` detail
14
+ // the plain Phase cell showed today).
15
+ //
16
+ // PER-SHAPE CORRELATION (§4b §241-278, S7 rollout §558-605). A delivery-graph run has NO aggregate
17
+ // `pr_key`; its downstream PRs attach via `pull_requests.root_request_key = delivery_graph_runs.run_key`
18
+ // (`app/lineage.ts` `collectRootPrs`). So this model reads the run row and, via the
19
+ // `delivery_graph_pr_counts` rollup keyed on `root_request_key`, whether any member PR is still in
20
+ // flight — the correlated-PR signal that tempers a `running` run to `Converging` (matching the shipped
21
+ // `deliveryOriginStage`, app/lineage.ts). This is NOT a `process_key` join: `pull_requests.process_key`
22
+ // is reassigned downstream (convergence, then merge), so it is not the run's identity.
23
+ //
24
+ // LIFECYCLE-STAGE FIDELITY, STATELESS COARSE KEY (§4b §413-449). `delivery_graph_runs` stores no stage
25
+ // column (only `phase`/park metadata, whose values like "Running" are NOT `STAGE_KEYS`), and at S7 a
26
+ // running node with no open user task exposes only a generic `Running` with no node id. So the `stage`
27
+ // is derived STATELESSLY from the run's current effective status + the member-PR-in-flight signal on
28
+ // every read (nothing is held; the read model persists no stage key), mapped onto a CONFIGURED
29
+ // `STAGE_KEYS` bracket — never a fabricated cell position or an unconfigured `activeField` label:
30
+ // - terminal `done` → `Done`, state `ok` (settles outright; does not wait on PRs).
31
+ // - terminal `failed`/`abandoned` → `Done`, state `failed` (the axis tail bracket).
32
+ // - `awaiting-approval` (reserved, pre-dispatch legacy rows) → `Requested` (the initial bracket).
33
+ // - `running` with a member PR still in flight → `Converging`.
34
+ // - `running` otherwise (dispatch begun, no PR frontier) → `Implementing` (the deterministic initial
35
+ // value for a freshly-running graph, §433).
36
+ // At S7 a graph collapses to this ONE coarse run-level step: `delivery_graph_runs` stores a single
37
+ // `phase` per run, not a per-branch topology, so the least-advanced-active frontier reduction
38
+ // (app/stepAxis.ts `reduceFrontier`) is DEFINED but not yet computable from this source — the genuine
39
+ // per-branch reduction is deferred to S8's element-instance read model. A single-track feature and a
40
+ // single-step graph both reduce trivially to their one branch.
41
+
42
+ import { and, caseWhen, col, countWhere, defineReadModel, defineRollup, type Expr, eq, fromTable, gt, isNotNull, lit, not, or, type ReadModel, type Rollup, rcol, when } from "@nanobpm/urban";
43
+ import { TERMINAL_STATUSES } from "./deliveryStatuses.ts";
44
+ import { PR_TRACKING_RELATION } from "./planRollups.ts";
45
+
46
+ /** The slice-PR relation the member-PR rollup folds over: the auto-provisioned
47
+ * `pull_requests__tracking` derived VIEW (ADR-0065), NOT the raw `pull_requests` table — so a member PR
48
+ * that was terminated out of band reads its terminal-folded `derived_status` (`abandoned`) and is not
49
+ * held in the live frontier (§4b S7 rollout §558-562). Re-exported from app/planRollups.ts — the ONE
50
+ * canonical declaration of the tracking-relation name — so this model shares that single source rather
51
+ * than reintroducing a drift surface if the relation is ever renamed. */
52
+ export { PR_TRACKING_RELATION };
53
+
54
+ /** The delivery-graph member-PR rollup: one row per `root_request_key` with the single count the
55
+ * `Implementing`→`Converging` temper reads — how many attached PRs are still IN FLIGHT (their
56
+ * terminal-folded `derived_status` is NOT in {@link TERMINAL_STATUSES}; a NULL status is not terminal,
57
+ * so a DB desync counts as in flight rather than wrongly settling the run). Keyed on `root_request_key`
58
+ * so the read-model lookup joins `delivery_graph_runs.run_key = root_request_key` (the per-shape
59
+ * correlation contract). The `isNotNull(root_request_key)` guard keeps unrooted PRs (their own roots)
60
+ * out of every run's count. */
61
+ export const deliveryGraphPrCounts: Rollup = defineRollup({
62
+ name: "delivery_graph_pr_counts",
63
+ source: fromTable(PR_TRACKING_RELATION),
64
+ groupBy: ["root_request_key"],
65
+ aggregates: {
66
+ prs_in_flight: countWhere(
67
+ and(
68
+ isNotNull(col("root_request_key")),
69
+ not(or(...TERMINAL_STATUSES.map((s) => eq(col("derived_status"), lit(s))))),
70
+ ),
71
+ ),
72
+ },
73
+ });
74
+
75
+ /** Every delivery-graph rollup, in managed-VIEW dependency order. The migration emits their VIEW DDL in
76
+ * this order; the parity guard iterates it. */
77
+ export const DELIVERY_GRAPH_ROLLUPS: readonly Rollup[] = [deliveryGraphPrCounts];
78
+
79
+ /** The base table the read model reads: the auto-provisioned `delivery_graph_runs__tracking` derived
80
+ * VIEW (ADR-0065), NOT the raw `delivery_graph_runs` table. It re-exports `delivery_graph_runs.*` plus a
81
+ * terminal-folded `derived_status` (an out-of-band-terminated run reads `failed` per the
82
+ * `instanceTracking` `onTerminated` edge, else the base `status` — which `pollDeliveryGraphPhase` owns
83
+ * the `COMPLETED → done` reconciliation for). The status-classifying derivation reads `derived_status`,
84
+ * so a terminated run renders `Done` instead of freezing at `Implementing`/`Converging`. */
85
+ export const DELIVERY_GRAPH_READ_MODEL_BASE_TABLE = "delivery_graph_runs__tracking";
86
+
87
+ /** The base alias the managed VIEW gives `delivery_graph_runs__tracking` — pinned so the emitted
88
+ * derived-column SQL (`dg."col"`) matches the migration exactly (the drift guard compares this alias). */
89
+ export const DELIVERY_GRAPH_READ_MODEL_BASE_ALIAS = "dg";
90
+
91
+ /** The rollup-lookup alias `rcol(...)` reads under — the `LEFT JOIN delivery_graph_pr_counts pc ON
92
+ * dg.run_key = pc.root_request_key` target. Pinned so the emitted SQL and the migration's JOIN agree. */
93
+ export const PR_COUNTS_LOOKUP = "pc";
94
+
95
+ /** The effective (terminal-folded) status column the derivation classifies on — the tracking VIEW's
96
+ * `derived_status`. Single source of truth for the name so the derivation can't drift from it. */
97
+ export const EFFECTIVE_STATUS_COLUMN = "derived_status";
98
+
99
+ const ds = col(EFFECTIVE_STATUS_COLUMN);
100
+
101
+ /** Whether a member PR of the run is still in flight — `prs_in_flight > 0` on the rollup lookup (0 on a
102
+ * LEFT-JOIN miss, so a run with no attached PRs reads not-in-flight). Tempers a `running` run to
103
+ * `Converging`, matching the shipped `deliveryOriginStage` (app/lineage.ts). */
104
+ const memberPrInFlight: Expr = gt(rcol(PR_COUNTS_LOOKUP, "prs_in_flight"), lit(0));
105
+
106
+ /**
107
+ * The derived `stage` — a CONFIGURED `STAGE_KEYS` value (never a raw `phase` string), from the stateless
108
+ * coarse-key rule (see the module header). Terminal statuses settle the step outright (before the PR
109
+ * check), so `done` does not block on an open PR; a live `running` frontier with a member PR in flight
110
+ * reads `Converging`, else `Implementing` (the deterministic initial value for a freshly-running graph).
111
+ * `awaiting-approval` (reserved legacy pre-dispatch rows) maps to the initial `Requested` bracket.
112
+ */
113
+ const stage: Expr = caseWhen(
114
+ [
115
+ when(eq(ds, lit("done")), lit("Done")),
116
+ when(or(eq(ds, lit("failed")), eq(ds, lit("abandoned"))), lit("Done")),
117
+ when(eq(ds, lit("awaiting-approval")), lit("Requested")),
118
+ when(memberPrInFlight, lit("Converging")),
119
+ ],
120
+ lit("Implementing"),
121
+ );
122
+
123
+ /**
124
+ * The active step's render state in the `pipeline` column's vocabulary (`ok`/`failed`/`blocked`/null):
125
+ * a `done` graph is a success terminal (`ok`), a `failed`/`abandoned` graph is a failed terminal
126
+ * (`failed`), else in progress (`null`). A delivery-graph run has no `blocked` terminal in its lifecycle
127
+ * union, so that tier never arises here. Reuses the canonical terminal tiers (app/stepAxis.ts).
128
+ */
129
+ const stageState: Expr = caseWhen(
130
+ [
131
+ when(eq(ds, lit("done")), lit("ok")),
132
+ when(or(eq(ds, lit("failed")), eq(ds, lit("abandoned"))), lit("failed")),
133
+ ],
134
+ lit(null),
135
+ );
136
+
137
+ /** The keys of {@link deliveryGraphReadModel}'s DERIVED columns, in the order the migration emits them.
138
+ * Base columns are identity pass-throughs (listed in the migration directly); `park_label` is a
139
+ * hand-authored display column over the base `phase`/`phase_node_id` (no TS twin). */
140
+ export const DELIVERY_GRAPH_READ_MODEL_DERIVED = ["stage", "stage_state"] as const;
141
+ export type DeliveryGraphReadModelDerivedColumn = (typeof DELIVERY_GRAPH_READ_MODEL_DERIVED)[number];
142
+
143
+ /**
144
+ * The declare-once `delivery_graph_read_model` derived columns. `selectBaseColumns: false` because the
145
+ * base columns are plain identity pass-throughs enumerated in the migration (so the static pages↔schema
146
+ * contract guard, which reads a VIEW's columns off an aliased select-list, sees them). Both the
147
+ * migration VIEW (`sqlSelectFor`, drift-guarded) and the runtime TS oracle (`fnFor`) are generated from
148
+ * THIS single declaration; the member-PR rollup lookup supplies the in-flight signal the CASE consumes.
149
+ */
150
+ export const deliveryGraphReadModel: ReadModel = defineReadModel({
151
+ name: "delivery_graph_read_model",
152
+ baseTable: DELIVERY_GRAPH_READ_MODEL_BASE_TABLE,
153
+ selectBaseColumns: false,
154
+ lookups: [
155
+ {
156
+ as: PR_COUNTS_LOOKUP,
157
+ rollup: deliveryGraphPrCounts,
158
+ on: [{ base: "run_key", rollup: "root_request_key" }],
159
+ defaults: { prs_in_flight: 0 },
160
+ },
161
+ ],
162
+ derive: {
163
+ stage,
164
+ stage_state: stageState,
165
+ },
166
+ });