@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 CHANGED
@@ -1,3 +1,18 @@
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
+
9
+ # [0.119.0](https://github.com/nanobpm/nano-workforce/compare/v0.118.2...v0.119.0) (2026-08-22)
10
+
11
+
12
+ ### Features
13
+
14
+ * **epic-detail:** wave visualization + task→representation links ([#411](https://github.com/nanobpm/nano-workforce/issues/411)) ([#434](https://github.com/nanobpm/nano-workforce/issues/434)) ([cff11fe](https://github.com/nanobpm/nano-workforce/commit/cff11febc30c1fbe08ea385ef5cf36fae0ff2fe7)), closes [nano-ide#424](https://github.com/nano-ide/issues/424)
15
+
1
16
  ## [0.118.2](https://github.com/nanobpm/nano-workforce/compare/v0.118.1...v0.118.2) (2026-08-21)
2
17
 
3
18
 
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
+ });
@@ -0,0 +1,170 @@
1
+ // Coverage for the Epic-detail wave visualization + task→representation links (issue #411).
2
+ //
3
+ // Two guards, mirroring the repo's split between a derived-read-model test (migration042.test.ts —
4
+ // apply the migration to a real in-memory DB and assert its output) and a page-projection guard
5
+ // (waitGateVisibility.test.ts — pure text assertions that the declarative page wires the surface):
6
+ //
7
+ // 1. The VIEW rollup over sample `plan_tasks` × `pull_requests` rows: the six-way per-wave count
8
+ // partition and the pre-formatted `bar` string. Because `plan_wave_summary` is a VIEW (the whole
9
+ // point of #411 — a single derived source of truth, enabled by nano-ide#424) this exercises the
10
+ // real SQLite view, not a re-implementation.
11
+ // 2. The epic-detail page projects the wave banner, the per-wave summary section, and the
12
+ // task→representation links (PR url + processExplorer instance) on the wave-state grid.
13
+ import { readFileSync } from "node:fs";
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { test } from "node:test";
16
+ import { fileURLToPath } from "node:url";
17
+ import { assert, assertEquals } from "#test-assert";
18
+
19
+ const MIGRATION = fileURLToPath(new URL("../db/migrations/059_plan_wave_summary.sql", import.meta.url));
20
+ const PAGE = fileURLToPath(new URL("../pages/epic-detail.page.json", import.meta.url));
21
+
22
+ /** A DB with the base `plan_tasks` / `pull_requests` shapes the views read, plus the views applied. */
23
+ function viewDb(): DatabaseSync {
24
+ const db = new DatabaseSync(":memory:");
25
+ db.exec(
26
+ `CREATE TABLE plan_tasks (
27
+ id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
28
+ prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
29
+ wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
30
+ CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
31
+ );
32
+ db.exec(readFileSync(MIGRATION, "utf8"));
33
+ return db;
34
+ }
35
+
36
+ function addTask(
37
+ db: DatabaseSync,
38
+ plan_key: string,
39
+ task_index: number,
40
+ status: string,
41
+ wave: number,
42
+ pr?: { pr_key: string; url: string; status: string; process_key: string },
43
+ ): void {
44
+ db.prepare(
45
+ "INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
46
+ ).run(plan_key, task_index, `t${task_index}`, status, pr?.pr_key ?? null, wave);
47
+ if (pr) {
48
+ db.prepare(
49
+ "INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)",
50
+ ).run(pr.pr_key, pr.url, pr.status, pr.process_key);
51
+ }
52
+ }
53
+
54
+ test("plan_wave_summary partitions each wave's tasks and pre-formats the progress bar", () => {
55
+ const db = viewDb();
56
+ const plan = "o/r#1";
57
+ // Wave 0 — 5 tasks: 3 merged, 1 converging (in-flight), 1 blocked (no PR).
58
+ addTask(db, plan, 0, "opened", 0, { pr_key: "o/r#10", url: "https://gh/10", status: "merged", process_key: "P10" });
59
+ addTask(db, plan, 1, "opened", 0, { pr_key: "o/r#11", url: "https://gh/11", status: "merged", process_key: "P11" });
60
+ addTask(db, plan, 2, "opened", 0, { pr_key: "o/r#12", url: "https://gh/12", status: "merged", process_key: "P12" });
61
+ addTask(db, plan, 3, "opened", 0, { pr_key: "o/r#13", url: "https://gh/13", status: "converging", process_key: "P13" });
62
+ addTask(db, plan, 4, "blocked", 0);
63
+ // Wave 1 — an escalated slice (with a draft PR) and a skipped slice.
64
+ addTask(db, plan, 5, "escalated", 1, { pr_key: "o/r#14", url: "https://gh/14", status: "escalated", process_key: "P14" });
65
+ addTask(db, plan, 6, "skipped", 1);
66
+
67
+ const rows = db
68
+ .prepare("SELECT * FROM plan_wave_summary WHERE plan_key = ? ORDER BY wave")
69
+ .all(plan) as Array<Record<string, unknown>>;
70
+ assertEquals(rows.length, 2);
71
+
72
+ const w0 = rows[0];
73
+ assertEquals(w0.total, 5);
74
+ assertEquals(w0.merged, 3);
75
+ assertEquals(w0.in_flight, 1);
76
+ assertEquals(w0.blocked, 1);
77
+ assertEquals(w0.escalated, 0);
78
+ assertEquals(w0.skipped, 0);
79
+ // 3 filled + 2 empty glyphs (width = total), then the named non-zero categories.
80
+ assertEquals(w0.bar, "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked");
81
+
82
+ const w1 = rows[1];
83
+ assertEquals(w1.total, 2);
84
+ assertEquals(w1.merged, 0);
85
+ assertEquals(w1.in_flight, 0);
86
+ assertEquals(w1.escalated, 1);
87
+ assertEquals(w1.skipped, 1);
88
+ assertEquals(w1.bar, "░░ 0/2 merged · 1 escalated · 1 skipped");
89
+ });
90
+
91
+ test("a merged PR wins over the task's own status, and unlevelized tasks are excluded", () => {
92
+ const db = viewDb();
93
+ const plan = "o/r#2";
94
+ // An escalated task whose PR nonetheless merged counts as merged, not escalated (PR wins).
95
+ addTask(db, plan, 0, "escalated", 0, { pr_key: "o/r#20", url: "https://gh/20", status: "merged", process_key: "P20" });
96
+ // A task with no wave yet (not levelized) must not appear in any wave row.
97
+ db.prepare(
98
+ "INSERT INTO plan_tasks (plan_key, task_index, task_id, status, wave) VALUES (?, ?, ?, ?, NULL)",
99
+ ).run(plan, 1, "t1", "pending");
100
+
101
+ const rows = db
102
+ .prepare("SELECT wave, total, merged, escalated FROM plan_wave_summary WHERE plan_key = ?")
103
+ .all(plan) as Array<Record<string, unknown>>;
104
+ assertEquals(rows.length, 1);
105
+ assertEquals(rows[0].wave, 0);
106
+ assertEquals(rows[0].total, 1);
107
+ assertEquals(rows[0].merged, 1);
108
+ assertEquals(rows[0].escalated, 0);
109
+ });
110
+
111
+ test("plan_wave_tasks carries each task's PR url + process_key link targets", () => {
112
+ const db = viewDb();
113
+ addTask(db, "o/r#3", 0, "opened", 0, { pr_key: "o/r#30", url: "https://gh/30", status: "converging", process_key: "P30" });
114
+ addTask(db, "o/r#3", 1, "blocked", 0); // no PR → null link targets
115
+
116
+ const rows = db
117
+ .prepare("SELECT task_id, pr_key, pr_url, process_key FROM plan_wave_tasks WHERE plan_key = ? ORDER BY task_index")
118
+ .all("o/r#3") as Array<Record<string, unknown>>;
119
+ assertEquals(rows[0].pr_url, "https://gh/30");
120
+ assertEquals(rows[0].process_key, "P30");
121
+ assertEquals(rows[1].pr_url, null);
122
+ assertEquals(rows[1].process_key, null);
123
+ });
124
+
125
+ test("epic-detail projects the wave banner, the per-wave summary, and task→representation links", () => {
126
+ const page = JSON.parse(readFileSync(PAGE, "utf8"));
127
+ const byId = (id: string) => page.nodes.find((n: { id: string }) => n.id === id);
128
+
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).
132
+ const banner = byId("wave-banner");
133
+ assert(banner, "epic detail must show the epic-level wave banner");
134
+ assertEquals(banner.props.data.table, "plan_read_model");
135
+ assert(
136
+ banner.props.data.filter.some((f: { field: string; eqParam?: boolean }) => f.field === "plan_key" && f.eqParam),
137
+ "the banner is scoped to this epic",
138
+ );
139
+ assert(/\{\{\s*wave_label\s*\}\}/.test(banner.props.header), "the banner surfaces the wave_label");
140
+ assert(/\{\{\s*epic_phase\s*\}\}/.test(banner.props.header), "the banner surfaces the epic phase");
141
+
142
+ // 2. The per-wave summary section: a grid over the derived VIEW, ordered by wave, with the bar.
143
+ const summary = byId("wave-summary");
144
+ assert(summary, "epic detail must show the per-wave progress summary");
145
+ assertEquals(summary.props.data.table, "plan_wave_summary");
146
+ assertEquals(summary.props.data.orderBy.field, "wave");
147
+ const summaryCols: string[] = summary.props.columns.map((c: { field: string }) => c.field);
148
+ for (const f of ["wave", "bar", "merged", "in_flight", "blocked", "escalated", "skipped", "total"]) {
149
+ assert(summaryCols.includes(f), `the summary grid shows ${f}`);
150
+ }
151
+
152
+ // 3. The wave-state grid links each in-flight task to its representation (PR + process instance).
153
+ const waveState = byId("wave-state");
154
+ assert(waveState, "epic detail must keep the wave-state grid");
155
+ assertEquals(waveState.props.data.table, "plan_wave_tasks");
156
+ const cols: Array<Record<string, unknown>> = waveState.props.columns;
157
+ const prCol = cols.find((c) => c.field === "pr_key");
158
+ assertEquals(prCol?.linkField, "pr_url", "the PR cell links to the GitHub PR url");
159
+ const statusCol = cols.find((c) => c.field === "status") as {
160
+ link?: { kind?: string; keyField?: string };
161
+ };
162
+ assertEquals(statusCol.link?.kind, "processExplorer", "the status cell links to the process instance");
163
+ assertEquals(statusCol.link?.keyField, "process_key");
164
+ // The existing tabs (Active / Skipped / All) and detail drawer must still be present.
165
+ assertEquals(waveState.props.tabs.length, 3);
166
+ const detailFields: string[] = waveState.props.detail.fields.map((f: { field: string }) => f.field);
167
+ for (const f of ["open_question", "answer", "draft_pr_key", "prompt"]) {
168
+ assert(detailFields.includes(f), `the detail drawer keeps ${f}`);
169
+ }
170
+ });