@nanobpm/nano-workforce 0.118.2 → 0.120.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.
@@ -0,0 +1,262 @@
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 } 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, list_bucket TEXT, ack_open INTEGER);
38
+ CREATE TABLE plan_tasks (
39
+ id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
40
+ prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
41
+ wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
42
+ CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
43
+ );
44
+ db.exec(MIG("059_plan_wave_summary.sql"));
45
+ db.exec(MIG("060_plan_wave_rollup.sql"));
46
+ db.exec(MIG("061_plan_delivery_rollup.sql"));
47
+ return db;
48
+ }
49
+
50
+ interface SampleTask {
51
+ status: string;
52
+ wave: number | null;
53
+ pr?: { status: string };
54
+ // A slice that OPENED a PR (so `pr_key` is set and it counts toward `prs_opened`) but whose
55
+ // `pull_requests` row is ABSENT — a DB desync. Mirrors `pollDelivery`'s `MISSING_PR_STATUS`
56
+ // sentinel: the LEFT JOIN yields `status IS NULL`, which `plan_delivery_counts` treats as
57
+ // in-flight (non-terminal), so it can never wrongly promote an epic to `landed`.
58
+ danglingPr?: boolean;
59
+ }
60
+
61
+ // Sentinel mirroring `pollDelivery`'s `MISSING_PR_STATUS` — the status fed to `deriveDelivery` for a
62
+ // `pr_key` with no `pull_requests` row. Any non-terminal string works (it's counted as in-flight); it
63
+ // exists only to keep the `deriveDelivery` cross-check aligned with the view's `status IS NULL` branch.
64
+ const MISSING_PR_STATUS = "missing";
65
+
66
+ // Insert a plan plus its tasks (and each task's PR, if any). PR keys are derived so the test rows
67
+ // stay terse. Returns the flat `pull_requests.status` list `deriveDelivery` consumes (only tasks
68
+ // that opened a PR), so the delivery assertions can cross-check the view against it.
69
+ function addPlan(db: DatabaseSync, plan_key: string, status: string, tasks: SampleTask[]): string[] {
70
+ db.prepare(
71
+ "INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
72
+ ).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", "active");
73
+ const prStatuses: string[] = [];
74
+ tasks.forEach((t, i) => {
75
+ const prKey = t.pr || t.danglingPr ? `${plan_key}::pr${i}` : null;
76
+ db.prepare(
77
+ "INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
78
+ ).run(plan_key, i, `t${i}`, t.status, prKey, t.wave);
79
+ if (t.danglingPr) {
80
+ // Opened a PR (pr_key set → counts toward prs_opened) but NO `pull_requests` row: the DB desync
81
+ // `pollDelivery` feeds to `deriveDelivery` as MISSING_PR_STATUS (in-flight).
82
+ prStatuses.push(MISSING_PR_STATUS);
83
+ } else if (t.pr && prKey) {
84
+ db.prepare("INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)").run(
85
+ prKey,
86
+ `https://gh/${prKey}`,
87
+ t.pr.status,
88
+ `P${i}`,
89
+ );
90
+ prStatuses.push(t.pr.status);
91
+ }
92
+ });
93
+ return prStatuses;
94
+ }
95
+
96
+ function delivery(db: DatabaseSync, plan_key: string): { delivery: unknown; delivery_label: unknown } {
97
+ return db.prepare("SELECT delivery, delivery_label FROM plan_delivery WHERE plan_key = ?").get(plan_key) as {
98
+ delivery: unknown;
99
+ delivery_label: unknown;
100
+ };
101
+ }
102
+
103
+ function counts(db: DatabaseSync, plan_key: string): { prs_opened: number; prs_merged: number; prs_in_flight: number } {
104
+ const r = db
105
+ .prepare("SELECT prs_opened, prs_merged, prs_in_flight FROM plan_delivery_counts WHERE plan_key = ?")
106
+ .get(plan_key) as { prs_opened: number; prs_merged: number; prs_in_flight: number };
107
+ return { ...r };
108
+ }
109
+
110
+ function waveLabel(db: DatabaseSync, plan_key: string): Record<string, unknown> | undefined {
111
+ const r = db.prepare("SELECT wave_count, current_wave, wave_label FROM plan_wave_label WHERE plan_key = ?").get(plan_key) as
112
+ | Record<string, unknown>
113
+ | undefined;
114
+ return r === undefined ? undefined : { ...r };
115
+ }
116
+
117
+ test("plan_delivery reproduces deriveDelivery exactly (converging / landed / not-done / resolved-not-landed / taskless)", () => {
118
+ const db = viewDb();
119
+ // A `done` epic with slices still in flight → converging.
120
+ const a = addPlan(db, "o/r#1", "done", [
121
+ { status: "opened", wave: 0, pr: { status: "merged" } },
122
+ { status: "opened", wave: 0, pr: { status: "merged" } },
123
+ { status: "opened", wave: 1, pr: { status: "converging" } },
124
+ { status: "blocked", wave: 1 },
125
+ ]);
126
+ // A `done` epic with every slice PR merged → landed.
127
+ const b = addPlan(db, "o/r#2", "done", [
128
+ { status: "opened", wave: 0, pr: { status: "merged" } },
129
+ { status: "opened", wave: 0, pr: { status: "merged" } },
130
+ ]);
131
+ // A `dispatched` (not-done) epic → no positive delivery signal yet, even with an open PR.
132
+ const c = addPlan(db, "o/r#3", "dispatched", [
133
+ { status: "opened", wave: 0, pr: { status: "converging" } },
134
+ { status: "pending", wave: 1 },
135
+ { status: "pending", wave: 2 },
136
+ ]);
137
+ // A `done` epic where every PR is terminal but not all merged (one abandoned) → resolved, NOT landed.
138
+ const d = addPlan(db, "o/r#4", "done", [
139
+ { status: "opened", wave: 0, pr: { status: "merged" } },
140
+ { status: "opened", wave: 0, pr: { status: "abandoned" } },
141
+ ]);
142
+ // A `planning` epic with no tasks → no PRs → null.
143
+ const e = addPlan(db, "o/r#5", "planning", []);
144
+
145
+ for (const [plan_key, status, prStatuses] of [
146
+ ["o/r#1", "done", a],
147
+ ["o/r#2", "done", b],
148
+ ["o/r#3", "dispatched", c],
149
+ ["o/r#4", "done", d],
150
+ ["o/r#5", "planning", e],
151
+ ] as const) {
152
+ const expected = deriveDelivery(status, prStatuses);
153
+ const row = delivery(db, plan_key);
154
+ assertEquals(row.delivery, expected.delivery, `${plan_key}: delivery`);
155
+ assertEquals(row.delivery_label, expected.label, `${plan_key}: delivery_label`);
156
+ }
157
+
158
+ // Pin the exact pre-formatted strings so a formatting drift can't hide behind the cross-check.
159
+ assertEquals(delivery(db, "o/r#1").delivery_label, "2/3 slices merged, 1 converging");
160
+ assertEquals(delivery(db, "o/r#2").delivery_label, "2/2 slices merged");
161
+ assertEquals(delivery(db, "o/r#4").delivery, null);
162
+ });
163
+
164
+ test("plan_delivery_counts pins the two subtle predicates: `converged` is terminal, a dangling pr_key is in-flight", () => {
165
+ const db = viewDb();
166
+ // `converged` is in TERMINAL_STATUSES (review-only mode): a `done` epic whose only remaining PR is
167
+ // `converged` (not `merged`) is resolved-not-landed. It must NOT count as in-flight — so delivery is
168
+ // NULL, never `converging`. This pins the hard-coded terminal set in the view's SQL against
169
+ // TERMINAL_STATUSES; drop `converged` from either and prs_in_flight becomes 1 here.
170
+ const conv = addPlan(db, "o/r#c", "done", [
171
+ { status: "opened", wave: 0, pr: { status: "merged" } },
172
+ { status: "opened", wave: 0, pr: { status: "converged" } },
173
+ ]);
174
+ assertEquals(counts(db, "o/r#c"), { prs_opened: 2, prs_merged: 1, prs_in_flight: 0 });
175
+ assertEquals(delivery(db, "o/r#c").delivery, null, "converged is terminal-not-merged → resolved, not landed");
176
+ assertEquals({ ...delivery(db, "o/r#c") }, { delivery: deriveDelivery("done", conv).delivery, delivery_label: deriveDelivery("done", conv).label });
177
+
178
+ // A dangling `pr_key` (task opened a PR but the `pull_requests` row is absent — the poller's
179
+ // MISSING_PR_STATUS desync). The LEFT JOIN yields `status IS NULL`, which the view's
180
+ // `p.status IS NULL OR …` branch counts as in-flight — so even though every OTHER PR merged, the
181
+ // epic stays `converging` and can never be wrongly promoted to `landed`.
182
+ const dangling = addPlan(db, "o/r#d", "done", [
183
+ { status: "opened", wave: 0, pr: { status: "merged" } },
184
+ { status: "opened", wave: 0, danglingPr: true },
185
+ ]);
186
+ assertEquals(counts(db, "o/r#d"), { prs_opened: 2, prs_merged: 1, prs_in_flight: 1 });
187
+ assertEquals(delivery(db, "o/r#d").delivery, "converging", "a dangling pr_key keeps the epic in flight (never landed)");
188
+ assertEquals({ ...delivery(db, "o/r#d") }, {
189
+ delivery: deriveDelivery("done", dangling).delivery,
190
+ delivery_label: deriveDelivery("done", dangling).label,
191
+ });
192
+ });
193
+
194
+ test("plan_wave_label reproduces the workers' wave_count / current_wave / wave_label projection", () => {
195
+ const db = viewDb();
196
+ // Wave 0 fully merged, wave 1 still converging → frontier is wave 1 (the gating wave). "2/2".
197
+ addPlan(db, "o/r#1", "done", [
198
+ { status: "opened", wave: 0, pr: { status: "merged" } },
199
+ { status: "opened", wave: 0, pr: { status: "merged" } },
200
+ { status: "opened", wave: 1, pr: { status: "converging" } },
201
+ { status: "blocked", wave: 1 },
202
+ ]);
203
+ // Single wave, all merged → frontier pins to the last index (0). "1/1".
204
+ addPlan(db, "o/r#2", "done", [
205
+ { status: "opened", wave: 0, pr: { status: "merged" } },
206
+ { status: "opened", wave: 0, pr: { status: "merged" } },
207
+ ]);
208
+ // Three waves, freshly dispatched (all in flight) → frontier is wave 0. "1/3".
209
+ addPlan(db, "o/r#3", "dispatched", [
210
+ { status: "opened", wave: 0, pr: { status: "converging" } },
211
+ { status: "pending", wave: 1 },
212
+ { status: "pending", wave: 2 },
213
+ ]);
214
+ // No levelized tasks → no wave rollup row (matches the workers leaving a taskless plan NULL).
215
+ addPlan(db, "o/r#5", "planning", []);
216
+
217
+ assertEquals(waveLabel(db, "o/r#1"), { wave_count: 2, current_wave: 1, wave_label: "2/2" });
218
+ assertEquals(waveLabel(db, "o/r#2"), { wave_count: 1, current_wave: 0, wave_label: "1/1" });
219
+ assertEquals(waveLabel(db, "o/r#3"), { wave_count: 3, current_wave: 0, wave_label: "1/3" });
220
+ assertEquals(waveLabel(db, "o/r#5"), undefined);
221
+ });
222
+
223
+ test("plan_read_model joins the derived wave + delivery projections onto the plans row", () => {
224
+ const db = viewDb();
225
+ addPlan(db, "o/r#1", "done", [
226
+ { status: "opened", wave: 0, pr: { status: "merged" } },
227
+ { status: "opened", wave: 1, pr: { status: "converging" } },
228
+ ]);
229
+ addPlan(db, "o/r#5", "planning", []);
230
+
231
+ const row = db.prepare("SELECT * FROM plan_read_model WHERE plan_key = ?").get("o/r#1") as Record<string, unknown>;
232
+ assertEquals(row.plan_key, "o/r#1");
233
+ assertEquals(row.status, "done");
234
+ assertEquals(row.list_bucket, "active");
235
+ assertEquals(row.wave_label, "2/2");
236
+ assertEquals(row.wave_count, 2);
237
+ assertEquals(row.current_wave, 1);
238
+ assertEquals(row.delivery, "converging");
239
+ assertEquals(row.delivery_label, "1/2 slices merged, 1 converging");
240
+
241
+ // A taskless plan still appears (LEFT JOINs), with the derived columns NULL.
242
+ 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>;
243
+ assertEquals({ ...empty }, { wave_label: null, delivery: null, delivery_label: null });
244
+ });
245
+
246
+ test("the operator pages read the derived plan_read_model VIEW for the wave/delivery cells", () => {
247
+ // Overview epics grid — binds the view and still surfaces wave_label + delivery_label.
248
+ const overview = PAGE("overview.page.json");
249
+ const epics = (overview.nodes ?? []).find((n: { id: string }) => n.id === "overview-epics");
250
+ assert(epics, "overview must keep the Active Epics grid");
251
+ assertEquals(epics.props.data.table, "plan_read_model");
252
+ const epicCols: string[] = epics.props.columns.map((c: { field: string }) => c.field);
253
+ assert(epicCols.includes("wave_label") && epicCols.includes("delivery_label"), "overview epics grid surfaces wave_label + delivery_label");
254
+
255
+ // Epic-detail wave banner + plan grid both read the view.
256
+ const detail = PAGE("epic-detail.page.json");
257
+ const byId = (id: string) => (detail.nodes ?? []).find((n: { id: string }) => n.id === id);
258
+ assertEquals(byId("wave-banner").props.data.table, "plan_read_model");
259
+ assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
260
+ assert(/\{\{\s*wave_label\s*\}\}/.test(byId("wave-banner").props.header), "the banner surfaces wave_label");
261
+ assertEquals(byId("wave-banner").props.body, "delivery_label", "the banner body is the delivery_label");
262
+ });
@@ -0,0 +1,93 @@
1
+ -- Epic-detail wave visualization + task→representation links (issue #411).
2
+ --
3
+ -- The Epic detail page (pages/epic-detail.page.json) had no glanceable per-wave progress and no
4
+ -- click-through from an in-flight task to the thing that represents it (its PR / process instance).
5
+ -- Both are pure ROLLUPS of data that already exists — `plan_tasks` (the slices + their wave) joined
6
+ -- to `pull_requests` (each slice's PR url + engine `process_key`) — so per AGENTS.md "Derivation over
7
+ -- duplication / no drift surfaces" they are DERIVED, not denormalised onto a worker-written table.
8
+ --
9
+ -- Historically the codebase reached for a denormalised flat table here (see 022_plan_wave_progress,
10
+ -- 029_plan_delivery, 051_merges_per_day) purely because Urban's datasource could not read a SQL
11
+ -- VIEW. That constraint is gone (nano-ide#424: `gateway.schema()` now introspects
12
+ -- `type IN ('table','view')` and tags a view read-only), so these are VIEWs — a single source of
13
+ -- truth with NO write-path and no possibility of drift from `plan_tasks`.
14
+ --
15
+ -- Three views, layered so each is a plain `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no
16
+ -- select-list subquery) — which keeps them parseable by the static pages↔schema contract guard
17
+ -- (scripts/pages-contract.test.ts) that introspects the migrations to whitelist page columns:
18
+ --
19
+ -- • plan_wave_tasks — per-task rows (every `plan_tasks` column) PLUS the link targets the
20
+ -- wave-state grid needs to reach a task's representation: `pr_url`
21
+ -- (pull_requests.url — the GitHub PR) and `process_key`
22
+ -- (pull_requests.process_key — the engine instance, for the processExplorer
23
+ -- link, exactly as the Plan grid links its own `process_key`).
24
+ -- • plan_wave_counts — one row per (plan_key, wave) with the six-way task partition
25
+ -- (total / merged / in_flight / blocked / escalated / skipped). A task is
26
+ -- `merged` iff its PR reached `pull_requests.status = 'merged'` (the same
27
+ -- merged predicate app/delivery.ts derives the epic rollup from); otherwise
28
+ -- it falls to its `plan_tasks.status` bucket, and everything else
29
+ -- (pending/opened/waiting-for-lane/abandoned) is `in_flight`. The CASE
30
+ -- priority makes the five named buckets DISJOINT so they always sum to
31
+ -- `total` — which is what lets the bar below use `total` as its width.
32
+ -- • plan_wave_summary — the same counts PLUS `bar`, a PRE-FORMATTED progress string
33
+ -- (e.g. "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked"). It is pre-formatted
34
+ -- because the dataGrid renderer has no per-cell templating — a bar has to be
35
+ -- a ready-to-show string. `▓` = merged, `░` = not-yet-merged; the block run
36
+ -- is built with SQLite string funcs (`hex(zeroblob(n))` → n '0' chars →
37
+ -- `replace` to the glyph), and only non-zero categories are named in the
38
+ -- suffix.
39
+ --
40
+ -- Forward-only, additive (a new read model, no schema change to any base table). The runner wraps
41
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after the
42
+ -- current highest prefix (058).
43
+
44
+ CREATE VIEW plan_wave_tasks AS
45
+ SELECT
46
+ t.id AS id,
47
+ t.plan_key AS plan_key,
48
+ t.task_index AS task_index,
49
+ t.task_id AS task_id,
50
+ t.title AS title,
51
+ t.prompt AS prompt,
52
+ t.status AS status,
53
+ t.pr_key AS pr_key,
54
+ t.summary AS summary,
55
+ t.created_at AS created_at,
56
+ t.updated_at AS updated_at,
57
+ t.wave AS wave,
58
+ t.open_question AS open_question,
59
+ t.answer AS answer,
60
+ t.draft_pr_key AS draft_pr_key,
61
+ t.corr_key AS corr_key,
62
+ p.url AS pr_url,
63
+ p.process_key AS process_key
64
+ FROM plan_tasks t
65
+ LEFT JOIN pull_requests p ON p.pr_key = t.pr_key;
66
+
67
+ CREATE VIEW plan_wave_counts AS
68
+ SELECT
69
+ t.plan_key AS plan_key,
70
+ t.wave AS wave,
71
+ COUNT(*) AS total,
72
+ SUM(CASE WHEN p.status = 'merged' THEN 1 ELSE 0 END) AS merged,
73
+ SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status = 'skipped' THEN 1 ELSE 0 END) AS skipped,
74
+ SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status = 'blocked' THEN 1 ELSE 0 END) AS blocked,
75
+ SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status = 'escalated' THEN 1 ELSE 0 END) AS escalated,
76
+ SUM(CASE WHEN p.status = 'merged' THEN 0 WHEN t.status IN ('skipped', 'blocked', 'escalated') THEN 0 ELSE 1 END) AS in_flight
77
+ FROM plan_tasks t
78
+ LEFT JOIN pull_requests p ON p.pr_key = t.pr_key
79
+ WHERE t.wave IS NOT NULL
80
+ GROUP BY t.plan_key, t.wave;
81
+
82
+ CREATE VIEW plan_wave_summary AS
83
+ SELECT
84
+ c.plan_key AS plan_key,
85
+ c.wave AS wave,
86
+ c.total AS total,
87
+ c.merged AS merged,
88
+ c.in_flight AS in_flight,
89
+ c.blocked AS blocked,
90
+ c.escalated AS escalated,
91
+ c.skipped AS skipped,
92
+ replace(substr(hex(zeroblob(c.merged)), 1, c.merged), '0', '▓') || replace(substr(hex(zeroblob(c.total - c.merged)), 1, c.total - c.merged), '0', '░') || ' ' || c.merged || '/' || c.total || ' merged' || CASE WHEN c.in_flight > 0 THEN ' · ' || c.in_flight || ' in-flight' ELSE '' END || CASE WHEN c.blocked > 0 THEN ' · ' || c.blocked || ' blocked' ELSE '' END || CASE WHEN c.escalated > 0 THEN ' · ' || c.escalated || ' escalated' ELSE '' END || CASE WHEN c.skipped > 0 THEN ' · ' || c.skipped || ' skipped' ELSE '' END AS bar
93
+ FROM plan_wave_counts c;
@@ -0,0 +1,52 @@
1
+ -- Wave-progress read model as a derived VIEW (epic #412 — retire worker-maintained projections).
2
+ --
3
+ -- 022_plan_wave_progress.sql denormalised `plans.wave_count` / `plans.current_wave` /
4
+ -- `plans.wave_label` — a per-epic "wave X/N" at-a-glance projection — onto the `plans` row, written
5
+ -- by the wave workers (`record-plan`, `select-wave`, `record-wave`). Its comment cites the sole
6
+ -- reason it was a worker-maintained table rather than a VIEW: "Urban's datasource cannot read a SQL
7
+ -- VIEW". That constraint is gone (nano-ide#424: `gateway.schema()` now introspects
8
+ -- `type IN ('table','view')`), so — exactly like 059 did for the wave-summary rollup — this expresses
9
+ -- the same projection as a DERIVED view: a single source of truth with NO write-path and no drift
10
+ -- from `plan_tasks`.
11
+ --
12
+ -- Reuses 059's `plan_wave_counts` (one row per (plan_key, wave), with the six-way task partition,
13
+ -- including `in_flight`) so the frontier can be derived purely, layered so each view stays a plain
14
+ -- `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no select-list subquery) — which keeps them
15
+ -- parseable by the static pages↔schema contract guard (scripts/pages-contract.test.ts).
16
+ --
17
+ -- • plan_wave_progress — one row per plan_key with the two numeric projections:
18
+ -- - wave_count = MAX(wave)+1 (the levelizer emits contiguous waves 0..N-1, so this equals
19
+ -- `app/waves.ts` `waveCount`). A plan with no LEVELIZED tasks contributes no
20
+ -- `plan_wave_counts` row, so it is absent here and reads NULL through the
21
+ -- downstream LEFT JOIN — matching the workers, which leave a taskless plan's
22
+ -- wave columns NULL.
23
+ -- - current_wave = the live FRONTIER, derived (not process-state-tracked): the lowest wave
24
+ -- that still has an `in_flight` task (the wave the fleet is actively
25
+ -- implementing / the wave the merge-barrier is gating), else — once every
26
+ -- wave has settled (in_flight = 0 everywhere) — pinned to the last index
27
+ -- MAX(wave). This reproduces the workers' projection: `record-plan` starts it
28
+ -- at 0 (wave 0 is in flight), `record-wave`/`select-wave` advance it to the
29
+ -- next gating wave as each wave's PRs merge, and it pins to wave_count-1 on
30
+ -- completion (a finished epic reads N/N).
31
+ -- • plan_wave_label — the same two numbers PLUS `wave_label`, the PRE-FORMATTED 1-based "X/N"
32
+ -- display string (`(current_wave+1)/wave_count`) the epics-index and epic
33
+ -- banner render, because the dataGrid has no per-cell templating.
34
+ --
35
+ -- Forward-only, additive (a new read model; no schema change to any base table). The runner wraps
36
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
37
+
38
+ CREATE VIEW plan_wave_progress AS
39
+ SELECT
40
+ c.plan_key AS plan_key,
41
+ MAX(c.wave) + 1 AS wave_count,
42
+ COALESCE(MIN(CASE WHEN c.in_flight > 0 THEN c.wave END), MAX(c.wave)) AS current_wave
43
+ FROM plan_wave_counts c
44
+ GROUP BY c.plan_key;
45
+
46
+ CREATE VIEW plan_wave_label AS
47
+ SELECT
48
+ w.plan_key AS plan_key,
49
+ w.wave_count AS wave_count,
50
+ w.current_wave AS current_wave,
51
+ (w.current_wave + 1) || '/' || w.wave_count AS wave_label
52
+ FROM plan_wave_progress w;
@@ -0,0 +1,98 @@
1
+ -- Epic delivery read model as a derived VIEW, plus the composite `plans` read model the pages bind
2
+ -- (epic #412 — retire worker-maintained projections).
3
+ --
4
+ -- 029_plan_delivery.sql denormalised `plans.delivery` ('converging'|'landed'|NULL) and
5
+ -- `plans.delivery_label` onto the `plans` row, recomputed each poll pass by `pollDelivery`
6
+ -- (app/service.ts) which joins each `plan_tasks.pr_key` → `pull_requests.status`. The PURE derivation
7
+ -- lives in `deriveDelivery`/`TERMINAL_STATUSES` (app/delivery.ts). Its comment cites the sole reason
8
+ -- it was a poller-maintained table rather than a VIEW: "Urban's datasource cannot read a SQL VIEW".
9
+ -- That constraint is gone (nano-ide#424), so this expresses the SAME `deriveDelivery` logic as a
10
+ -- DERIVED view — a single source of truth with NO write-path and no drift.
11
+ --
12
+ -- Layered so each view stays a plain `CREATE VIEW <name> AS SELECT … FROM …` (no CTE / no
13
+ -- select-list subquery), parseable by scripts/pages-contract.test.ts:
14
+ --
15
+ -- • plan_delivery_counts — one row per plan_key with the three counts `deriveDelivery` folds over
16
+ -- the slice PRs (only tasks that OPENED a PR — `pr_key IS NOT NULL` —
17
+ -- count, mirroring `pollDelivery`'s `if (!t.pr_key) continue`):
18
+ -- - prs_opened = number of slice tasks with a PR.
19
+ -- - prs_merged = those whose PR reached `status = 'merged'`.
20
+ -- - prs_in_flight = those whose PR is NON-terminal (`status` NOT IN
21
+ -- `TERMINAL_STATUSES` = converged/merged/abandoned). A `pr_key` with
22
+ -- no `pull_requests` row (status NULL, the poller's MISSING_PR_STATUS
23
+ -- sentinel) is non-terminal, so it counts as in flight — a DB desync
24
+ -- can never wrongly promote an epic to `landed`.
25
+ -- • plan_delivery — the derived signal + PRE-FORMATTED label, per `deriveDelivery`:
26
+ -- - NULL when the plan is not `done` or opened no PRs (no positive
27
+ -- signal yet), OR every PR is terminal but not all merged
28
+ -- (resolved-not-landed).
29
+ -- - 'converging' + "M/O slices merged, F converging" when ≥1 PR is in
30
+ -- flight.
31
+ -- - 'landed' + "O/O slices merged" when every slice PR merged
32
+ -- (prs_in_flight = 0 AND prs_merged = prs_opened > 0).
33
+ -- • plan_read_model — the `plans` row with its wave (060) and delivery projections DERIVED
34
+ -- from the views instead of read from the denormalised columns. This is
35
+ -- the datasource the operator pages (overview / epic-detail) bind, so that
36
+ -- when the wave-1 cleanup task DROPs plans.wave_label / plans.current_wave
37
+ -- / plans.wave_count / plans.delivery / plans.delivery_label, every page
38
+ -- already reads the single-source-of-truth views. It projects only the
39
+ -- `plans` columns those pages reference, plus the five derived columns.
40
+ --
41
+ -- Forward-only, additive. NO BEGIN/COMMIT — the runner wraps each file in its own transaction.
42
+
43
+ CREATE VIEW plan_delivery_counts AS
44
+ SELECT
45
+ t.plan_key AS plan_key,
46
+ COUNT(t.pr_key) AS prs_opened,
47
+ SUM(CASE WHEN t.pr_key IS NOT NULL AND p.status = 'merged' THEN 1 ELSE 0 END) AS prs_merged,
48
+ SUM(CASE WHEN t.pr_key IS NOT NULL AND (p.status IS NULL OR p.status NOT IN ('converged', 'merged', 'abandoned')) THEN 1 ELSE 0 END) AS prs_in_flight
49
+ FROM plan_tasks t
50
+ LEFT JOIN pull_requests p ON p.pr_key = t.pr_key
51
+ GROUP BY t.plan_key;
52
+
53
+ CREATE VIEW plan_delivery AS
54
+ SELECT
55
+ pl.plan_key AS plan_key,
56
+ CASE
57
+ WHEN pl.status IS NOT 'done' OR COALESCE(c.prs_opened, 0) = 0 THEN NULL
58
+ WHEN COALESCE(c.prs_in_flight, 0) > 0 THEN 'converging'
59
+ WHEN c.prs_merged = c.prs_opened THEN 'landed'
60
+ ELSE NULL
61
+ END AS delivery,
62
+ CASE
63
+ WHEN pl.status IS NOT 'done' OR COALESCE(c.prs_opened, 0) = 0 THEN NULL
64
+ WHEN COALESCE(c.prs_in_flight, 0) > 0 THEN c.prs_merged || '/' || c.prs_opened || ' slices merged, ' || c.prs_in_flight || ' converging'
65
+ WHEN c.prs_merged = c.prs_opened THEN c.prs_opened || '/' || c.prs_opened || ' slices merged'
66
+ ELSE NULL
67
+ END AS delivery_label
68
+ FROM plans pl
69
+ LEFT JOIN plan_delivery_counts c ON c.plan_key = pl.plan_key;
70
+
71
+ CREATE VIEW plan_read_model AS
72
+ SELECT
73
+ pl.plan_key AS plan_key,
74
+ pl.repo AS repo,
75
+ pl.issue_number AS issue_number,
76
+ pl.issue_url AS issue_url,
77
+ pl.title AS title,
78
+ pl.status AS status,
79
+ pl.task_count AS task_count,
80
+ pl.process_key AS process_key,
81
+ pl.outcome AS outcome,
82
+ pl.updated_at AS updated_at,
83
+ pl.epic_phase AS epic_phase,
84
+ pl.base_branch AS base_branch,
85
+ pl.wait_gate_label AS wait_gate_label,
86
+ pl.bound_artifacts AS bound_artifacts,
87
+ pl.promotion_pr AS promotion_pr,
88
+ pl.promotion_state AS promotion_state,
89
+ pl.list_bucket AS list_bucket,
90
+ pl.ack_open AS ack_open,
91
+ wl.wave_count AS wave_count,
92
+ wl.current_wave AS current_wave,
93
+ wl.wave_label AS wave_label,
94
+ d.delivery AS delivery,
95
+ d.delivery_label AS delivery_label
96
+ FROM plans pl
97
+ LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
98
+ LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;
@@ -0,0 +1,72 @@
1
+ -- Merged-per-day throughput / burn-up as a derived SQL VIEW (epic #412, retiring the 051 flat table).
2
+ --
3
+ -- 051_merges_per_day.sql created the DENORMALISED `merges_per_day` read table (day / merged /
4
+ -- cumulative / bar) that `pollMergesPerDay` (app/mergesPerDay.ts) recomputes each poll pass from the
5
+ -- `merges` audit rows (004_merge.sql). Its comment cites the ONE reason it could not simply be a
6
+ -- VIEW: "Urban's page datasource cannot read a SQL VIEW" (gateway.ts `schema()` whitelisted
7
+ -- `type='table'` only). That constraint is GONE — nano-ide#424 made `gateway.schema()` introspect
8
+ -- `type IN ('table','view')` and tag a view read-only, and #411 (059_plan_wave_summary.sql)
9
+ -- established the layered-VIEW pattern. So the aggregate becomes what AGENTS.md always wanted
10
+ -- ("Derivation over duplication"): a VIEW that is a single source of truth with NO write-path and no
11
+ -- possibility of drift from the `merges` audit trail.
12
+ --
13
+ -- This migration is WAVE-0 / PURELY ADDITIVE: it adds the VIEW and the Velocity page is repointed
14
+ -- onto it, but the `merges_per_day` TABLE and its `pollMergesPerDay` write-path are LEFT IN PLACE (a
15
+ -- harmless duplicate) so the surface never goes stale while both coexist. A wave-1 cleanup task
16
+ -- ("retire-projection-writepaths-cleanup") drops the table and deletes the write-path AFTER this
17
+ -- merges.
18
+ --
19
+ -- The VIEW must reproduce the CURRENT projection EXACTLY, including two subtleties beyond the 051
20
+ -- comment's canonical `SELECT date(at) AS day, COUNT(DISTINCT pr_key) …`:
21
+ -- • the day is bucketed in the operator's LOCAL calendar day (issue #361) — `date(at, 'localtime')`,
22
+ -- not UTC — matching `deriveMergesPerDay`, so a merge either side of a local midnight lands on the
23
+ -- day the operator saw it;
24
+ -- • `bar` is the SAME pre-formatted proportional block-character string the `prose` renderer draws
25
+ -- today (the renderer has no per-cell templating / no chart node, so the bar must arrive
26
+ -- ready-to-show): a run of `█` glyphs whose length is `max(1, round((merged / busiest) * 30))`
27
+ -- (min one glyph for any non-zero day; the busiest day is 30 wide), i.e. exactly
28
+ -- `barFor()`/`BAR_WIDTH`/`BAR_FULL` in app/mergesPerDay.ts.
29
+ --
30
+ -- Layered into TWO plain views so each is a `CREATE VIEW <name> AS SELECT … FROM …` with NO CTE and
31
+ -- NO select-list subquery — which keeps them parseable by the static pages↔schema contract guard
32
+ -- (scripts/pages-contract.test.ts), exactly as 059 layers its counts → summary:
33
+ --
34
+ -- • merges_per_day_counts — one row per local calendar day: `day` + `merged`
35
+ -- (COUNT(DISTINCT pr_key) so a PR with several `merged` audit rows on one day — an
36
+ -- already-merged short-circuit or a retry — counts once; `queued`/`blocked` rows are excluded).
37
+ -- • merges_per_day_view — the same rows PLUS the burn-up `cumulative`
38
+ -- (`SUM(merged) OVER (ORDER BY day)` — a window function in the select list, allowed by the
39
+ -- guard) and the pre-formatted `bar`. The bar length uses `MAX(merged) OVER ()` (the busiest
40
+ -- day) as the scale; the block run is built with SQLite string funcs
41
+ -- (`hex(zeroblob(n))` → 2n '0' chars → `substr` to n → `replace` to the `█` glyph), the same
42
+ -- trick 059 uses for its progress bar. Both live in the SELECT list, not a subquery, so the
43
+ -- guard can still read every output column.
44
+ --
45
+ -- Forward-only, additive (a new read model, no change to any base table). The runner wraps each file
46
+ -- in its own transaction, so this file must NOT contain BEGIN/COMMIT.
47
+
48
+ CREATE VIEW merges_per_day_counts AS
49
+ SELECT
50
+ date(m.at, 'localtime') AS day,
51
+ COUNT(DISTINCT m.pr_key) AS merged
52
+ FROM merges m
53
+ WHERE m.outcome = 'merged'
54
+ GROUP BY date(m.at, 'localtime');
55
+
56
+ CREATE VIEW merges_per_day_view AS
57
+ SELECT
58
+ c.day AS day,
59
+ c.merged AS merged,
60
+ SUM(c.merged) OVER (ORDER BY c.day) AS cumulative,
61
+ CASE
62
+ WHEN c.merged <= 0 OR MAX(c.merged) OVER () <= 0 THEN ''
63
+ ELSE replace(
64
+ substr(
65
+ hex(zeroblob(max(1, CAST(round((c.merged * 1.0 / MAX(c.merged) OVER ()) * 30.0) AS INTEGER)))),
66
+ 1,
67
+ max(1, CAST(round((c.merged * 1.0 / MAX(c.merged) OVER ()) * 30.0) AS INTEGER))
68
+ ),
69
+ '0', '█'
70
+ )
71
+ END AS bar
72
+ FROM merges_per_day_counts c;
@@ -0,0 +1,75 @@
1
+ -- Lineage read-model: derive the view-expressible identity columns of `lineage_threads` (epic
2
+ -- #412 — "Retire worker-maintained denormalized projections in favour of SQL VIEWs").
3
+ --
4
+ -- `lineage_threads` (037_lineage.sql) is a denormalised read table `pollLineage` (app/lineage.ts)
5
+ -- rewrites every poll pass, stitching request → implementation → PR(s) → convergence → merge into
6
+ -- one narrative per `root_request_key`. Its comment cited "Urban's datasource cannot read a SQL
7
+ -- VIEW" as the sole reason to denormalise; nano-ide#424 removed that constraint (gateway.schema()
8
+ -- now introspects `type IN ('table','view')`), so the parts that are plain rollups of data that
9
+ -- ALREADY exists should be DERIVED, not duplicated (AGENTS.md "Derivation over duplication / no
10
+ -- drift surfaces"), exactly as 059_plan_wave_summary.sql did for the plans wave/delivery rollups.
11
+ --
12
+ -- AUDIT — per column, is it a clean rollup or genuinely procedural?
13
+ --
14
+ -- VIEW-EXPRESSIBLE (pure structural function of which origin table the root matches — no frontier
15
+ -- logic, no representative-PR selection, no formatting — so a plain parseable view reproduces them
16
+ -- EXACTLY as `deriveLineage` does):
17
+ -- • `kind` — 'epic' when the root is a `plans.plan_key`, 'feature' when it is a
18
+ -- `feature_runs.feature_key`, else 'pr' (self-rooted human/webhook PR). The
19
+ -- epic-before-feature precedence mirrors `collectThreads`, which sets the plan
20
+ -- thread after the feature thread for the same key.
21
+ -- • `issue_url` — the matched origin's `issue_url` (`plans`/`feature_runs`), NULL for a
22
+ -- self-rooted PR — exactly `deriveLineage`'s `origin.kind === "pr" ? null : …`.
23
+ -- • `title` — the matched origin's `title` for an epic/feature thread. A self-rooted PR's
24
+ -- title is the PROCEDURAL representative-PR pick, so it falls back to the
25
+ -- poller-written `lineage_threads.title` for kind 'pr' (see below).
26
+ --
27
+ -- PROCEDURAL (multi-stage frontier / ordering logic in the pure `deriveLineage`, which selects a
28
+ -- representative PR — "first non-terminal by pr_key, else last by pr_key" — branches on origin
29
+ -- kind + feature pre-hand-off, rolls epic fan-out up via `deriveDelivery`, and formats round /
30
+ -- slice-count label strings; none of this is a plain no-CTE / no-select-list-subquery view, and
31
+ -- forcing it would risk diverging from the tested derivation): `stage`, `stage_label`,
32
+ -- `process_key`, `active`, plus the membership columns `pr_count` / `pr_keys` (which union a
33
+ -- root's threaded PRs with its origin's own `pr_key` / `plan_tasks.pr_key` and dedupe across
34
+ -- roots — not a clean grouped join) and the self-rooted `title`. These stay written by
35
+ -- `pollLineage` for the wave-1 cleanup task to trim; this view PASSES THEM THROUGH from
36
+ -- `lineage_threads` so the single Lineage grid keeps rendering identically.
37
+ --
38
+ -- The view is a plain `CREATE VIEW <name> AS SELECT … FROM …` — no CTE, no select-list subquery,
39
+ -- every column aliased — so the static pages↔schema contract guard (scripts/pages-contract.test.ts)
40
+ -- can introspect its output columns to whitelist the repointed page. CASE / COALESCE in the select
41
+ -- list are fine (they are not subqueries).
42
+ --
43
+ -- Forward-only, additive: a new read model, no schema change to any base table, no DROP. The runner
44
+ -- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. This task owns
45
+ -- the disjoint migration block 064-069; a single view suffices, so 065-069 are left unused.
46
+
47
+ CREATE VIEW lineage_thread_view AS
48
+ SELECT
49
+ lt.root_request_key AS root_request_key,
50
+ CASE
51
+ WHEN pl.plan_key IS NOT NULL THEN 'epic'
52
+ WHEN fr.feature_key IS NOT NULL THEN 'feature'
53
+ ELSE 'pr'
54
+ END AS kind,
55
+ CASE
56
+ WHEN pl.plan_key IS NOT NULL THEN pl.title
57
+ WHEN fr.feature_key IS NOT NULL THEN fr.title
58
+ ELSE lt.title
59
+ END AS title,
60
+ CASE
61
+ WHEN pl.plan_key IS NOT NULL THEN pl.issue_url
62
+ WHEN fr.feature_key IS NOT NULL THEN fr.issue_url
63
+ ELSE NULL
64
+ END AS issue_url,
65
+ lt.stage AS stage,
66
+ lt.stage_label AS stage_label,
67
+ lt.process_key AS process_key,
68
+ lt.pr_keys AS pr_keys,
69
+ lt.pr_count AS pr_count,
70
+ lt.active AS active,
71
+ lt.created_at AS created_at,
72
+ lt.updated_at AS updated_at
73
+ FROM lineage_threads lt
74
+ LEFT JOIN plans pl ON pl.plan_key = lt.root_request_key
75
+ LEFT JOIN feature_runs fr ON fr.feature_key = lt.root_request_key;