@nanobpm/nano-workforce 0.136.0 → 0.138.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 +12 -0
- package/app/delivery.ts +100 -48
- package/app/deliveryGraphTextIngress.ts +94 -0
- package/app/deliveryStatuses.ts +13 -0
- package/app/planReadModel.test.ts +470 -0
- package/app/planReadModel.ts +159 -0
- package/app/planRollups.ts +127 -0
- package/db/migrations/082_plan_rollups_declare_once.sql +74 -0
- package/db/migrations/083_plan_read_model_declare_once.sql +84 -0
- package/openapi.yaml +73 -16
- package/operations/previewDeliveryGraph.test.ts +15 -13
- package/operations/previewDeliveryGraph.ts +24 -82
- package/operations/stageDeliveryGraph.test.ts +106 -0
- package/operations/stageDeliveryGraph.ts +53 -0
- package/package.json +5 -5
- package/pages/delivery-graphs/delivery-graphs.css +47 -0
- package/pages/delivery-graphs/embed.html +1 -1
- package/pages/delivery-graphs/mount.js +110 -87
- package/pages/delivery-graphs/standalone.html +1 -1
- package/test/delivery-graphs-embed.test.ts +47 -35
- package/app/delivery.test.ts +0 -76
- package/app/planWaveSummary.test.ts +0 -170
- package/app/plansReadModel.test.ts +0 -406
|
@@ -1,406 +0,0 @@
|
|
|
1
|
-
// Read-model VIEW coverage for the plans-table wave (022) and delivery (029) projections (epic #412
|
|
2
|
-
// — "Retire worker-maintained denormalized projections in favour of SQL VIEWs").
|
|
3
|
-
//
|
|
4
|
-
// Historically `plans.wave_count`/`current_wave`/`wave_label` (022_plan_wave_progress.sql) were
|
|
5
|
-
// written by the wave workers, and `plans.delivery`/`delivery_label` (029_plan_delivery.sql) by
|
|
6
|
-
// `pollDelivery` (app/service.ts) via the pure `deriveDelivery` (app/delivery.ts) — both denormalised
|
|
7
|
-
// onto `plans` only because "Urban's datasource cannot read a SQL VIEW". That constraint is gone
|
|
8
|
-
// (nano-ide#424), so 060/061 express the SAME projections as DERIVED views. This asserts the views
|
|
9
|
-
// reproduce the previous projections' EXACT values — including the pre-formatted `wave_label` /
|
|
10
|
-
// `delivery_label` display strings — over sample `plans` × `plan_tasks` × `pull_requests` rows, so
|
|
11
|
-
// the wave-1 cleanup can drop the worker write-paths + columns with no behavioural change.
|
|
12
|
-
//
|
|
13
|
-
// The delivery assertions cross-check against the real `deriveDelivery` (the single source of truth
|
|
14
|
-
// the poller used), not a re-implementation; the wave assertions pin the frontier derivation that
|
|
15
|
-
// reproduces the workers' `current_wave` (record-plan starts at 0, advances per landed wave, pins to
|
|
16
|
-
// wave_count-1 on completion).
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
18
|
-
import { DatabaseSync } from "node:sqlite";
|
|
19
|
-
import { test } from "node:test";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { assert, assertEquals } from "#test-assert";
|
|
22
|
-
import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
23
|
-
|
|
24
|
-
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
25
|
-
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
26
|
-
|
|
27
|
-
// A DB with the base `plans` / `plan_tasks` / `pull_requests` shapes the views read (the `plans`
|
|
28
|
-
// columns `plan_read_model` projects, and the full `plan_tasks` shape 059's `plan_wave_tasks`
|
|
29
|
-
// reads), plus 059→061 applied in order.
|
|
30
|
-
function viewDb(): DatabaseSync {
|
|
31
|
-
const db = new DatabaseSync(":memory:");
|
|
32
|
-
db.exec(
|
|
33
|
-
`CREATE TABLE plans (
|
|
34
|
-
plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
|
|
35
|
-
status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT,
|
|
36
|
-
updated_at TEXT, epic_phase TEXT, base_branch TEXT, wait_gate_label TEXT, bound_artifacts TEXT,
|
|
37
|
-
promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER,
|
|
38
|
-
derived_status_override TEXT);
|
|
39
|
-
CREATE TABLE plan_tasks (
|
|
40
|
-
id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
|
|
41
|
-
prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
|
|
42
|
-
wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
|
|
43
|
-
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
|
|
44
|
-
);
|
|
45
|
-
// Stand-in for the managed `plans__tracking` VIEW urban provisions at mount (ADR-0065): re-exports
|
|
46
|
-
// `plans.*` plus the `derived_status` the terminal-edge reader (migration 079) reads. A test seeds
|
|
47
|
-
// `derived_status_override` to model the reconciler's derive edge (a terminated instance ⇒
|
|
48
|
-
// `abandoned` while base `status` stays frozen); absent, it falls through to the base `status`, exactly
|
|
49
|
-
// as the real VIEW's `ELSE base.status` branch does.
|
|
50
|
-
db.exec(
|
|
51
|
-
`CREATE VIEW plans__tracking AS
|
|
52
|
-
SELECT p.*, COALESCE(p.derived_status_override, p.status) AS derived_status FROM plans p;`,
|
|
53
|
-
);
|
|
54
|
-
db.exec(MIG("059_plan_wave_summary.sql"));
|
|
55
|
-
db.exec(MIG("060_plan_wave_rollup.sql"));
|
|
56
|
-
db.exec(MIG("061_plan_delivery_rollup.sql"));
|
|
57
|
-
// 074 redefines plan_read_model to DERIVE list_bucket/ack_open from status + acknowledged_at + the
|
|
58
|
-
// derived plan_delivery signal (issue #439), instead of reading the denormalised base columns.
|
|
59
|
-
db.exec(MIG("074_plan_read_model_derive_bucket.sql"));
|
|
60
|
-
// 079 re-points plan_read_model's status/bucket derivations at the derived plans__tracking VIEW so a
|
|
61
|
-
// terminated (derive-only `abandoned`) epic drops out of Active (issue #503).
|
|
62
|
-
db.exec(MIG("080_plan_read_model_derive_terminal.sql"));
|
|
63
|
-
return db;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface SampleTask {
|
|
67
|
-
status: string;
|
|
68
|
-
wave: number | null;
|
|
69
|
-
pr?: { status: string };
|
|
70
|
-
// A slice that OPENED a PR (so `pr_key` is set and it counts toward `prs_opened`) but whose
|
|
71
|
-
// `pull_requests` row is ABSENT — a DB desync. Mirrors `pollDelivery`'s `MISSING_PR_STATUS`
|
|
72
|
-
// sentinel: the LEFT JOIN yields `status IS NULL`, which `plan_delivery_counts` treats as
|
|
73
|
-
// in-flight (non-terminal), so it can never wrongly promote an epic to `landed`.
|
|
74
|
-
danglingPr?: boolean;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Sentinel mirroring `pollDelivery`'s `MISSING_PR_STATUS` — the status fed to `deriveDelivery` for a
|
|
78
|
-
// `pr_key` with no `pull_requests` row. Any non-terminal string works (it's counted as in-flight); it
|
|
79
|
-
// exists only to keep the `deriveDelivery` cross-check aligned with the view's `status IS NULL` branch.
|
|
80
|
-
const MISSING_PR_STATUS = "missing";
|
|
81
|
-
|
|
82
|
-
// Insert a plan plus its tasks (and each task's PR, if any). PR keys are derived so the test rows
|
|
83
|
-
// stay terse. Returns the flat `pull_requests.status` list `deriveDelivery` consumes (only tasks
|
|
84
|
-
// that opened a PR), so the delivery assertions can cross-check the view against it.
|
|
85
|
-
function addPlan(
|
|
86
|
-
db: DatabaseSync,
|
|
87
|
-
plan_key: string,
|
|
88
|
-
status: string,
|
|
89
|
-
tasks: SampleTask[],
|
|
90
|
-
opts: { acknowledged_at?: string | null } = {},
|
|
91
|
-
): string[] {
|
|
92
|
-
db.prepare(
|
|
93
|
-
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
94
|
-
).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", opts.acknowledged_at ?? null, "active");
|
|
95
|
-
const prStatuses: string[] = [];
|
|
96
|
-
tasks.forEach((t, i) => {
|
|
97
|
-
const prKey = t.pr || t.danglingPr ? `${plan_key}::pr${i}` : null;
|
|
98
|
-
db.prepare(
|
|
99
|
-
"INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
|
|
100
|
-
).run(plan_key, i, `t${i}`, t.status, prKey, t.wave);
|
|
101
|
-
if (t.danglingPr) {
|
|
102
|
-
// Opened a PR (pr_key set → counts toward prs_opened) but NO `pull_requests` row: the DB desync
|
|
103
|
-
// `pollDelivery` feeds to `deriveDelivery` as MISSING_PR_STATUS (in-flight).
|
|
104
|
-
prStatuses.push(MISSING_PR_STATUS);
|
|
105
|
-
} else if (t.pr && prKey) {
|
|
106
|
-
db.prepare("INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)").run(
|
|
107
|
-
prKey,
|
|
108
|
-
`https://gh/${prKey}`,
|
|
109
|
-
t.pr.status,
|
|
110
|
-
`P${i}`,
|
|
111
|
-
);
|
|
112
|
-
prStatuses.push(t.pr.status);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
return prStatuses;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function delivery(db: DatabaseSync, plan_key: string): { delivery: unknown; delivery_label: unknown } {
|
|
119
|
-
return db.prepare("SELECT delivery, delivery_label FROM plan_delivery WHERE plan_key = ?").get(plan_key) as {
|
|
120
|
-
delivery: unknown;
|
|
121
|
-
delivery_label: unknown;
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function counts(db: DatabaseSync, plan_key: string): { prs_opened: number; prs_merged: number; prs_in_flight: number } {
|
|
126
|
-
const r = db
|
|
127
|
-
.prepare("SELECT prs_opened, prs_merged, prs_in_flight FROM plan_delivery_counts WHERE plan_key = ?")
|
|
128
|
-
.get(plan_key) as { prs_opened: number; prs_merged: number; prs_in_flight: number };
|
|
129
|
-
return { ...r };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// The derived Active/History bucket flags the epics pages bind — read straight off `plan_read_model`
|
|
133
|
-
// (074), plus the delivery signal the derivation folds in, so the assertions can cross-check the
|
|
134
|
-
// VIEW against the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles.
|
|
135
|
-
function bucket(db: DatabaseSync, plan_key: string): { list_bucket: unknown; ack_open: unknown; delivery: string | null } {
|
|
136
|
-
const r = db
|
|
137
|
-
.prepare("SELECT list_bucket, ack_open, delivery FROM plan_read_model WHERE plan_key = ?")
|
|
138
|
-
.get(plan_key) as { list_bucket: unknown; ack_open: unknown; delivery: string | null };
|
|
139
|
-
return { ...r };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function waveLabel(db: DatabaseSync, plan_key: string): Record<string, unknown> | undefined {
|
|
143
|
-
const r = db.prepare("SELECT wave_count, current_wave, wave_label FROM plan_wave_label WHERE plan_key = ?").get(plan_key) as
|
|
144
|
-
| Record<string, unknown>
|
|
145
|
-
| undefined;
|
|
146
|
-
return r === undefined ? undefined : { ...r };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
test("plan_delivery reproduces deriveDelivery exactly (converging / landed / not-done / resolved-not-landed / taskless)", () => {
|
|
150
|
-
const db = viewDb();
|
|
151
|
-
// A `done` epic with slices still in flight → converging.
|
|
152
|
-
const a = addPlan(db, "o/r#1", "done", [
|
|
153
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
154
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
155
|
-
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
156
|
-
{ status: "blocked", wave: 1 },
|
|
157
|
-
]);
|
|
158
|
-
// A `done` epic with every slice PR merged → landed.
|
|
159
|
-
const b = addPlan(db, "o/r#2", "done", [
|
|
160
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
161
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
162
|
-
]);
|
|
163
|
-
// A `dispatched` (not-done) epic → no positive delivery signal yet, even with an open PR.
|
|
164
|
-
const c = addPlan(db, "o/r#3", "dispatched", [
|
|
165
|
-
{ status: "opened", wave: 0, pr: { status: "converging" } },
|
|
166
|
-
{ status: "pending", wave: 1 },
|
|
167
|
-
{ status: "pending", wave: 2 },
|
|
168
|
-
]);
|
|
169
|
-
// A `done` epic where every PR is terminal but not all merged (one abandoned) → resolved, NOT landed.
|
|
170
|
-
const d = addPlan(db, "o/r#4", "done", [
|
|
171
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
172
|
-
{ status: "opened", wave: 0, pr: { status: "abandoned" } },
|
|
173
|
-
]);
|
|
174
|
-
// A `planning` epic with no tasks → no PRs → null.
|
|
175
|
-
const e = addPlan(db, "o/r#5", "planning", []);
|
|
176
|
-
|
|
177
|
-
for (const [plan_key, status, prStatuses] of [
|
|
178
|
-
["o/r#1", "done", a],
|
|
179
|
-
["o/r#2", "done", b],
|
|
180
|
-
["o/r#3", "dispatched", c],
|
|
181
|
-
["o/r#4", "done", d],
|
|
182
|
-
["o/r#5", "planning", e],
|
|
183
|
-
] as const) {
|
|
184
|
-
const expected = deriveDelivery(status, prStatuses);
|
|
185
|
-
const row = delivery(db, plan_key);
|
|
186
|
-
assertEquals(row.delivery, expected.delivery, `${plan_key}: delivery`);
|
|
187
|
-
assertEquals(row.delivery_label, expected.label, `${plan_key}: delivery_label`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Pin the exact pre-formatted strings so a formatting drift can't hide behind the cross-check.
|
|
191
|
-
assertEquals(delivery(db, "o/r#1").delivery_label, "2/3 slices merged, 1 converging");
|
|
192
|
-
assertEquals(delivery(db, "o/r#2").delivery_label, "2/2 slices merged");
|
|
193
|
-
assertEquals(delivery(db, "o/r#4").delivery, null);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("plan_delivery_counts pins the two subtle predicates: `converged` is terminal, a dangling pr_key is in-flight", () => {
|
|
197
|
-
const db = viewDb();
|
|
198
|
-
// `converged` is in TERMINAL_STATUSES (review-only mode): a `done` epic whose only remaining PR is
|
|
199
|
-
// `converged` (not `merged`) is resolved-not-landed. It must NOT count as in-flight — so delivery is
|
|
200
|
-
// NULL, never `converging`. This pins the hard-coded terminal set in the view's SQL against
|
|
201
|
-
// TERMINAL_STATUSES; drop `converged` from either and prs_in_flight becomes 1 here.
|
|
202
|
-
const conv = addPlan(db, "o/r#c", "done", [
|
|
203
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
204
|
-
{ status: "opened", wave: 0, pr: { status: "converged" } },
|
|
205
|
-
]);
|
|
206
|
-
assertEquals(counts(db, "o/r#c"), { prs_opened: 2, prs_merged: 1, prs_in_flight: 0 });
|
|
207
|
-
assertEquals(delivery(db, "o/r#c").delivery, null, "converged is terminal-not-merged → resolved, not landed");
|
|
208
|
-
assertEquals({ ...delivery(db, "o/r#c") }, { delivery: deriveDelivery("done", conv).delivery, delivery_label: deriveDelivery("done", conv).label });
|
|
209
|
-
|
|
210
|
-
// A dangling `pr_key` (task opened a PR but the `pull_requests` row is absent — the poller's
|
|
211
|
-
// MISSING_PR_STATUS desync). The LEFT JOIN yields `status IS NULL`, which the view's
|
|
212
|
-
// `p.status IS NULL OR …` branch counts as in-flight — so even though every OTHER PR merged, the
|
|
213
|
-
// epic stays `converging` and can never be wrongly promoted to `landed`.
|
|
214
|
-
const dangling = addPlan(db, "o/r#d", "done", [
|
|
215
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
216
|
-
{ status: "opened", wave: 0, danglingPr: true },
|
|
217
|
-
]);
|
|
218
|
-
assertEquals(counts(db, "o/r#d"), { prs_opened: 2, prs_merged: 1, prs_in_flight: 1 });
|
|
219
|
-
assertEquals(delivery(db, "o/r#d").delivery, "converging", "a dangling pr_key keeps the epic in flight (never landed)");
|
|
220
|
-
assertEquals({ ...delivery(db, "o/r#d") }, {
|
|
221
|
-
delivery: deriveDelivery("done", dangling).delivery,
|
|
222
|
-
delivery_label: deriveDelivery("done", dangling).label,
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
test("plan_wave_label reproduces the workers' wave_count / current_wave / wave_label projection", () => {
|
|
227
|
-
const db = viewDb();
|
|
228
|
-
// Wave 0 fully merged, wave 1 still converging → frontier is wave 1 (the gating wave). "2/2".
|
|
229
|
-
addPlan(db, "o/r#1", "done", [
|
|
230
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
231
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
232
|
-
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
233
|
-
{ status: "blocked", wave: 1 },
|
|
234
|
-
]);
|
|
235
|
-
// Single wave, all merged → frontier pins to the last index (0). "1/1".
|
|
236
|
-
addPlan(db, "o/r#2", "done", [
|
|
237
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
238
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
239
|
-
]);
|
|
240
|
-
// Three waves, freshly dispatched (all in flight) → frontier is wave 0. "1/3".
|
|
241
|
-
addPlan(db, "o/r#3", "dispatched", [
|
|
242
|
-
{ status: "opened", wave: 0, pr: { status: "converging" } },
|
|
243
|
-
{ status: "pending", wave: 1 },
|
|
244
|
-
{ status: "pending", wave: 2 },
|
|
245
|
-
]);
|
|
246
|
-
// No levelized tasks → no wave rollup row (matches the workers leaving a taskless plan NULL).
|
|
247
|
-
addPlan(db, "o/r#5", "planning", []);
|
|
248
|
-
|
|
249
|
-
assertEquals(waveLabel(db, "o/r#1"), { wave_count: 2, current_wave: 1, wave_label: "2/2" });
|
|
250
|
-
assertEquals(waveLabel(db, "o/r#2"), { wave_count: 1, current_wave: 0, wave_label: "1/1" });
|
|
251
|
-
assertEquals(waveLabel(db, "o/r#3"), { wave_count: 3, current_wave: 0, wave_label: "1/3" });
|
|
252
|
-
assertEquals(waveLabel(db, "o/r#5"), undefined);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
test("plan_read_model joins the derived wave + delivery projections onto the plans row", () => {
|
|
256
|
-
const db = viewDb();
|
|
257
|
-
addPlan(db, "o/r#1", "done", [
|
|
258
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
259
|
-
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
260
|
-
]);
|
|
261
|
-
addPlan(db, "o/r#5", "planning", []);
|
|
262
|
-
|
|
263
|
-
const row = db.prepare("SELECT * FROM plan_read_model WHERE plan_key = ?").get("o/r#1") as Record<string, unknown>;
|
|
264
|
-
assertEquals(row.plan_key, "o/r#1");
|
|
265
|
-
assertEquals(row.status, "done");
|
|
266
|
-
assertEquals(row.list_bucket, "active");
|
|
267
|
-
assertEquals(row.wave_label, "2/2");
|
|
268
|
-
assertEquals(row.wave_count, 2);
|
|
269
|
-
assertEquals(row.current_wave, 1);
|
|
270
|
-
assertEquals(row.delivery, "converging");
|
|
271
|
-
assertEquals(row.delivery_label, "1/2 slices merged, 1 converging");
|
|
272
|
-
|
|
273
|
-
// A taskless plan still appears (LEFT JOINs), with the derived columns NULL.
|
|
274
|
-
const empty = db.prepare("SELECT wave_label, delivery, delivery_label FROM plan_read_model WHERE plan_key = ?").get("o/r#5") as Record<string, unknown>;
|
|
275
|
-
assertEquals({ ...empty }, { wave_label: null, delivery: null, delivery_label: null });
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
test("the operator pages read the derived plan_read_model VIEW for the wave/delivery cells", () => {
|
|
279
|
-
// Overview epics grid — binds the view and still surfaces wave_label + delivery_label.
|
|
280
|
-
const overview = PAGE("overview.page.json");
|
|
281
|
-
const epics = (overview.nodes ?? []).find((n: { id: string }) => n.id === "overview-epics");
|
|
282
|
-
assert(epics, "overview must keep the Active Epics grid");
|
|
283
|
-
assertEquals(epics.props.data.table, "plan_read_model");
|
|
284
|
-
const epicCols: string[] = epics.props.columns.map((c: { field: string }) => c.field);
|
|
285
|
-
assert(epicCols.includes("wave_label") && epicCols.includes("delivery_label"), "overview epics grid surfaces wave_label + delivery_label");
|
|
286
|
-
|
|
287
|
-
// Epic-detail wave banner + plan grid both read the view.
|
|
288
|
-
const detail = PAGE("epic-detail.page.json");
|
|
289
|
-
const byId = (id: string) => (detail.nodes ?? []).find((n: { id: string }) => n.id === id);
|
|
290
|
-
assertEquals(byId("wave-banner").props.data.table, "plan_read_model");
|
|
291
|
-
assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
|
|
292
|
-
assert(/\{\{\s*wave_label\s*\}\}/.test(byId("wave-banner").props.header), "the banner surfaces wave_label");
|
|
293
|
-
assertEquals(byId("wave-banner").props.body, "delivery_label", "the banner body is the delivery_label");
|
|
294
|
-
|
|
295
|
-
// Epic INDEX page (epic.page.json) — the standalone Epics grid must also read the derived VIEW, not
|
|
296
|
-
// the raw `plans` table. `plans` stays a valid schema table, so a regression here (reverting the
|
|
297
|
-
// binding) would leave every OTHER test green while the index silently resumed reading stale
|
|
298
|
-
// list_bucket/ack_open; this pins it (suppressed advisory epic.page.json — issue #439).
|
|
299
|
-
const epicIndex = PAGE("epic.page.json");
|
|
300
|
-
const epicPlans = (epicIndex.nodes ?? []).find((n: { id: string }) => n.id === "epic-plans");
|
|
301
|
-
assert(epicPlans, "epic index must keep the Epics grid");
|
|
302
|
-
assertEquals(epicPlans.props.data.table, "plan_read_model");
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test("plan_read_model DERIVES list_bucket / ack_open from status + delivery + acknowledged_at, matching deriveEpicBucket / epicIsAcknowledgeable (issue #439)", () => {
|
|
306
|
-
const db = viewDb();
|
|
307
|
-
// done, still converging (a slice in flight): Active, Dismiss suppressed — never ticked off mid-flight.
|
|
308
|
-
const converging = addPlan(db, "o/r#conv", "done", [
|
|
309
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
310
|
-
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
311
|
-
]);
|
|
312
|
-
// done, fully landed, unacknowledged: Active with Dismiss OPEN (ack_open=1).
|
|
313
|
-
const landed = addPlan(db, "o/r#land", "done", [
|
|
314
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
315
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
316
|
-
]);
|
|
317
|
-
// done, landed AND acknowledged: History, Dismiss closed.
|
|
318
|
-
const acked = addPlan(
|
|
319
|
-
db,
|
|
320
|
-
"o/r#ack",
|
|
321
|
-
"done",
|
|
322
|
-
[{ status: "opened", wave: 0, pr: { status: "merged" } }],
|
|
323
|
-
{ acknowledged_at: "2026-02-02T00:00:00Z" },
|
|
324
|
-
);
|
|
325
|
-
// resolved-not-landed (one abandoned), unacknowledged: delivery null → acknowledgeable, Active + Dismiss.
|
|
326
|
-
const resolved = addPlan(db, "o/r#res", "done", [
|
|
327
|
-
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
328
|
-
{ status: "opened", wave: 0, pr: { status: "abandoned" } },
|
|
329
|
-
]);
|
|
330
|
-
// live (dispatched) epic: Active, not acknowledgeable.
|
|
331
|
-
const live = addPlan(db, "o/r#live", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
|
|
332
|
-
|
|
333
|
-
for (const [plan_key, status, prStatuses, ackAt] of [
|
|
334
|
-
["o/r#conv", "done", converging, null],
|
|
335
|
-
["o/r#land", "done", landed, null],
|
|
336
|
-
["o/r#ack", "done", acked, "2026-02-02T00:00:00Z"],
|
|
337
|
-
["o/r#res", "done", resolved, null],
|
|
338
|
-
["o/r#live", "dispatched", live, null],
|
|
339
|
-
] as const) {
|
|
340
|
-
const b = bucket(db, plan_key);
|
|
341
|
-
const expectedDelivery = deriveDelivery(status, prStatuses).delivery;
|
|
342
|
-
assertEquals(b.delivery, expectedDelivery, `${plan_key}: delivery`);
|
|
343
|
-
// Cross-check the VIEW against the pure helpers — the SAME oracle the acknowledge-epic op guards on.
|
|
344
|
-
assertEquals(
|
|
345
|
-
b.list_bucket,
|
|
346
|
-
deriveEpicBucket(status, expectedDelivery, ackAt),
|
|
347
|
-
`${plan_key}: list_bucket must equal deriveEpicBucket`,
|
|
348
|
-
);
|
|
349
|
-
const expectedAckOpen = epicIsAcknowledgeable(status, expectedDelivery) && ackAt === null ? 1 : 0;
|
|
350
|
-
assertEquals(b.ack_open, expectedAckOpen, `${plan_key}: ack_open must equal epicIsAcknowledgeable`);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Pin the human-visible outcomes so a derivation drift can't hide behind the cross-check.
|
|
354
|
-
assertEquals(bucket(db, "o/r#conv"), { list_bucket: "active", ack_open: 0, delivery: "converging" });
|
|
355
|
-
assertEquals(bucket(db, "o/r#land"), { list_bucket: "active", ack_open: 1, delivery: "landed" });
|
|
356
|
-
assertEquals(bucket(db, "o/r#ack"), { list_bucket: "history", ack_open: 0, delivery: "landed" });
|
|
357
|
-
assertEquals(bucket(db, "o/r#res"), { list_bucket: "active", ack_open: 1, delivery: null });
|
|
358
|
-
assertEquals(bucket(db, "o/r#live"), { list_bucket: "active", ack_open: 0, delivery: null });
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test("RED/GREEN GUARD: a RAW-datasource plans.status write (the instanceTracking reconciler bypass) leaves plan_read_model's bucket CONSISTENT", () => {
|
|
362
|
-
// Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated process
|
|
363
|
-
// instance it writes `{status:"abandoned"}` to `plans` through the RAW datasource — bypassing the
|
|
364
|
-
// (now retired) projecting `plans` gateway. Under the OLD write-time projection the stored
|
|
365
|
-
// `list_bucket`/`ack_open` would freeze at their pre-terminal values; because they are now a VIEW
|
|
366
|
-
// over `status`, the read model stays correct with no write-path for any writer to leave stale.
|
|
367
|
-
const db = viewDb();
|
|
368
|
-
// A live epic mid-flight — Active, its (stale) stored projection says active/converging.
|
|
369
|
-
addPlan(db, "o/r#kill", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
|
|
370
|
-
assertEquals(bucket(db, "o/r#kill").list_bucket, "active");
|
|
371
|
-
|
|
372
|
-
// The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
|
|
373
|
-
// UPDATE, exactly what the raw datasource emits.) It touches neither list_bucket nor ack_open.
|
|
374
|
-
db.prepare("UPDATE plans SET status = 'abandoned' WHERE plan_key = ?").run("o/r#kill");
|
|
375
|
-
|
|
376
|
-
// `abandoned` is a terminal non-`done` status: History, never acknowledgeable — matches the oracle.
|
|
377
|
-
const b = bucket(db, "o/r#kill");
|
|
378
|
-
assertEquals(b.list_bucket, deriveEpicBucket("abandoned", b.delivery, null), "list_bucket tracks status via the VIEW");
|
|
379
|
-
assertEquals(b.list_bucket, "history", "an abandoned epic is filed under History, not wedged in Active");
|
|
380
|
-
assertEquals(b.ack_open, epicIsAcknowledgeable("abandoned", b.delivery) ? 1 : 0);
|
|
381
|
-
assertEquals(b.ack_open, 0, "no phantom Dismiss on a reconciler-cancelled epic");
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("RED/GREEN #503: a DERIVE-ONLY terminated epic (base status frozen, derived_status='abandoned') drops out of Active", () => {
|
|
385
|
-
// ADR-0065 (urban 0.81.0): cancel/terminate is DERIVE-ONLY — the reconciler feeds urban's projection
|
|
386
|
-
// and `plans__tracking.derived_status` recomputes `abandoned` on READ; it does NOT write the terminal
|
|
387
|
-
// onto the base `plans.status` column. So the base row stays frozen at its last transient
|
|
388
|
-
// (`dispatched`) while the epic is really terminated. Before 079 `plan_read_model` bucketed off the
|
|
389
|
-
// frozen base column and rendered the dead epic ACTIVE on the epic index/detail forever (the #503 /
|
|
390
|
-
// #497 phantom). 079 reads the effective status off `plans__tracking`, so it drops to History.
|
|
391
|
-
const db = viewDb();
|
|
392
|
-
// Seed a plan whose engine instance was terminated out-of-band: base status still `dispatched`, but
|
|
393
|
-
// the derive edge reports `abandoned` (modelled via the plans__tracking stand-in's override column).
|
|
394
|
-
db.prepare(
|
|
395
|
-
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket, derived_status_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
396
|
-
).run("o/r#term", "o/r", 7, "https://gh/o/r#term", "dispatched", 0, "2026-01-01T00:00:00Z", null, "active", "abandoned");
|
|
397
|
-
|
|
398
|
-
const r = db
|
|
399
|
-
.prepare("SELECT status, list_bucket, ack_open FROM plan_read_model WHERE plan_key = ?")
|
|
400
|
-
.get("o/r#term") as { status: string; list_bucket: string; ack_open: number };
|
|
401
|
-
|
|
402
|
-
assertEquals(r.status, "abandoned", "plan_read_model surfaces the DERIVED terminal, not the frozen base transient");
|
|
403
|
-
assertEquals(r.list_bucket, "history", "a derive-only terminated epic is filed under History, not wedged Active");
|
|
404
|
-
assertEquals(r.list_bucket, deriveEpicBucket("abandoned", null, null));
|
|
405
|
-
assertEquals(r.ack_open, 0, "no phantom Dismiss on a derive-only terminated epic");
|
|
406
|
-
});
|