@nanobpm/nano-workforce 0.143.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 +6 -0
- package/app/deliveryGraphReadModel.test.ts +392 -0
- package/app/deliveryGraphReadModel.ts +166 -0
- package/app/stepAxis.test.ts +142 -0
- package/app/stepAxis.ts +217 -0
- package/db/migrations/087_delivery_graph_read_model.sql +75 -0
- package/package.json +1 -1
- package/pages/delivery-graph-detail.page.json +18 -2
- package/pages/delivery-graphs.page.json +25 -8
- package/pages/overview.page.json +18 -2
- package/scripts/pages-contract.test.ts +15 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.143.0](https://github.com/nanobpm/nano-workforce/compare/v0.142.0...v0.143.0) (2026-08-25)
|
|
2
8
|
|
|
3
9
|
### 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
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Unit coverage for the ONE canonical progress step axis (app/stepAxis.ts) — the single source of the
|
|
2
|
+
// derived stepper's vocabulary, cell→step mapping, terminal-tier normalization, and the deterministic
|
|
3
|
+
// parallel-frontier reduction (ADR 0006 §4b, issue #541 / S7).
|
|
4
|
+
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import { STAGE_DONE_STATUSES, STAGE_KEYS } from "./stage.ts";
|
|
8
|
+
import {
|
|
9
|
+
CELL_STEP,
|
|
10
|
+
DONE_TERMINAL,
|
|
11
|
+
type FrontierBranch,
|
|
12
|
+
INITIAL_STEP,
|
|
13
|
+
reduceFrontier,
|
|
14
|
+
STEP_KEYS,
|
|
15
|
+
stepOrdinal,
|
|
16
|
+
terminalTier,
|
|
17
|
+
TERMINAL_STEP,
|
|
18
|
+
} from "./stepAxis.ts";
|
|
19
|
+
|
|
20
|
+
test("STEP_KEYS is SEEDED from STAGE_KEYS — the axis cannot fork across surfaces", () => {
|
|
21
|
+
assertEquals([...STEP_KEYS], [...STAGE_KEYS]);
|
|
22
|
+
// The two lifecycle bookends are the head/tail of the shared axis.
|
|
23
|
+
assertEquals(INITIAL_STEP, STAGE_KEYS[0]);
|
|
24
|
+
assertEquals(INITIAL_STEP, "Requested");
|
|
25
|
+
assertEquals(TERMINAL_STEP, STAGE_KEYS[STAGE_KEYS.length - 1]);
|
|
26
|
+
assertEquals(TERMINAL_STEP, "Done");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the explicit cell→step mapping collapses every process cell onto an existing STAGE_KEYS bracket (no new axis entries)", () => {
|
|
30
|
+
// The three executable cells map to their lifecycle bracket; the interstitial wait/human/escalation
|
|
31
|
+
// cells HOLD at the host bracket rather than owning a distinct step.
|
|
32
|
+
assertEquals(CELL_STEP.implement, "Implementing");
|
|
33
|
+
assertEquals(CELL_STEP.converge, "Converging");
|
|
34
|
+
assertEquals(CELL_STEP.merge, "Merging");
|
|
35
|
+
assertEquals(CELL_STEP.wait, "Implementing");
|
|
36
|
+
assertEquals(CELL_STEP.human, "Converging");
|
|
37
|
+
assertEquals(CELL_STEP.escalation, "Converging");
|
|
38
|
+
// Every mapped bracket is a real axis key — v1 adds no stages, so the pipeline renderer is unchanged.
|
|
39
|
+
for (const step of Object.values(CELL_STEP)) {
|
|
40
|
+
assert(STEP_KEYS.includes(step), `cell step "${step}" is not a STAGE_KEYS member`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("stepOrdinal gives the axis total order the frontier reduction compares advancement by", () => {
|
|
45
|
+
assertEquals(stepOrdinal("Requested"), 0);
|
|
46
|
+
assertEquals(stepOrdinal("Implementing"), 1);
|
|
47
|
+
assertEquals(stepOrdinal("PR open"), 2);
|
|
48
|
+
assertEquals(stepOrdinal("Converging"), 3);
|
|
49
|
+
assertEquals(stepOrdinal("Merging"), 4);
|
|
50
|
+
assertEquals(stepOrdinal("Done"), 5);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("terminalTier reuses the shipped stage_state tiers (converged/merged/done→ok, blocked distinct, failed/skipped/abandoned→failed, active→null)", () => {
|
|
54
|
+
assertEquals(terminalTier("merged"), "ok");
|
|
55
|
+
assertEquals(terminalTier("converged"), "ok");
|
|
56
|
+
assertEquals(terminalTier("done"), "ok");
|
|
57
|
+
assertEquals(terminalTier("blocked"), "blocked");
|
|
58
|
+
assertEquals(terminalTier("failed"), "failed");
|
|
59
|
+
assertEquals(terminalTier("skipped"), "failed");
|
|
60
|
+
assertEquals(terminalTier("abandoned"), "failed");
|
|
61
|
+
assertEquals(terminalTier("running"), null);
|
|
62
|
+
assertEquals(terminalTier("converging"), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("terminalTier's partition is DERIVED from STAGE_DONE_STATUSES — every canonical terminal is tiered exactly once (drift guard)", () => {
|
|
66
|
+
// The stepAxis module already throws at load if the partition drifts from STAGE_DONE_STATUSES; assert
|
|
67
|
+
// the coupling here too so a reviewer sees the invariant. Every STAGE_DONE_STATUSES member gets a
|
|
68
|
+
// non-null tier, and the only status tiered OUTSIDE that canonical set is the delivery-graph `done`.
|
|
69
|
+
for (const status of STAGE_DONE_STATUSES) {
|
|
70
|
+
assert(terminalTier(status) !== null, `STAGE_DONE_STATUSES member "${status}" must be tiered by terminalTier`);
|
|
71
|
+
}
|
|
72
|
+
assertEquals(terminalTier(DONE_TERMINAL), "ok");
|
|
73
|
+
assert(!STAGE_DONE_STATUSES.includes(DONE_TERMINAL), "`done` is the S7 canonical success value, tiered on top of STAGE_DONE_STATUSES (not a member of it)");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── reduceFrontier — the deterministic parallel-frontier rollup (§4b §280-332) ────────────────────
|
|
77
|
+
|
|
78
|
+
const branch = (nodeId: string, step: FrontierBranch["step"], terminal: string | null = null): FrontierBranch => ({ nodeId, step, terminal });
|
|
79
|
+
|
|
80
|
+
test("a single branch reduces trivially to itself (the feature + S7 delivery-graph coarse case)", () => {
|
|
81
|
+
assertEquals(reduceFrontier([branch("n0", "Implementing")]), { step: "Implementing", state: null });
|
|
82
|
+
assertEquals(reduceFrontier([branch("n0", "Done", "done")]), { step: "Done", state: "ok" });
|
|
83
|
+
assertEquals(reduceFrontier([branch("n0", "Done", "failed")]), { step: "Done", state: "failed" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("all-active frontier reduces to the LEAST-ADVANCED active branch (never further than the slowest in-flight branch)", () => {
|
|
87
|
+
const r = reduceFrontier([branch("a", "Merging"), branch("b", "Implementing"), branch("c", "Converging")]);
|
|
88
|
+
assertEquals(r, { step: "Implementing", state: null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("all-active ties on step break deterministically by stable node id", () => {
|
|
92
|
+
const r = reduceFrontier([branch("z", "Converging"), branch("a", "Converging")]);
|
|
93
|
+
assertEquals(r, { step: "Converging", state: null });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("MIXED with only SUCCESS terminals + active branches reduces to the least-advanced ACTIVE branch (terminal branches are past, not 'still blocked on')", () => {
|
|
97
|
+
const r = reduceFrontier([branch("done1", "Done", "merged"), branch("active1", "Converging"), branch("active2", "Merging")]);
|
|
98
|
+
assertEquals(r, { step: "Converging", state: null });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("MIXED: a NON-SUCCESS terminal (failed) takes precedence over in-flight siblings — the aggregate renders that branch's step + failed state", () => {
|
|
102
|
+
const r = reduceFrontier([branch("active", "Implementing"), branch("bad", "Converging", "failed"), branch("done", "Done", "merged")]);
|
|
103
|
+
assertEquals(r, { step: "Converging", state: "failed" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("MIXED: a blocked terminal surfaces as the DISTINCT blocked render state (operator-actionable), not masked by an active sibling", () => {
|
|
107
|
+
const r = reduceFrontier([branch("active", "Merging"), branch("stuck", "Implementing", "blocked")]);
|
|
108
|
+
assertEquals(r, { step: "Implementing", state: "blocked" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("MULTIPLE non-success terminals tie-break by earliest terminal step, then stable node id", () => {
|
|
112
|
+
// Two failed branches at different steps → earliest step wins.
|
|
113
|
+
assertEquals(
|
|
114
|
+
reduceFrontier([branch("a", "Converging", "failed"), branch("b", "Implementing", "blocked"), branch("act", "Merging")]),
|
|
115
|
+
{ step: "Implementing", state: "blocked" },
|
|
116
|
+
);
|
|
117
|
+
// Two non-success terminals at the SAME step → stable node id wins (and its own render state).
|
|
118
|
+
assertEquals(
|
|
119
|
+
reduceFrontier([branch("z", "Converging", "failed"), branch("a", "Converging", "blocked")]),
|
|
120
|
+
{ step: "Converging", state: "blocked" },
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("ALL-TERMINAL, all success → done (the axis tail, ok)", () => {
|
|
125
|
+
const r = reduceFrontier([branch("a", "Converging", "converged"), branch("b", "Merging", "merged")]);
|
|
126
|
+
assertEquals(r, { step: "Done", state: "ok" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("ALL-TERMINAL with any non-success → earliest non-success terminal step (same tie-break)", () => {
|
|
130
|
+
const r = reduceFrontier([branch("a", "Merging", "merged"), branch("b", "Converging", "failed"), branch("c", "Implementing", "blocked")]);
|
|
131
|
+
assertEquals(r, { step: "Implementing", state: "blocked" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("reduceFrontier throws on an empty frontier — a unit always has at least its own branch", () => {
|
|
135
|
+
let threw = false;
|
|
136
|
+
try {
|
|
137
|
+
reduceFrontier([]);
|
|
138
|
+
} catch {
|
|
139
|
+
threw = true;
|
|
140
|
+
}
|
|
141
|
+
assert(threw, "expected reduceFrontier([]) to throw");
|
|
142
|
+
});
|
package/app/stepAxis.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// app/stepAxis.ts — the ONE canonical progress step axis for the derived stepper (ADR 0006 §4b, S7).
|
|
2
|
+
//
|
|
3
|
+
// §4b collapses the unit's three progress projections (feature `deriveStage`, epic write-time
|
|
4
|
+
// `epic_phase`, delivery-graph `pollDeliveryGraphPhase`) onto ONE derivation over a single step axis,
|
|
5
|
+
// rendered by the ONE `pipeline` renderer kind. This module owns the canonical definition of that
|
|
6
|
+
// axis: the ordered steps, the explicit cell→step mapping, the terminal-tier normalization, and the
|
|
7
|
+
// deterministic parallel-frontier reduction. Feature (`app/stage.ts` / `app/featureReadModel.ts`) and
|
|
8
|
+
// delivery-graph (`app/deliveryGraphReadModel.ts`) both project onto it; there is no per-surface
|
|
9
|
+
// re-declaration of the step vocabulary.
|
|
10
|
+
//
|
|
11
|
+
// SEEDED FROM `STAGE_KEYS`, OWNS THE MAPPING (§4b §217-232). `STAGE_KEYS` (app/stage.ts) is the closest
|
|
12
|
+
// EXISTING projection of the axis but it is NOT literally a clean cell sequence — it mixes lifecycle
|
|
13
|
+
// states (`Requested` / `PR open` / `Done`) with process cells (`implement` / `converge` / `merge`),
|
|
14
|
+
// and hosts interstitial `wait` / `human` / `escalation` cells. So this module SEEDS `STEP_KEYS` from
|
|
15
|
+
// `STAGE_KEYS` (the single source of truth for the six brackets) but adds the thing `STAGE_KEYS` lacks:
|
|
16
|
+
// an explicit map of which cell entry/exit each step corresponds to, and how the interstitial cells
|
|
17
|
+
// collapse into an existing bracket. v1 leaves the two existing axis consumers physically in place (the
|
|
18
|
+
// exported `STAGE_KEYS` and the static `stages` array in `pages/feature.page.json`) and only SEEDS this
|
|
19
|
+
// mapping from them — deriving/retiring those duplicates is a flagged follow-up, not S7.
|
|
20
|
+
//
|
|
21
|
+
// LIFECYCLE-STAGE FIDELITY ONLY (S7). Per §4b, S7 renders the coarse LIFECYCLE stage, not a per-cell
|
|
22
|
+
// position: even feature is not per-cell today (`deriveStage` collapses a readiness-probe/timer park or
|
|
23
|
+
// an active `implement-task` all to `Implementing`). True mid-cell / per-node resolution is the S8
|
|
24
|
+
// element-instance source (#542, #473). The interstitial-cell mapping below therefore documents the
|
|
25
|
+
// bracket each cell COLLAPSES into; it does not add per-cell steps.
|
|
26
|
+
|
|
27
|
+
import { STAGE_DONE_STATUSES, STAGE_KEYS, type StageKey, type StageState } from "./stage.ts";
|
|
28
|
+
|
|
29
|
+
/** The canonical ordered step axis — SEEDED from `STAGE_KEYS` (app/stage.ts), the single source of
|
|
30
|
+
* truth for the six pipeline brackets: Requested → Implementing → PR open → Converging → Merging →
|
|
31
|
+
* Done. This module owns the cell→step MAPPING onto these keys; the keys themselves stay sourced from
|
|
32
|
+
* `STAGE_KEYS` so the axis cannot fork across surfaces. */
|
|
33
|
+
export const STEP_KEYS: readonly StageKey[] = STAGE_KEYS;
|
|
34
|
+
export type StepKey = StageKey;
|
|
35
|
+
|
|
36
|
+
/** The deterministic INITIAL step for a pre-run / dispatch-pending / first-observation unit — the head
|
|
37
|
+
* of the axis (`Requested`, `STAGE_KEYS[0]`). Pins the scalar `activeField` so it is never undefined on
|
|
38
|
+
* a first observation with no prior lifecycle key (§4b §431-449). */
|
|
39
|
+
export const INITIAL_STEP: StepKey = STEP_KEYS[0];
|
|
40
|
+
|
|
41
|
+
/** The TERMINAL step — the tail of the axis (`Done`). A terminal unit pins its `activeField` here
|
|
42
|
+
* outright (with an `ok`/`failed` render state) so it can never render an undefined/invalid active
|
|
43
|
+
* stage (§4b §436-438). */
|
|
44
|
+
export const TERMINAL_STEP: StepKey = STEP_KEYS[STEP_KEYS.length - 1];
|
|
45
|
+
|
|
46
|
+
/** The process-cell vocabulary of the composed unit (§2/S4): the three executable pipeline cells
|
|
47
|
+
* (`implement` / `converge` / `merge`) plus the interstitial `wait` / `human` / `escalation` cells that
|
|
48
|
+
* can be inserted around them. */
|
|
49
|
+
export type ProcessCell = "implement" | "converge" | "merge" | "wait" | "human" | "escalation";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The explicit **cell → step** mapping (§4b's first deliverable). Each executable cell ENTERS its
|
|
53
|
+
* bracket when the token arrives and EXITS it when the token advances to the next cell's bracket; the
|
|
54
|
+
* three lifecycle markers bracket the cell run — a token before `implement` reads `Requested`, raising
|
|
55
|
+
* the PR on `implement` exit enters `PR open`, and a merged/terminal token reads `Done`.
|
|
56
|
+
*
|
|
57
|
+
* The interstitial `wait` / `human` / `escalation` cells do **not** own a distinct step — they HOLD the
|
|
58
|
+
* frontier at the bracket of the cell they interrupt (a human gate mid-convergence reads `Converging`,
|
|
59
|
+
* a wait-gate before implementation reads `Implementing`), collapsing into an existing `STAGE_KEYS`
|
|
60
|
+
* bracket rather than extending the axis. This is why the `pipeline` renderer needs no new stages for
|
|
61
|
+
* v1: every cell maps onto one of the six existing keys.
|
|
62
|
+
*/
|
|
63
|
+
export const CELL_STEP: Readonly<Record<ProcessCell, StepKey>> = {
|
|
64
|
+
implement: "Implementing",
|
|
65
|
+
converge: "Converging",
|
|
66
|
+
merge: "Merging",
|
|
67
|
+
// Interstitial cells collapse into the host bracket (hold, do not advance).
|
|
68
|
+
wait: "Implementing",
|
|
69
|
+
human: "Converging",
|
|
70
|
+
escalation: "Converging",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The zero-based ordinal of a step on the axis — the total order the frontier reduction compares
|
|
74
|
+
* "advancement" by. `-1` for an unknown key (defensive; every derived step is a `STEP_KEYS` member). */
|
|
75
|
+
export function stepOrdinal(step: StepKey): number {
|
|
76
|
+
return STEP_KEYS.indexOf(step);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The renderer's terminal render-state tiers, reusing the SHIPPED `featureReadModel` `stage_state`
|
|
80
|
+
* basis (`STAGE_DONE_STATUSES` + the `stage_state` CASE, app/featureReadModel.ts) rather than inventing
|
|
81
|
+
* a second mapping (derivation-over-duplication, §4b §296-308):
|
|
82
|
+
* - `ok` — a SUCCESS terminal: `merged` / `converged` / the new canonical `done`.
|
|
83
|
+
* - `blocked` — the renderer's DISTINCT operator-actionable `blocked` terminal (never folded away).
|
|
84
|
+
* - `failed` — a FAILED terminal: `failed` / `skipped` / `abandoned`.
|
|
85
|
+
* - `null` — not terminal (in progress).
|
|
86
|
+
* Note this is the PER-CELL / per-PR tier: `converged` is a success terminal here. The EPIC rollup's
|
|
87
|
+
* shape-aware predicate (`converged` is resolved-not-landed) is applied by the caller, not here. */
|
|
88
|
+
export type TerminalTier = "ok" | "failed" | "blocked";
|
|
89
|
+
|
|
90
|
+
/** The delivery-graph canonical SUCCESS terminal (app/deliveryGraphReadModel.ts). `STAGE_DONE_STATUSES`
|
|
91
|
+
* predates the S7 axis and does NOT include it, so it is the one status tiered ON TOP of the shared
|
|
92
|
+
* feature basis below (special-cased per the ADR §4b `done` value). */
|
|
93
|
+
export const DONE_TERMINAL = "done";
|
|
94
|
+
|
|
95
|
+
/** The per-cell terminal PARTITION — the SAME basis as featureReadModel's `stage_state` CASE
|
|
96
|
+
* (app/featureReadModel.ts: `merged`/`converged`→ok, `blocked`→blocked, `failed`/`skipped`/`abandoned`→
|
|
97
|
+
* failed). It is NOT a second hand-kept copy: every member is drawn from the shipped
|
|
98
|
+
* `STAGE_DONE_STATUSES`, and the exhaustiveness guard below fails at module load if this partition ever
|
|
99
|
+
* stops covering that canonical set EXACTLY — so a terminal status added to `STAGE_DONE_STATUSES` cannot
|
|
100
|
+
* silently fall through untiered (drift becomes a hard error, not a wrong render). */
|
|
101
|
+
const SUCCESS_TERMINALS: readonly string[] = ["merged", "converged"];
|
|
102
|
+
const BLOCKED_TERMINAL = "blocked";
|
|
103
|
+
const FAILED_TERMINALS: readonly string[] = ["failed", "skipped", "abandoned"];
|
|
104
|
+
|
|
105
|
+
// Structural coupling to the single source of truth: the partition must tier EXACTLY the members of
|
|
106
|
+
// STAGE_DONE_STATUSES (the delivery-graph `done` value is tiered separately, so it is excluded here).
|
|
107
|
+
const _tiered = new Set<string>([...SUCCESS_TERMINALS, BLOCKED_TERMINAL, ...FAILED_TERMINALS]);
|
|
108
|
+
const _canonical = new Set<string>(STAGE_DONE_STATUSES);
|
|
109
|
+
if (_tiered.size !== _canonical.size || [..._canonical].some((s) => !_tiered.has(s))) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"stepAxis terminalTier partition drifted from STAGE_DONE_STATUSES — every terminal status must be " +
|
|
112
|
+
`tiered exactly once. tiered=[${[..._tiered].sort().join(",")}] canonical=[${[..._canonical].sort().join(",")}]`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function terminalTier(status: string): TerminalTier | null {
|
|
117
|
+
if (status === DONE_TERMINAL || SUCCESS_TERMINALS.includes(status)) return "ok";
|
|
118
|
+
if (status === BLOCKED_TERMINAL) return "blocked";
|
|
119
|
+
if (FAILED_TERMINALS.includes(status)) return "failed";
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A `TerminalTier` is a NON-SUCCESS terminal (`failed` / `blocked`) iff it is operator-actionable —
|
|
124
|
+
* the thing an in-flight sibling must not mask. `ok` (success) is the only success terminal. */
|
|
125
|
+
export function isNonSuccessTerminal(tier: TerminalTier): boolean {
|
|
126
|
+
return tier === "failed" || tier === "blocked";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Map a terminal tier onto the `pipeline` column's `stateField` vocabulary (`ok`/`failed`/`blocked`).
|
|
130
|
+
* The raw canonical `done` never reaches the renderer verbatim (any other string silently degrades to
|
|
131
|
+
* in-progress), so a success terminal renders as `ok`. Total over the three tiers. */
|
|
132
|
+
export function tierRenderState(tier: TerminalTier): StageState {
|
|
133
|
+
return tier;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One branch of an aggregate's active frontier, projected onto the canonical axis. `terminal` is the
|
|
137
|
+
* branch's canonical terminal status when it has settled (`merged`/`converged`/`done`/`blocked`/
|
|
138
|
+
* `failed`/`skipped`/`abandoned`), or `null` while the branch is still active at `step`. `nodeId` is
|
|
139
|
+
* the stable identity used as the deterministic tie-break. */
|
|
140
|
+
export interface FrontierBranch {
|
|
141
|
+
nodeId: string;
|
|
142
|
+
step: StepKey;
|
|
143
|
+
terminal: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The reduced scalar the `pipeline` column binds: one `STAGE_KEYS` step plus its render state. */
|
|
147
|
+
export interface ReducedFrontier {
|
|
148
|
+
step: StepKey;
|
|
149
|
+
state: StageState;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Deterministic tie-break: earliest step ordinal, then stable `nodeId`. */
|
|
153
|
+
function earliest(a: FrontierBranch, b: FrontierBranch): FrontierBranch {
|
|
154
|
+
const da = stepOrdinal(a.step);
|
|
155
|
+
const db = stepOrdinal(b.step);
|
|
156
|
+
if (da !== db) return da < db ? a : b;
|
|
157
|
+
return a.nodeId <= b.nodeId ? a : b;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reduce a parallel active frontier to ONE deterministic step for the scalar `pipeline` `activeField`
|
|
162
|
+
* (§4b §280-332). The `pipeline` column binds a single scalar, but an N-node/parallel DAG (epic waves,
|
|
163
|
+
* delivery graphs) can occupy incomparable cells at once, so the frontier is reduced deterministically:
|
|
164
|
+
*
|
|
165
|
+
* - A **non-success terminal** (`failed` / `blocked`) takes PRECEDENCE in every case — it is an
|
|
166
|
+
* operator-actionable signal an in-flight sibling must not mask. Among multiple non-success
|
|
167
|
+
* terminals the tie-break is earliest terminal step, then stable node id; the aggregate renders at
|
|
168
|
+
* that branch's step with that terminal's render state (`failed` / `blocked`).
|
|
169
|
+
* - Otherwise, if any branch is still ACTIVE, reduce to the **least-advanced active branch** (the
|
|
170
|
+
* "still blocked on" read) with an in-progress state — the aggregate never renders further along
|
|
171
|
+
* than its slowest in-flight branch. Terminal (success) branches are past, not "still blocked on".
|
|
172
|
+
* - Otherwise every branch is a SUCCESS terminal → `done` (the axis tail, `ok`).
|
|
173
|
+
*
|
|
174
|
+
* The shape-aware epic success predicate (`converged` is resolved-not-landed for an epic rollup, not a
|
|
175
|
+
* success terminal) is applied by the CALLER when it classifies each branch's `terminal`; this reducer
|
|
176
|
+
* treats whatever terminal it is handed per the per-cell tier. A single-branch unit (feature, and a
|
|
177
|
+
* delivery-graph at S7's coarse run-level fidelity) reduces trivially to that branch.
|
|
178
|
+
*
|
|
179
|
+
* Throws on an empty frontier — a unit always has at least its own (initial) branch; an empty input is
|
|
180
|
+
* a caller bug, not a renderable state.
|
|
181
|
+
*/
|
|
182
|
+
export function reduceFrontier(branches: readonly FrontierBranch[]): ReducedFrontier {
|
|
183
|
+
if (branches.length === 0) {
|
|
184
|
+
throw new Error("reduceFrontier: empty frontier — a unit always has at least one branch");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const nonSuccess: FrontierBranch[] = [];
|
|
188
|
+
const active: FrontierBranch[] = [];
|
|
189
|
+
for (const b of branches) {
|
|
190
|
+
if (b.terminal === null) {
|
|
191
|
+
active.push(b);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const tier = terminalTier(b.terminal);
|
|
195
|
+
// An unrecognised terminal degrades to a failed-tier signal (defensive; the caller passes canonical
|
|
196
|
+
// union terminals) so it is never silently masked by an in-flight sibling.
|
|
197
|
+
if (tier === null || isNonSuccessTerminal(tier)) nonSuccess.push(b);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (nonSuccess.length > 0) {
|
|
201
|
+
const pick = nonSuccess.reduce(earliest);
|
|
202
|
+
const tier = terminalTier(pick.terminal ?? "") ?? "failed";
|
|
203
|
+
return { step: pick.step, state: tierRenderState(tier) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (active.length > 0) {
|
|
207
|
+
const pick = active.reduce(earliest);
|
|
208
|
+
return { step: pick.step, state: null };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// All branches are success terminals → the shared success bucket (`done`).
|
|
212
|
+
return { step: TERMINAL_STEP, state: "ok" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Re-export the shipped terminal-status set the tier basis draws on, so a consumer can reference the
|
|
216
|
+
* single source without a second import of app/featureReadModel.ts. */
|
|
217
|
+
export { STAGE_DONE_STATUSES };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- Delivery-graph read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452), for the
|
|
2
|
+
-- ONE derived stepper (ADR 0006 §4b, issue #541 / S7).
|
|
3
|
+
--
|
|
4
|
+
-- Before §4b the delivery-graph surfaces rendered `delivery_graph_runs.phase` — a bare text projection
|
|
5
|
+
-- the user-task park poll (`pollDeliveryGraphPhase`) recomputes — on a DIFFERENT renderer from
|
|
6
|
+
-- feature's `pipeline` stepper. S7 collapses feature + delivery-graph onto the ONE canonical step axis
|
|
7
|
+
-- (app/stepAxis.ts, seeded from `STAGE_KEYS`) rendered by the ONE `pipeline` kind. This migration adds
|
|
8
|
+
-- the delivery-graph half: a `delivery_graph_read_model` VIEW that maps the run's lifecycle onto a
|
|
9
|
+
-- CONFIGURED `STAGE_KEYS` bracket (`stage`) with a render state (`stage_state`), plus a companion
|
|
10
|
+
-- `park_label` display column carrying the actionable "Parked on human node: <label>" text so promoting
|
|
11
|
+
-- the stepper does not drop the detail the plain Phase cell showed.
|
|
12
|
+
--
|
|
13
|
+
-- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
|
|
14
|
+
-- app/deliveryGraphReadModel.ts — the member-PR rollup DDL from `deliveryGraphPrCounts.viewDdl()`, and
|
|
15
|
+
-- `stage`/`stage_state` from `deliveryGraphReadModel.sqlSelectFor(col, { baseAlias: "dg" })` — which
|
|
16
|
+
-- ALSO drive the runtime TS via `reduce`/`fnFor`. The two lowerings fall out of the same closed-DSL AST
|
|
17
|
+
-- and cannot diverge; a drift guard (app/deliveryGraphReadModel.test.ts) fails if this file stops
|
|
18
|
+
-- matching the declaration, and `assertRollupParity`/`assertReadModelParity` prove the SQL and TS
|
|
19
|
+
-- lowerings agree.
|
|
20
|
+
--
|
|
21
|
+
-- PER-SHAPE CORRELATION. A delivery-graph run has no aggregate `pr_key`; its downstream PRs attach via
|
|
22
|
+
-- `pull_requests.root_request_key = delivery_graph_runs.run_key`. The `delivery_graph_pr_counts` rollup
|
|
23
|
+
-- folds the member PRs (through `pull_requests__tracking.derived_status`, so an out-of-band-terminated
|
|
24
|
+
-- PR is not held in flight) grouped by `root_request_key`, and the read model LEFT JOINs it on
|
|
25
|
+
-- `dg.run_key = pc.root_request_key`. `prs_in_flight > 0` tempers a `running` run to `Converging`,
|
|
26
|
+
-- matching the shipped `deliveryOriginStage` (app/lineage.ts) — NOT a `process_key` join (that key is
|
|
27
|
+
-- reassigned to the downstream convergence/merge instances).
|
|
28
|
+
--
|
|
29
|
+
-- SEMANTICS. The status-classifying `stage`/`stage_state` read the terminal-folded `derived_status` off
|
|
30
|
+
-- the auto-provisioned `delivery_graph_runs__tracking` derived VIEW (ADR-0065), so a cancelled run
|
|
31
|
+
-- renders `Done`/`failed` instead of freezing at `Implementing`/`Converging`. Base columns stay aliased
|
|
32
|
+
-- identity pass-throughs (so the static pages↔schema contract guard sees the VIEW's columns), sourced
|
|
33
|
+
-- off `delivery_graph_runs__tracking`'s re-export of the base `delivery_graph_runs.*`; `status` is the
|
|
34
|
+
-- effective `COALESCE(derived_status, status)` so the pages' Active/History status filter tracks a
|
|
35
|
+
-- terminated run. `park_label` is a hand-authored DISPLAY column (D3 — display formatting is out of the
|
|
36
|
+
-- framework AST, so it carries no TS twin): the run's `phase` when it is parked on a human node
|
|
37
|
+
-- (`phase_node_id` set), else NULL.
|
|
38
|
+
--
|
|
39
|
+
-- Forward-only VIEW definition (DROP then CREATE). `delivery_graph_runs__tracking` is the managed VIEW
|
|
40
|
+
-- urban provisions at mount; SQLite does not validate a view body at CREATE time, so this migration
|
|
41
|
+
-- (which runs before that mount) is created fine and resolves once the managed VIEW exists. The runner
|
|
42
|
+
-- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 085.
|
|
43
|
+
|
|
44
|
+
DROP VIEW IF EXISTS delivery_graph_read_model;
|
|
45
|
+
DROP VIEW IF EXISTS delivery_graph_pr_counts;
|
|
46
|
+
|
|
47
|
+
CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
|
|
48
|
+
SELECT
|
|
49
|
+
"__urban_rollup_src"."root_request_key" AS "root_request_key",
|
|
50
|
+
SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
|
|
51
|
+
FROM "pull_requests__tracking" "__urban_rollup_src"
|
|
52
|
+
GROUP BY "__urban_rollup_src"."root_request_key";
|
|
53
|
+
|
|
54
|
+
CREATE VIEW delivery_graph_read_model AS
|
|
55
|
+
SELECT
|
|
56
|
+
dg.run_key AS run_key,
|
|
57
|
+
COALESCE(dg.derived_status, dg.status) AS status,
|
|
58
|
+
dg.process_key AS process_key,
|
|
59
|
+
dg.process_definition_id AS process_definition_id,
|
|
60
|
+
dg.digest AS digest,
|
|
61
|
+
dg.side_effecting AS side_effecting,
|
|
62
|
+
dg.node_count AS node_count,
|
|
63
|
+
dg.human_node_count AS human_node_count,
|
|
64
|
+
dg.side_effect_count AS side_effect_count,
|
|
65
|
+
dg.title AS title,
|
|
66
|
+
dg.phase AS phase,
|
|
67
|
+
dg.phase_node_id AS phase_node_id,
|
|
68
|
+
dg.human_labels AS human_labels,
|
|
69
|
+
dg.created_at AS created_at,
|
|
70
|
+
dg.updated_at AS updated_at,
|
|
71
|
+
CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
|
|
72
|
+
CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
|
|
73
|
+
CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
|
|
74
|
+
FROM delivery_graph_runs__tracking dg
|
|
75
|
+
LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.144.0",
|
|
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",
|
|
@@ -85,14 +85,30 @@
|
|
|
85
85
|
"data": {
|
|
86
86
|
"kind": "datasource",
|
|
87
87
|
"source": "app",
|
|
88
|
-
"table": "
|
|
88
|
+
"table": "delivery_graph_read_model",
|
|
89
89
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
90
90
|
"filter": [{ "field": "run_key", "eqParam": true }]
|
|
91
91
|
},
|
|
92
92
|
"columns": [
|
|
93
93
|
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "30%" },
|
|
94
94
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
95
|
-
{
|
|
95
|
+
{
|
|
96
|
+
"field": "stage",
|
|
97
|
+
"header": "Pipeline",
|
|
98
|
+
"kind": "pipeline",
|
|
99
|
+
"stages": [
|
|
100
|
+
{ "key": "Requested", "label": "Requested" },
|
|
101
|
+
{ "key": "Implementing", "label": "Implementing" },
|
|
102
|
+
{ "key": "PR open", "label": "PR open" },
|
|
103
|
+
{ "key": "Converging", "label": "Converging" },
|
|
104
|
+
{ "key": "Merging", "label": "Merging" },
|
|
105
|
+
{ "key": "Done", "label": "Done" }
|
|
106
|
+
],
|
|
107
|
+
"activeField": "stage",
|
|
108
|
+
"stateField": "stage_state",
|
|
109
|
+
"locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
|
|
110
|
+
},
|
|
111
|
+
{ "field": "park_label", "header": "Parked", "truncate": true, "width": "30%" },
|
|
96
112
|
{ "field": "node_count", "header": "Nodes" },
|
|
97
113
|
{ "field": "human_node_count", "header": "Human" },
|
|
98
114
|
{ "field": "side_effect_count", "header": "Side effects" },
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"data": {
|
|
119
119
|
"kind": "datasource",
|
|
120
120
|
"source": "app",
|
|
121
|
-
"table": "
|
|
121
|
+
"table": "delivery_graph_read_model",
|
|
122
122
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
123
123
|
"filter": [{ "field": "status", "in": ["awaiting-approval", "running"] }]
|
|
124
124
|
},
|
|
@@ -128,15 +128,32 @@
|
|
|
128
128
|
{ "label": "All", "filter": [] }
|
|
129
129
|
],
|
|
130
130
|
"columns": [
|
|
131
|
-
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "
|
|
132
|
-
{ "field": "status", "header": "Status", "truncate": true, "width": "
|
|
133
|
-
{ "field": "process_key", "header": "Instance", "width": "
|
|
134
|
-
{
|
|
131
|
+
{ "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "18%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
132
|
+
{ "field": "status", "header": "Status", "truncate": true, "width": "10%", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
133
|
+
{ "field": "process_key", "header": "Instance", "width": "8%", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
134
|
+
{
|
|
135
|
+
"field": "stage",
|
|
136
|
+
"header": "Pipeline",
|
|
137
|
+
"kind": "pipeline",
|
|
138
|
+
"width": "18%",
|
|
139
|
+
"stages": [
|
|
140
|
+
{ "key": "Requested", "label": "Requested" },
|
|
141
|
+
{ "key": "Implementing", "label": "Implementing" },
|
|
142
|
+
{ "key": "PR open", "label": "PR open" },
|
|
143
|
+
{ "key": "Converging", "label": "Converging" },
|
|
144
|
+
{ "key": "Merging", "label": "Merging" },
|
|
145
|
+
{ "key": "Done", "label": "Done" }
|
|
146
|
+
],
|
|
147
|
+
"activeField": "stage",
|
|
148
|
+
"stateField": "stage_state",
|
|
149
|
+
"locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
|
|
150
|
+
},
|
|
151
|
+
{ "field": "park_label", "header": "Parked", "truncate": true, "width": "12%" },
|
|
135
152
|
{ "field": "node_count", "header": "Nodes", "width": "5%" },
|
|
136
153
|
{ "field": "human_node_count", "header": "Human", "width": "5%" },
|
|
137
|
-
{ "field": "side_effect_count", "header": "Effects", "width": "
|
|
138
|
-
{ "field": "created_at", "header": "Dispatched", "width": "
|
|
139
|
-
{ "field": "updated_at", "header": "Updated", "width": "
|
|
154
|
+
{ "field": "side_effect_count", "header": "Effects", "width": "6%" },
|
|
155
|
+
{ "field": "created_at", "header": "Dispatched", "width": "7%", "truncate": true, "format": "datetime" },
|
|
156
|
+
{ "field": "updated_at", "header": "Updated", "width": "6%", "truncate": true, "format": "datetime" }
|
|
140
157
|
],
|
|
141
158
|
"detail": {
|
|
142
159
|
"fields": [
|
package/pages/overview.page.json
CHANGED
|
@@ -207,14 +207,30 @@
|
|
|
207
207
|
"data": {
|
|
208
208
|
"kind": "datasource",
|
|
209
209
|
"source": "app",
|
|
210
|
-
"table": "
|
|
210
|
+
"table": "delivery_graph_read_model",
|
|
211
211
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
212
212
|
"filter": [{ "field": "status", "in": ["awaiting-approval", "running"] }]
|
|
213
213
|
},
|
|
214
214
|
"columns": [
|
|
215
215
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "run_key", "truncate": true, "width": "30%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
|
|
216
216
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
217
|
-
{
|
|
217
|
+
{
|
|
218
|
+
"field": "stage",
|
|
219
|
+
"header": "Pipeline",
|
|
220
|
+
"kind": "pipeline",
|
|
221
|
+
"stages": [
|
|
222
|
+
{ "key": "Requested", "label": "Requested" },
|
|
223
|
+
{ "key": "Implementing", "label": "Implementing" },
|
|
224
|
+
{ "key": "PR open", "label": "PR open" },
|
|
225
|
+
{ "key": "Converging", "label": "Converging" },
|
|
226
|
+
{ "key": "Merging", "label": "Merging" },
|
|
227
|
+
{ "key": "Done", "label": "Done" }
|
|
228
|
+
],
|
|
229
|
+
"activeField": "stage",
|
|
230
|
+
"stateField": "stage_state",
|
|
231
|
+
"locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
|
|
232
|
+
},
|
|
233
|
+
{ "field": "park_label", "header": "Parked", "truncate": true, "width": "28%" },
|
|
218
234
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
|
219
235
|
],
|
|
220
236
|
"detail": {
|
|
@@ -333,8 +333,10 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
333
333
|
plan_read_model: { field: "list_bucket", in: ["active"] },
|
|
334
334
|
feature_runs: { field: "status", in: ["running", "escalated", "awaiting_operator"] },
|
|
335
335
|
// The 4th dispatch surface (issue #386) — active delivery graphs. Both in-flight statuses
|
|
336
|
-
// (`awaiting-approval` parked at the gate, `running` dispatched) show here.
|
|
337
|
-
|
|
336
|
+
// (`awaiting-approval` parked at the gate, `running` dispatched) show here. Binds the derived
|
|
337
|
+
// `delivery_graph_read_model` VIEW (S7 / #541 — the single source of truth for the pipeline
|
|
338
|
+
// projection it also renders), which re-exports every run column plus the effective `status`.
|
|
339
|
+
delivery_graph_read_model: { field: "status", in: ["awaiting-approval", "running"] },
|
|
338
340
|
};
|
|
339
341
|
const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
|
|
340
342
|
for (const [table, { field, in: values }] of Object.entries(expected)) {
|
|
@@ -361,19 +363,21 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
|
|
|
361
363
|
assert(tab, "pages/_nav.json must carry a `Delivery Graphs` nav tab → the delivery-graphs page");
|
|
362
364
|
|
|
363
365
|
// 2) The page carries the compose → preview → dispatch App View (issue #441 — the rendered preview
|
|
364
|
-
// that consumes the compile output), plus an in-flight grid over the
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
366
|
+
// that consumes the compile output), plus an in-flight grid over the delivery-graph run data (the
|
|
367
|
+
// derived `delivery_graph_read_model` VIEW, S7 / #541 — which re-exports every `delivery_graph_runs`
|
|
368
|
+
// column plus the pipeline projection) that links to the per-graph detail page. The rich preview
|
|
369
|
+
// (mermaid diagram + humanNodes[] + sideEffects[] + inline errors) can't render in a bare
|
|
370
|
+
// `actionForm` (its response is discarded), so the surface is an `appView` embed over the SAME
|
|
371
|
+
// compile/dispatch doors.
|
|
368
372
|
const page = JSON.parse(readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8"));
|
|
369
373
|
const compose = (page.nodes ?? []).find(
|
|
370
374
|
(n: Json) => n.type === "appView" && typeof n.props?.embed === "string" && n.props.embed.includes("delivery-graphs/embed.html"),
|
|
371
375
|
);
|
|
372
376
|
assert(compose, "delivery-graphs page must have an appView embedding ./delivery-graphs/embed.html (the compose → preview → dispatch view, #441)");
|
|
373
377
|
const grid = (page.nodes ?? []).find(
|
|
374
|
-
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "
|
|
378
|
+
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_read_model",
|
|
375
379
|
);
|
|
376
|
-
assert(grid, "delivery-graphs page must have an in-flight grid over
|
|
380
|
+
assert(grid, "delivery-graphs page must have an in-flight grid over the derived delivery_graph_read_model VIEW");
|
|
377
381
|
const linkCol = (grid.props?.columns ?? []).find((c: Json) => c.link?.page === "delivery-graph-detail");
|
|
378
382
|
assert(
|
|
379
383
|
linkCol && linkCol.link?.keyField === "run_key",
|
|
@@ -383,9 +387,9 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
|
|
|
383
387
|
// 3) The per-graph detail page reads the run aggregate scoped to the route param (run_key).
|
|
384
388
|
const detail = JSON.parse(readFileSync(`${ROOT}pages/delivery-graph-detail.page.json`, "utf8"));
|
|
385
389
|
const runGrid = (detail.nodes ?? []).find(
|
|
386
|
-
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "
|
|
390
|
+
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_read_model",
|
|
387
391
|
);
|
|
388
|
-
assert(runGrid, "delivery-graph-detail must bind a grid to
|
|
392
|
+
assert(runGrid, "delivery-graph-detail must bind a grid to the derived delivery_graph_read_model VIEW");
|
|
389
393
|
assert(
|
|
390
394
|
(runGrid.props?.data?.filter ?? []).some((fl: Json) => fl.field === "run_key" && fl.eqParam === true),
|
|
391
395
|
"delivery-graph-detail must scope its run grid to the route param (run_key eqParam)",
|
|
@@ -399,7 +403,7 @@ test("issue #386: the human-facing Delivery Graphs surface is wired (nav tab, pa
|
|
|
399
403
|
"overview subtitle must no longer say 'three dispatch surfaces' (a delivery graph is a 4th)",
|
|
400
404
|
);
|
|
401
405
|
const ovGrid = (overview.nodes ?? []).find(
|
|
402
|
-
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "
|
|
406
|
+
(n: Json) => n.type === "dataGrid" && n.props?.data?.table === "delivery_graph_read_model",
|
|
403
407
|
);
|
|
404
408
|
const ovLink = (ovGrid?.props?.columns ?? []).find((c: Json) => c.link?.page === "delivery-graph-detail");
|
|
405
409
|
assert(
|