@nanobpm/nano-workforce 0.119.0 → 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 +8 -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 +4 -2
- package/app/plansReadModel.test.ts +262 -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 +2 -2
- 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 +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
# [0.120.0](https://github.com/nanobpm/nano-workforce/compare/v0.119.0...v0.120.0) (2026-08-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **lineage:** derive view-expressible lineage_threads columns via SQL VIEW ([#437](https://github.com/nanobpm/nano-workforce/issues/437)) ([f25c824](https://github.com/nanobpm/nano-workforce/commit/f25c824bf9c72c3757a58794065a5c86b208dbe0)), closes [nano-ide#424](https://github.com/nano-ide/issues/424) [nanobpm/nano-workforce#412](https://github.com/nanobpm/nano-workforce/issues/412)
|
|
7
|
+
* **velocity:** back merged-per-day with a SQL VIEW, repoint Velocity page ([#412](https://github.com/nanobpm/nano-workforce/issues/412)) ([#436](https://github.com/nanobpm/nano-workforce/issues/436)) ([5be81e4](https://github.com/nanobpm/nano-workforce/commit/5be81e466f2593622ae837d6e63b00f0287c305d)), closes [nano-ide#424](https://github.com/nano-ide/issues/424) [#411](https://github.com/nanobpm/nano-workforce/issues/411) [#361](https://github.com/nanobpm/nano-workforce/issues/361)
|
|
8
|
+
|
|
1
9
|
# [0.119.0](https://github.com/nanobpm/nano-workforce/compare/v0.118.2...v0.119.0) (2026-08-22)
|
|
2
10
|
|
|
3
11
|
|
package/app/lineage.ts
CHANGED
|
@@ -330,6 +330,31 @@ export interface LineageThreadRow {
|
|
|
330
330
|
const lineageThreads = (data: DataLayer) =>
|
|
331
331
|
data.table<LineageThreadRow>("lineage_threads", "root_request_key");
|
|
332
332
|
|
|
333
|
+
/** Read-only handle on the `lineage_thread_view` VIEW (migration 064) the Lineage page now binds.
|
|
334
|
+
* Same shape as `LineageThreadRow`: the view PASSES THROUGH the procedural frontier columns from
|
|
335
|
+
* `lineage_threads` (`stage`/`stage_label`/`process_key`/`pr_keys`/`pr_count`/`active`/timestamps)
|
|
336
|
+
* and DERIVES the view-expressible identity columns (`kind`, `issue_url`, and an epic/feature
|
|
337
|
+
* thread's `title`) from the `plans`/`feature_runs` origin joins — the single source of truth,
|
|
338
|
+
* eliminating those columns' drift surface (epic #412). A self-rooted PR's `title` falls back to
|
|
339
|
+
* the poller-written value, since it is a procedural representative-PR pick. */
|
|
340
|
+
const lineageThreadView = (data: DataLayer) =>
|
|
341
|
+
data.table<LineageThreadRow>("lineage_thread_view", "root_request_key");
|
|
342
|
+
|
|
343
|
+
/** All lineage threads off the same `lineage_thread_view` VIEW the Lineage page binds, in this
|
|
344
|
+
* module's own active-frontier-first, then-by-key stable order (matching {@link listLineage}) —
|
|
345
|
+
* NOT the page datasource's `orderBy: updated_at desc` + tab-specific `active` filter, which the
|
|
346
|
+
* page applies on top of this table. Additive: this reads the derived view rather than recomputing
|
|
347
|
+
* via {@link collectThreads}, so it reflects the identity columns' single source of truth (origin
|
|
348
|
+
* joins) while the frontier columns come through from the still-poller-written `lineage_threads`. */
|
|
349
|
+
export async function listLineageView(data: DataLayer): Promise<LineageThreadRow[]> {
|
|
350
|
+
const rows = await lineageThreadView(data).all();
|
|
351
|
+
return rows.sort(
|
|
352
|
+
(a, b) =>
|
|
353
|
+
Number(b.active) - Number(a.active) ||
|
|
354
|
+
a.root_request_key.localeCompare(b.root_request_key),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
333
358
|
function toLineagePr(row: PrRow): LineagePr {
|
|
334
359
|
return {
|
|
335
360
|
prKey: row.pr_key,
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Coverage for the merged-per-day VIEW that retires the denormalised `merges_per_day` table
|
|
2
|
+
// (epic #412; the read model originally shipped in issue #344 / 051_merges_per_day.sql).
|
|
3
|
+
//
|
|
4
|
+
// The whole point of #412 is that this aggregate — merged-per-day throughput + burn-up + a
|
|
5
|
+
// pre-formatted proportional bar — is now a derived SQL VIEW (enabled by nano-ide#424) instead of a
|
|
6
|
+
// worker-written flat table, so it is a single source of truth with no drift. This test therefore
|
|
7
|
+
// exercises the REAL SQLite view (062_merges_per_day_view.sql applied to an in-memory DB, mirroring
|
|
8
|
+
// migration053.test.ts / planWaveSummary.test.ts) and pins that it reproduces the previous
|
|
9
|
+
// projection's EXACT values: the DISTINCT-per-day count, the ascending burn-up `cumulative`, and the
|
|
10
|
+
// byte-for-byte pre-formatted `bar` string the Velocity `prose` renderer draws.
|
|
11
|
+
//
|
|
12
|
+
// `deriveMergesPerDay` (app/mergesPerDay.ts) is the pure function the retired `pollMergesPerDay`
|
|
13
|
+
// write-path used, so it is the authoritative oracle for "what the table held". We assert the view
|
|
14
|
+
// equals it over sample `merges` rows. Bucketing is local-calendar-day (issue #361 — the view uses
|
|
15
|
+
// `date(at, 'localtime')`, which resolves against the host zone). To keep the SQLite `localtime`
|
|
16
|
+
// bucketing and the JS oracle in lockstep WITHOUT mutating the process-global `process.env.TZ`
|
|
17
|
+
// (which `node --test` runs concurrently across FILES, so a `TZ` flip could leak into the
|
|
18
|
+
// timezone-specific assertions in app/mergesPerDay.test.ts), we drive the oracle with the SAME host
|
|
19
|
+
// zone SQLite uses by omitting its `timeZone` argument — `deriveMergesPerDay(rows)` defaults to the
|
|
20
|
+
// host zone. Both sides therefore bucket identically in any host zone, so no `day` string is
|
|
21
|
+
// hard-coded.
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { DatabaseSync } from "node:sqlite";
|
|
25
|
+
import { test } from "node:test";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { assert, assertEquals } from "#test-assert";
|
|
28
|
+
import { deriveMergesPerDay, type MergeAuditRow } from "./mergesPerDay.ts";
|
|
29
|
+
|
|
30
|
+
const MIGRATION = fileURLToPath(new URL("../db/migrations/062_merges_per_day_view.sql", import.meta.url));
|
|
31
|
+
|
|
32
|
+
/** A DB with the `merges` audit shape (004_merge.sql, FK-free like migration053.test.ts) + the view. */
|
|
33
|
+
function viewDb(): DatabaseSync {
|
|
34
|
+
const db = new DatabaseSync(":memory:");
|
|
35
|
+
db.exec(
|
|
36
|
+
`CREATE TABLE merges (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, pr_key TEXT NOT NULL, outcome TEXT NOT NULL,
|
|
38
|
+
method TEXT, detail TEXT, at TEXT NOT NULL);`,
|
|
39
|
+
);
|
|
40
|
+
db.exec(readFileSync(MIGRATION, "utf8"));
|
|
41
|
+
return db;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function seed(db: DatabaseSync, rows: readonly MergeAuditRow[]): void {
|
|
45
|
+
const ins = db.prepare("INSERT INTO merges (pr_key, outcome, at) VALUES (?, ?, ?)");
|
|
46
|
+
for (const r of rows) ins.run(r.pr_key, r.outcome, r.at);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The view's rows as `[day, merged, cumulative, bar]`, ordered like the Velocity page (day asc). */
|
|
50
|
+
function view(db: DatabaseSync): Array<[string, number, number, string]> {
|
|
51
|
+
return (
|
|
52
|
+
db.prepare("SELECT day, merged, cumulative, bar FROM merges_per_day_view ORDER BY day").all() as Array<
|
|
53
|
+
Record<string, unknown>
|
|
54
|
+
>
|
|
55
|
+
).map((r) => [r.day as string, Number(r.merged), Number(r.cumulative), r.bar as string]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const merged = (pr_key: string, at: string): MergeAuditRow => ({ pr_key, outcome: "merged", at });
|
|
59
|
+
|
|
60
|
+
test("merges_per_day_view reproduces the retired projection exactly (count, burn-up, bar)", () => {
|
|
61
|
+
const db = viewDb();
|
|
62
|
+
const rows: MergeAuditRow[] = [
|
|
63
|
+
// day A: 4 distinct PRs (the busiest day → widest bar), including a duplicate merged row.
|
|
64
|
+
merged("o/r#1", "2026-01-01T01:00:00Z"),
|
|
65
|
+
merged("o/r#2", "2026-01-01T02:00:00Z"),
|
|
66
|
+
merged("o/r#3", "2026-01-01T03:00:00Z"),
|
|
67
|
+
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
68
|
+
merged("o/r#4", "2026-01-01T05:00:00Z"), // retry / already-merged short-circuit → counts once
|
|
69
|
+
// day B: 1 PR → short but visible bar.
|
|
70
|
+
merged("o/r#5", "2026-01-02T10:00:00Z"),
|
|
71
|
+
// day C: 2 PRs.
|
|
72
|
+
merged("o/r#6", "2026-01-03T10:00:00Z"),
|
|
73
|
+
merged("o/r#7", "2026-01-03T11:00:00Z"),
|
|
74
|
+
// non-merged attempts must be ignored entirely.
|
|
75
|
+
{ pr_key: "o/r#8", outcome: "queued", at: "2026-01-02T12:00:00Z" },
|
|
76
|
+
{ pr_key: "o/r#9", outcome: "blocked", at: "2026-01-03T12:00:00Z" },
|
|
77
|
+
];
|
|
78
|
+
seed(db, rows);
|
|
79
|
+
|
|
80
|
+
const expected = deriveMergesPerDay(rows).map(
|
|
81
|
+
(d) => [d.day, d.merged, d.cumulative, d.bar] as [string, number, number, string],
|
|
82
|
+
);
|
|
83
|
+
assertEquals(view(db), expected);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("merges_per_day_view bar: full glyph run for the busiest day, min one glyph for a lone merge", () => {
|
|
87
|
+
const db = viewDb();
|
|
88
|
+
seed(db, [
|
|
89
|
+
merged("o/r#1", "2026-01-01T01:00:00Z"),
|
|
90
|
+
merged("o/r#2", "2026-01-01T02:00:00Z"),
|
|
91
|
+
merged("o/r#3", "2026-01-01T03:00:00Z"),
|
|
92
|
+
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
93
|
+
merged("o/r#5", "2026-01-02T01:00:00Z"),
|
|
94
|
+
]);
|
|
95
|
+
const rows = view(db);
|
|
96
|
+
const [a, b] = rows;
|
|
97
|
+
assertEquals(a[3], "█".repeat(30), "the busiest day fills the configured bar width (30)");
|
|
98
|
+
assert(a[3].length > b[3].length, "the busier day draws a longer bar");
|
|
99
|
+
assert([...b[3]].length >= 1, "a day with any merge draws at least one glyph");
|
|
100
|
+
assertEquals(view(db), deriveMergesPerDay([
|
|
101
|
+
merged("o/r#1", "2026-01-01T01:00:00Z"),
|
|
102
|
+
merged("o/r#2", "2026-01-01T02:00:00Z"),
|
|
103
|
+
merged("o/r#3", "2026-01-01T03:00:00Z"),
|
|
104
|
+
merged("o/r#4", "2026-01-01T04:00:00Z"),
|
|
105
|
+
merged("o/r#5", "2026-01-02T01:00:00Z"),
|
|
106
|
+
]).map((d) => [d.day, d.merged, d.cumulative, d.bar]));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("merges_per_day_view is empty when no merges are recorded", () => {
|
|
110
|
+
const db = viewDb();
|
|
111
|
+
seed(db, [{ pr_key: "o/r#1", outcome: "queued", at: "2026-01-01T01:00:00Z" }]);
|
|
112
|
+
assertEquals(view(db), []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("merges_per_day_view buckets on the LOCAL calendar day, matching deriveMergesPerDay (issue #361)", () => {
|
|
116
|
+
// The view's `date(at, 'localtime')` and the oracle's default zone both resolve against the host
|
|
117
|
+
// zone, so two merges either side of a local-day boundary bucket identically on both sides — no
|
|
118
|
+
// matter which host zone the suite runs under.
|
|
119
|
+
const db = viewDb();
|
|
120
|
+
const rows: MergeAuditRow[] = [
|
|
121
|
+
merged("o/r#1", "2026-01-01T23:30:00Z"),
|
|
122
|
+
merged("o/r#2", "2026-01-02T00:30:00Z"),
|
|
123
|
+
];
|
|
124
|
+
seed(db, rows);
|
|
125
|
+
assertEquals(view(db), deriveMergesPerDay(rows).map((d) => [d.day, d.merged, d.cumulative, d.bar]));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// A tiny deterministic PRNG (mulberry32) so the property check runs the SAME audit rows every time —
|
|
129
|
+
// non-determinism (`Math.random()`) would make a failure impossible to reproduce (see "no flaky
|
|
130
|
+
// tests" in AGENTS.md). Seeded once with a fixed constant.
|
|
131
|
+
function mulberry32(seed: number): () => number {
|
|
132
|
+
let a = seed >>> 0;
|
|
133
|
+
return () => {
|
|
134
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
135
|
+
let t = a;
|
|
136
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
137
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
138
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test("merges_per_day_view reproduces the projection across many random audits (property check)", () => {
|
|
143
|
+
const db = viewDb();
|
|
144
|
+
const outcomes = ["merged", "merged", "merged", "queued", "blocked"];
|
|
145
|
+
const rand = mulberry32(0x9e3779b9); // fixed seed → the same 200 trials on every run.
|
|
146
|
+
for (let trial = 0; trial < 200; trial++) {
|
|
147
|
+
db.exec("DELETE FROM merges");
|
|
148
|
+
const rows: MergeAuditRow[] = [];
|
|
149
|
+
const n = 1 + Math.floor(rand() * 40);
|
|
150
|
+
for (let i = 0; i < n; i++) {
|
|
151
|
+
const day = 1 + Math.floor(rand() * 9);
|
|
152
|
+
const hh = String(Math.floor(rand() * 24)).padStart(2, "0");
|
|
153
|
+
rows.push({
|
|
154
|
+
pr_key: `o/r#${1 + Math.floor(rand() * 12)}`,
|
|
155
|
+
outcome: outcomes[Math.floor(rand() * outcomes.length)],
|
|
156
|
+
at: `2026-01-0${day}T${hh}:00:00Z`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
seed(db, rows);
|
|
160
|
+
assertEquals(
|
|
161
|
+
view(db),
|
|
162
|
+
deriveMergesPerDay(rows).map((d) => [d.day, d.merged, d.cumulative, d.bar]),
|
|
163
|
+
`random trial ${trial} diverged from the projection`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Read-model guard for migration 064's `lineage_thread_view` VIEW (epic #412: retire the
|
|
2
|
+
// worker-maintained `lineage_threads` denormalisation in favour of SQL VIEWs for the parts that are
|
|
3
|
+
// clean rollups). Mirrors the derived-read-model test style of app/migration037.test.ts and
|
|
4
|
+
// app/planWaveSummary.test.ts: apply the migration to a real in-memory SQLite DB and assert the
|
|
5
|
+
// VIEW's output over sample rows — so this exercises the real view, not a re-implementation.
|
|
6
|
+
//
|
|
7
|
+
// The view DERIVES the view-expressible identity columns (`kind`, `issue_url`, and an epic/feature
|
|
8
|
+
// thread's `title`) from the `plans` / `feature_runs` origin joins, and PASSES THROUGH the
|
|
9
|
+
// procedural frontier columns (`stage`/`stage_label`/`process_key`/`pr_keys`/`pr_count`/`active`/
|
|
10
|
+
// timestamps) from `lineage_threads`. It must reproduce EXACTLY what `pollLineage` wrote for the
|
|
11
|
+
// migrated columns, so a future drop of those `lineage_threads` columns is behaviour-preserving.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { assertEquals } from "#test-assert";
|
|
17
|
+
|
|
18
|
+
const MIGRATION = fileURLToPath(new URL("../db/migrations/064_lineage_thread_view.sql", import.meta.url));
|
|
19
|
+
|
|
20
|
+
/** A DB with the base shapes the view reads (`lineage_threads`, `plans`, `feature_runs`) plus the
|
|
21
|
+
* view applied. The `lineage_threads` schema also models `kind`/`issue_url`, which the view does
|
|
22
|
+
* NOT read (it derives them from the `plans`/`feature_runs` origin joins) — they are kept here so
|
|
23
|
+
* `addThread` can write exactly what `pollLineage` denormalises. */
|
|
24
|
+
function viewDb(): DatabaseSync {
|
|
25
|
+
const db = new DatabaseSync(":memory:");
|
|
26
|
+
db.exec(
|
|
27
|
+
`CREATE TABLE lineage_threads (
|
|
28
|
+
root_request_key TEXT PRIMARY KEY, kind TEXT, title TEXT, issue_url TEXT, stage TEXT,
|
|
29
|
+
stage_label TEXT, process_key TEXT, pr_keys TEXT, pr_count INTEGER, active INTEGER,
|
|
30
|
+
created_at TEXT, updated_at TEXT);
|
|
31
|
+
CREATE TABLE plans (plan_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);
|
|
32
|
+
CREATE TABLE feature_runs (feature_key TEXT PRIMARY KEY, title TEXT, issue_url TEXT);`,
|
|
33
|
+
);
|
|
34
|
+
db.exec(readFileSync(MIGRATION, "utf8"));
|
|
35
|
+
return db;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Insert a `lineage_threads` row exactly as `pollLineage` denormalises one. */
|
|
39
|
+
function addThread(
|
|
40
|
+
db: DatabaseSync,
|
|
41
|
+
row: {
|
|
42
|
+
root_request_key: string;
|
|
43
|
+
kind: string;
|
|
44
|
+
title: string | null;
|
|
45
|
+
issue_url: string | null;
|
|
46
|
+
stage: string;
|
|
47
|
+
stage_label: string | null;
|
|
48
|
+
process_key: string | null;
|
|
49
|
+
pr_keys: string | null;
|
|
50
|
+
pr_count: number;
|
|
51
|
+
active: number;
|
|
52
|
+
},
|
|
53
|
+
): void {
|
|
54
|
+
db.prepare(
|
|
55
|
+
`INSERT INTO lineage_threads (root_request_key, kind, title, issue_url, stage, stage_label,
|
|
56
|
+
process_key, pr_keys, pr_count, active, created_at, updated_at)
|
|
57
|
+
VALUES (@root_request_key, @kind, @title, @issue_url, @stage, @stage_label, @process_key,
|
|
58
|
+
@pr_keys, @pr_count, @active, 't0', 't1')`,
|
|
59
|
+
).run(row);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("lineage_thread_view derives kind/issue_url/title for an epic thread from the plans origin", () => {
|
|
63
|
+
const db = viewDb();
|
|
64
|
+
db.prepare("INSERT INTO plans (plan_key, title, issue_url) VALUES (?, ?, ?)").run(
|
|
65
|
+
"o/r#2",
|
|
66
|
+
"Epic: retire projections",
|
|
67
|
+
"https://github.com/o/r/issues/2",
|
|
68
|
+
);
|
|
69
|
+
// pollLineage wrote the same identity values (denormalised) alongside the procedural frontier.
|
|
70
|
+
addThread(db, {
|
|
71
|
+
root_request_key: "o/r#2",
|
|
72
|
+
kind: "epic",
|
|
73
|
+
title: "Epic: retire projections",
|
|
74
|
+
issue_url: "https://github.com/o/r/issues/2",
|
|
75
|
+
stage: "converging",
|
|
76
|
+
stage_label: "3/5 slices merged, 2 converging",
|
|
77
|
+
process_key: "P-epic",
|
|
78
|
+
pr_keys: '["o/r#20","o/r#21"]',
|
|
79
|
+
pr_count: 5,
|
|
80
|
+
active: 1,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const v = db
|
|
84
|
+
.prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
|
|
85
|
+
.get("o/r#2") as Record<string, unknown>;
|
|
86
|
+
// Derived from the plans join — identical to what the poller denormalised.
|
|
87
|
+
assertEquals(v.kind, "epic");
|
|
88
|
+
assertEquals(v.title, "Epic: retire projections");
|
|
89
|
+
assertEquals(v.issue_url, "https://github.com/o/r/issues/2");
|
|
90
|
+
// Procedural frontier columns pass through unchanged from lineage_threads.
|
|
91
|
+
assertEquals(v.stage, "converging");
|
|
92
|
+
assertEquals(v.stage_label, "3/5 slices merged, 2 converging");
|
|
93
|
+
assertEquals(v.process_key, "P-epic");
|
|
94
|
+
assertEquals(v.pr_keys, '["o/r#20","o/r#21"]');
|
|
95
|
+
assertEquals(v.pr_count, 5);
|
|
96
|
+
assertEquals(v.active, 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("lineage_thread_view derives kind/issue_url/title for a feature thread from the feature_runs origin", () => {
|
|
100
|
+
const db = viewDb();
|
|
101
|
+
db.prepare("INSERT INTO feature_runs (feature_key, title, issue_url) VALUES (?, ?, ?)").run(
|
|
102
|
+
"o/r#1",
|
|
103
|
+
"Feature: add widget",
|
|
104
|
+
"https://github.com/o/r/issues/1",
|
|
105
|
+
);
|
|
106
|
+
addThread(db, {
|
|
107
|
+
root_request_key: "o/r#1",
|
|
108
|
+
kind: "feature",
|
|
109
|
+
title: "Feature: add widget",
|
|
110
|
+
issue_url: "https://github.com/o/r/issues/1",
|
|
111
|
+
stage: "merged",
|
|
112
|
+
stage_label: "Merged",
|
|
113
|
+
process_key: "P-feat",
|
|
114
|
+
pr_keys: '["o/r#10"]',
|
|
115
|
+
pr_count: 1,
|
|
116
|
+
active: 0,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const v = db
|
|
120
|
+
.prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
|
|
121
|
+
.get("o/r#1") as Record<string, unknown>;
|
|
122
|
+
assertEquals(v.kind, "feature");
|
|
123
|
+
assertEquals(v.title, "Feature: add widget");
|
|
124
|
+
assertEquals(v.issue_url, "https://github.com/o/r/issues/1");
|
|
125
|
+
assertEquals(v.stage, "merged");
|
|
126
|
+
assertEquals(v.active, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("lineage_thread_view self-roots an origin-less PR: kind 'pr', NULL issue_url, title falls back to the poller value", () => {
|
|
130
|
+
const db = viewDb();
|
|
131
|
+
// No plans / feature_runs row for this root — it is a human/webhook PR that is its own root.
|
|
132
|
+
addThread(db, {
|
|
133
|
+
root_request_key: "o/r#30",
|
|
134
|
+
kind: "pr",
|
|
135
|
+
title: "hotfix: bump dep",
|
|
136
|
+
issue_url: null,
|
|
137
|
+
stage: "converging",
|
|
138
|
+
stage_label: "Converging (round 2)",
|
|
139
|
+
process_key: "P-pr",
|
|
140
|
+
pr_keys: '["o/r#30"]',
|
|
141
|
+
pr_count: 1,
|
|
142
|
+
active: 1,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const v = db
|
|
146
|
+
.prepare("SELECT * FROM lineage_thread_view WHERE root_request_key = ?")
|
|
147
|
+
.get("o/r#30") as Record<string, unknown>;
|
|
148
|
+
assertEquals(v.kind, "pr");
|
|
149
|
+
// issue_url is always NULL for a self-rooted PR, exactly as deriveLineage sets it.
|
|
150
|
+
assertEquals(v.issue_url, null);
|
|
151
|
+
// The PR title is the procedural representative-PR pick, so it comes through from lineage_threads.
|
|
152
|
+
assertEquals(v.title, "hotfix: bump dep");
|
|
153
|
+
assertEquals(v.stage_label, "Converging (round 2)");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("lineage_thread_view reproduces the migrated columns for every thread the poller wrote", () => {
|
|
157
|
+
const db = viewDb();
|
|
158
|
+
db.prepare("INSERT INTO plans (plan_key, title, issue_url) VALUES ('o/r#2', 'Epic', 'u-epic')").run();
|
|
159
|
+
db.prepare("INSERT INTO feature_runs (feature_key, title, issue_url) VALUES ('o/r#1', 'Feat', 'u-feat')").run();
|
|
160
|
+
const rows = [
|
|
161
|
+
{ root_request_key: "o/r#2", kind: "epic", title: "Epic", issue_url: "u-epic", stage: "converging", stage_label: "…", process_key: "a", pr_keys: "[]", pr_count: 2, active: 1 },
|
|
162
|
+
{ root_request_key: "o/r#1", kind: "feature", title: "Feat", issue_url: "u-feat", stage: "merged", stage_label: "Merged", process_key: "b", pr_keys: "[]", pr_count: 1, active: 0 },
|
|
163
|
+
{ root_request_key: "o/r#30", kind: "pr", title: "PR", issue_url: null, stage: "opened", stage_label: "Opened", process_key: null, pr_keys: "[]", pr_count: 1, active: 1 },
|
|
164
|
+
];
|
|
165
|
+
for (const r of rows) addThread(db, r);
|
|
166
|
+
|
|
167
|
+
// The view's migrated columns must equal what pollLineage denormalised, for all three kinds.
|
|
168
|
+
for (const r of rows) {
|
|
169
|
+
const v = db
|
|
170
|
+
.prepare("SELECT kind, title, issue_url FROM lineage_thread_view WHERE root_request_key = ?")
|
|
171
|
+
.get(r.root_request_key) as Record<string, unknown>;
|
|
172
|
+
assertEquals(v.kind, r.kind);
|
|
173
|
+
assertEquals(v.title, r.title);
|
|
174
|
+
assertEquals(v.issue_url, r.issue_url);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
@@ -126,10 +126,12 @@ test("epic-detail projects the wave banner, the per-wave summary, and task→rep
|
|
|
126
126
|
const page = JSON.parse(readFileSync(PAGE, "utf8"));
|
|
127
127
|
const byId = (id: string) => page.nodes.find((n: { id: string }) => n.id === id);
|
|
128
128
|
|
|
129
|
-
// 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off
|
|
129
|
+
// 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off the derived
|
|
130
|
+
// `plan_read_model` VIEW (epic #412 — retiring the worker-maintained plans.wave_label column;
|
|
131
|
+
// the banner now reads the single-source-of-truth view instead of the raw `plans` table).
|
|
130
132
|
const banner = byId("wave-banner");
|
|
131
133
|
assert(banner, "epic detail must show the epic-level wave banner");
|
|
132
|
-
assertEquals(banner.props.data.table, "
|
|
134
|
+
assertEquals(banner.props.data.table, "plan_read_model");
|
|
133
135
|
assert(
|
|
134
136
|
banner.props.data.filter.some((f: { field: string; eqParam?: boolean }) => f.field === "plan_key" && f.eqParam),
|
|
135
137
|
"the banner is scoped to this epic",
|
|
@@ -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,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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.120.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"data": {
|
|
93
93
|
"kind": "datasource",
|
|
94
94
|
"source": "app",
|
|
95
|
-
"table": "
|
|
95
|
+
"table": "plan_read_model",
|
|
96
96
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
97
97
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
98
98
|
},
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
"data": {
|
|
138
138
|
"kind": "datasource",
|
|
139
139
|
"source": "app",
|
|
140
|
-
"table": "
|
|
140
|
+
"table": "plan_read_model",
|
|
141
141
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
142
142
|
"filter": [{ "field": "plan_key", "eqParam": true }]
|
|
143
143
|
},
|
package/pages/lineage.page.json
CHANGED
package/pages/overview.page.json
CHANGED
package/pages/velocity.page.json
CHANGED
|
@@ -323,12 +323,14 @@ test("issue #205: overview is the landing page and first nav item", async () =>
|
|
|
323
323
|
// EPIC surface buckets on the DERIVED `list_bucket` (issue #298) — NOT raw `status` — so a `done`
|
|
324
324
|
// epic still converging, or landed-but-unpromoted, does not vanish from the in-flight Epics section
|
|
325
325
|
// the instant `status=done`. Guarding the field here is the regression guard for that defect class.
|
|
326
|
+
// The epic surface binds the derived `plan_read_model` VIEW (epic #412 — the single source of truth
|
|
327
|
+
// for the wave/delivery projections it also renders), not the raw `plans` table.
|
|
326
328
|
const expected: Record<string, { field: string; in: string[] }> = {
|
|
327
329
|
pull_requests: {
|
|
328
330
|
field: "status",
|
|
329
331
|
in: ["converging", "waiting_review", "escalated", "waiting_deps", "waiting_merge", "queued", "merging"],
|
|
330
332
|
},
|
|
331
|
-
|
|
333
|
+
plan_read_model: { field: "list_bucket", in: ["active"] },
|
|
332
334
|
feature_runs: { field: "status", in: ["running", "escalated", "awaiting_operator"] },
|
|
333
335
|
// The 4th dispatch surface (issue #386) — active delivery graphs. Both in-flight statuses
|
|
334
336
|
// (`awaiting-approval` parked at the gate, `running` dispatched) show here.
|