@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.
- package/CHANGELOG.md +15 -0
- package/app/lineage.ts +25 -0
- package/app/mergesPerDayView.test.ts +166 -0
- package/app/migration064.test.ts +176 -0
- package/app/planWaveSummary.test.ts +170 -0
- package/app/plansReadModel.test.ts +262 -0
- package/db/migrations/059_plan_wave_summary.sql +93 -0
- package/db/migrations/060_plan_wave_rollup.sql +52 -0
- package/db/migrations/061_plan_delivery_rollup.sql +98 -0
- package/db/migrations/062_merges_per_day_view.sql +72 -0
- package/db/migrations/064_lineage_thread_view.sql +75 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +49 -4
- package/pages/lineage.page.json +1 -1
- package/pages/overview.page.json +1 -1
- package/pages/velocity.page.json +1 -1
- package/scripts/pages-contract.test.ts +51 -1
|
@@ -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;
|