@nanobpm/nano-workforce 0.121.0 → 0.123.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.
@@ -1,16 +1,14 @@
1
- // Read-model derivation + projection test for the merged-per-day throughput chart (issue #344).
1
+ // Read-model derivation test for the merged-per-day throughput chart (issue #344).
2
2
  //
3
- // `deriveMergesPerDay` is the single source of truth behind the denormalised `merges_per_day` table
4
- // the Velocity page reads. It must: count DISTINCT PRs per calendar day (a PR with several `merged`
5
- // audit rows on one day an `already-merged` short-circuit or a retry — counts once); ignore
6
- // `queued`/`blocked` attempts entirely; order days ascending; carry a running burn-up `cumulative`;
7
- // and scale each day's `bar` against the busiest day. `pollMergesPerDay` must project that onto the
8
- // read table idempotently a steady-state re-run writes nothing, and a day dropped from the audit is
9
- // pruned.
3
+ // `deriveMergesPerDay` is the single source of truth the `merges_per_day_view` VIEW (062) mirrors —
4
+ // the worker-maintained `merges_per_day` table + its `pollMergesPerDay` write-path were RETIRED (epic
5
+ // #412). It must: count DISTINCT PRs per calendar day (a PR with several `merged` audit rows on one
6
+ // day — an `already-merged` short-circuit or a retry counts once); ignore `queued`/`blocked`
7
+ // attempts entirely; order days ascending; carry a running burn-up `cumulative`; and scale each day's
8
+ // `bar` against the busiest day.
10
9
  import { test } from "node:test";
11
10
  import { assert, assertEquals } from "#test-assert";
12
- import type { DataLayer } from "@nanobpm/urban";
13
- import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./mergesPerDay.ts";
11
+ import { deriveMergesPerDay, type MergeAuditRow } from "./mergesPerDay.ts";
14
12
 
15
13
  // The bucketing is now LOCAL-calendar-day (issue #361: use the viewer's timezone, not UTC). The
16
14
  // derivation buckets in an explicit IANA `timeZone` argument (via `Intl.DateTimeFormat`), so these
@@ -19,49 +17,6 @@ import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./merg
19
17
  // reorder unrelated date-handling tests. The UTC-based assertions pass `"UTC"`; the
20
18
  // timezone-specific ones pass the zone they exercise.
21
19
 
22
- // A tiny in-memory record gateway (all/find/insert/update/delete), mirroring the fake-app style used
23
- // across the app tests (see app/delivery.test.ts), enough to exercise the `pollMergesPerDay`
24
- // projection.
25
- function memData(): { data: DataLayer; stores: Record<string, any[]>; writes: () => number } {
26
- const stores: Record<string, any[]> = {};
27
- let writes = 0;
28
- function tbl(name: string, pk = "id") {
29
- const rows = (stores[name] ??= [] as any[]);
30
- return {
31
- async all() {
32
- return rows.slice();
33
- },
34
- async get(id: any) {
35
- return rows.find((r) => r[pk] === id);
36
- },
37
- async find(where: any = {}) {
38
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
39
- },
40
- async insert(row: any) {
41
- writes++;
42
- rows.push({ ...row });
43
- return row[pk];
44
- },
45
- async update(id: any, patch: any) {
46
- writes++;
47
- const r = rows.find((row) => row[pk] === id);
48
- if (r) Object.assign(r, patch);
49
- return 1;
50
- },
51
- async delete(id: any) {
52
- const i = rows.findIndex((row) => row[pk] === id);
53
- if (i >= 0) {
54
- writes++;
55
- rows.splice(i, 1);
56
- }
57
- return 1;
58
- },
59
- };
60
- }
61
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
62
- return { data, stores, writes: () => writes };
63
- }
64
-
65
20
  const merged = (pr_key: string, at: string): MergeAuditRow => ({ pr_key, outcome: "merged", at });
66
21
 
67
22
  test("counts DISTINCT merged PRs per calendar day", () => {
@@ -216,42 +171,3 @@ test("an invalid IANA timeZone falls back to the host zone instead of throwing (
216
171
  assertEquals(days[0].merged, 1);
217
172
  assert(/^\d{4}-\d{2}-\d{2}$/.test(days[0].day));
218
173
  });
219
-
220
- test("pollMergesPerDay projects the aggregate onto merges_per_day", async () => {
221
- const { data, stores } = memData();
222
- stores.merges = [
223
- { id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" },
224
- { id: 2, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:05:00Z" }, // dup same day
225
- { id: 3, pr_key: "o/r#2", outcome: "merged", at: "2026-01-02T09:00:00Z" },
226
- { id: 4, pr_key: "o/r#3", outcome: "queued", at: "2026-01-02T09:10:00Z" }, // ignored
227
- ];
228
- await pollMergesPerDay(data, "UTC");
229
- const rows = (stores.merges_per_day ?? []).slice().sort((x, y) => x.day.localeCompare(y.day));
230
- assertEquals(rows.map((r) => [r.day, r.merged, r.cumulative]), [
231
- ["2026-01-01", 1, 1],
232
- ["2026-01-02", 1, 2],
233
- ]);
234
- for (const r of rows) assert(typeof r.updated_at === "string" && r.updated_at.length > 0);
235
- });
236
-
237
- test("pollMergesPerDay is idempotent — a steady-state re-run writes nothing", async () => {
238
- const { data, stores, writes } = memData();
239
- stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
240
- await pollMergesPerDay(data, "UTC");
241
- const afterFirst = writes();
242
- assert(afterFirst > 0, "the first pass must project at least one row");
243
- await pollMergesPerDay(data, "UTC");
244
- assertEquals(writes(), afterFirst, "a steady-state re-run must not write");
245
- });
246
-
247
- test("pollMergesPerDay prunes a day that no longer derives from the audit", async () => {
248
- const { data, stores } = memData();
249
- stores.merges_per_day = [
250
- { day: "2025-12-31", merged: 3, cumulative: 3, bar: "███", updated_at: "old" },
251
- ];
252
- stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
253
- await pollMergesPerDay(data, "UTC");
254
- const days = (stores.merges_per_day ?? []).map((r: any) => r.day);
255
- assert(!days.includes("2025-12-31"), "a stale day must be pruned");
256
- assert(days.includes("2026-01-01"), "the derived day must be present");
257
- });
@@ -14,23 +14,18 @@
14
14
  // SELECT date(at, 'localtime') AS day, COUNT(DISTINCT pr_key) AS merged
15
15
  // FROM merges WHERE outcome = 'merged' GROUP BY date(at, 'localtime');
16
16
  //
17
- // Two halves, mirroring the `deriveDelivery`/`pollDelivery` and `deriveLineage`/`pollLineage`
18
- // convention:
17
+ // The `deriveMergesPerDay` PURE function (issue #344), formerly the derive-half of the
18
+ // `deriveMergesPerDay`/`pollMergesPerDay` split. The merges-per-day read model is now a DERIVED SQL
19
+ // VIEW (`merges_per_day_view`, 062) — the worker-maintained `merges_per_day` table and its
20
+ // `pollMergesPerDay` write-path were RETIRED (epic #412). This pure derivation stays: it is the
21
+ // single source of truth the view's SQL mirrors, and is still exercised by its unit tests +
22
+ // the view's read-model guard.
19
23
  // • `deriveMergesPerDay` — a PURE function: merge audit rows → one ordered `MergeDay` per calendar
20
24
  // day (merged count, burn-up cumulative, a proportional bar string). No I/O, fully tested. Counts
21
25
  // `COUNT(DISTINCT pr_key)` — a PR with several `merged` rows on one day (an `already-merged`
22
26
  // short-circuit or retry) counts once — and ignores `queued`/`blocked` rows entirely.
23
- // • `pollMergesPerDay` — the gateway glue: read the `merges` rows and project them onto the
24
- // `merges_per_day` read table (051_merges_per_day.sql) the schema-driven Velocity page binds. A
25
- // denormalised flat table because Urban's datasource cannot read a SQL VIEW (gateway.ts
26
- // `schema()` whitelists `type='table'` only — same reason `lineage_threads`/`plans.delivery` are
27
- // flat tables). Writes only when a day's projection actually changes, so a steady-state pass is a
28
- // no-op.
29
- import type { DataLayer } from "@nanobpm/urban";
30
27
 
31
- const now = () => new Date().toISOString();
32
-
33
- /** The subset of a `merges` audit row (004_merge.sql) the projection reads. */
28
+ /** The subset of a `merges` audit row (004_merge.sql) the derivation reads. */
34
29
  export interface MergeAuditRow {
35
30
  pr_key: string;
36
31
  outcome: string;
@@ -166,51 +161,3 @@ export function deriveMergesPerDay(rows: readonly MergeAuditRow[], timeZone?: st
166
161
  }
167
162
  return out;
168
163
  }
169
-
170
- /** The denormalised read-table row `pollMergesPerDay` projects, one per calendar day. */
171
- interface MergesPerDayRow extends MergeDay {
172
- updated_at: string;
173
- }
174
-
175
- const mergesPerDay = (data: DataLayer) => data.table<MergesPerDayRow>("merges_per_day", "day");
176
- const mergesAudit = (data: DataLayer) => data.table<MergeAuditRow>("merges", "id");
177
-
178
- /** Idempotent read-model pass: recompute merged-per-day from the `merges` audit table and denormalise
179
- * it onto the `merges_per_day` read table the Velocity page reads. Additive/derived only — never
180
- * touches `merges`. Upserts a day only when its projection actually changes (so a steady-state pass is
181
- * a no-op) and prunes any stale day row that no longer derives (defensive — days are append-only in
182
- * practice, but a purge/rewrite of the audit must not leave a phantom). Buckets in `timeZone` (an
183
- * IANA zone) when given; the production caller omits it to use the host's resolved zone. */
184
- export async function pollMergesPerDay(data: DataLayer, timeZone?: string): Promise<void> {
185
- try {
186
- // Only `outcome === "merged"` rows contribute to the aggregate, so filter at the read rather than
187
- // scanning queued/blocked rows as the audit grows (deriveMergesPerDay ignores non-merged rows too).
188
- const audit = await mergesAudit(data).find({ outcome: "merged" });
189
- const want = deriveMergesPerDay(audit, timeZone);
190
- const wantByDay = new Map(want.map((d) => [d.day, d]));
191
-
192
- const existing = await mergesPerDay(data).all();
193
- const existingByDay = new Map(existing.map((r) => [r.day, r]));
194
-
195
- for (const d of want) {
196
- const cur = existingByDay.get(d.day);
197
- if (!cur) {
198
- await mergesPerDay(data).insert({ ...d, updated_at: now() });
199
- } else if (cur.merged !== d.merged || cur.cumulative !== d.cumulative || cur.bar !== d.bar) {
200
- await mergesPerDay(data).update(d.day, {
201
- merged: d.merged,
202
- cumulative: d.cumulative,
203
- bar: d.bar,
204
- updated_at: now(),
205
- });
206
- }
207
- }
208
-
209
- // Prune any projected day that no longer derives from the audit trail.
210
- for (const r of existing) {
211
- if (!wantByDay.has(r.day)) await mergesPerDay(data).delete(r.day);
212
- }
213
- } catch (err) {
214
- console.error(`[poller] merges-per-day: ${err}`);
215
- }
216
- }
package/app/plan.ts CHANGED
@@ -12,7 +12,6 @@
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
14
  import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
15
- import { deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
16
15
  import { EPIC_PHASE } from "./epicPhase.ts";
17
16
  import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
18
17
  import {
@@ -74,18 +73,11 @@ export interface Plan {
74
73
  // Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
75
74
  // waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
76
75
  gate_wave: number | null;
77
- // Operator-visibility wave progress (022_plan_wave_progress.sql, #137): denormalised so the
78
- // epics-index can show wave X/N at a glance. `wave_count` is the total waves (N); `current_wave`
79
- // is the 0-based index of the wave the fleet is actively implementing (advanced by select-wave,
80
- // pinned to wave_count-1 on completion). Display-only never gates control flow. NULL until the
81
- // plan is dispatched with tasks.
82
- wave_count: number | null;
83
- current_wave: number | null;
84
- // Pre-formatted 1-based "X/N" progress string for the epics-index at-a-glance column
85
- // (022_plan_wave_progress.sql, #137). The dataGrid has no per-cell templating (nano-ide#214),
86
- // so this is projected alongside the numeric columns by the same worker writes. NULL until
87
- // dispatched with tasks.
88
- wave_label: string | null;
76
+ // Operator-visibility wave progress is now a DERIVED read model (epic #412): the `wave_count` /
77
+ // `current_wave` / `wave_label` columns (022_plan_wave_progress.sql) were RETIRED the pages read
78
+ // them from the `plan_wave_label` / `plan_read_model` SQL VIEWs (060/061), and `pollWaitGate`
79
+ // derives "has the epic fanned out?" at read time from `plan_tasks`. There is no write-path and no
80
+ // stored column any more, so nothing on `plans` denormalises wave progress.
89
81
  // Per-plan capability token for the coordination blackboard (009_plan_blackboard.sql, #51).
90
82
  // Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
91
83
  // plans created before the blackboard shipped.
@@ -96,13 +88,13 @@ export interface Plan {
96
88
  // admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
97
89
  // carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
98
90
  base_branch: string | null;
99
- // Derived epic delivery signal (029_plan_delivery.sql, #171): separates "fan-out dispatched to
100
- // convergence" (status=done) from "all slice PRs actually merged". Recomputed idempotently by the
101
- // poller's `pollDelivery` pass by joining each plan_tasks.pr_key pull_requests.status — never
102
- // written by the plan lifecycle. `delivery` is 'converging' | 'landed' | NULL (see deriveDelivery
103
- // in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
104
- delivery: string | null;
105
- delivery_label: string | null;
91
+ // The derived epic delivery signal (029_plan_delivery.sql) was RETIRED as a stored column (epic
92
+ // #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
93
+ // `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
94
+ // (app/delivery.ts). The pages read them off the view; the pollers that still need the signal
95
+ // (`pollPromotion` for `isPromotable`) recompute it at
96
+ // read time via `deriveDelivery`, as does the `plan_read_model` VIEW's bucket derivation (074).
97
+ // There is no stored column and no write-path any more.
106
98
  // Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
107
99
  // Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
108
100
  // projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
@@ -120,16 +112,18 @@ export interface Plan {
120
112
  // which has nothing to promote). Display-only; projected by the poller.
121
113
  promotion_pr: string | null;
122
114
  promotion_state: string | null;
123
- // Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). Derived,
124
- // write-time-projected by the `plans` gateway (below) from the pure `deriveEpicBucket` /
125
- // `epicIsAcknowledgeable` helpers (app/delivery.ts) never written by the plan lifecycle or a
126
- // poller directly. Bucket epics on the derived `delivery` rollup, not raw `status`, so a `done`
127
- // epic still converging or landed-but-unpromoted does not vanish from Active.
115
+ // Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). RETIRED as a
116
+ // write-time projection (issue #439): `list_bucket`/`ack_open` are now DERIVED by the
117
+ // `plan_read_model` VIEW (074) from `status`, `acknowledged_at`, and the derived `plan_delivery`
118
+ // signal mirroring the pure `deriveEpicBucket` / `epicIsAcknowledgeable` (app/delivery.ts). The
119
+ // Epics pages bind the VIEW, never these base columns, so a raw-datasource `status` write (the
120
+ // `instanceTracking` reconciler) can no longer leave them stale, and the delivery-aware
121
+ // `pollPlanBucket` correction is retired (the VIEW sees the live signal). The base columns survive
122
+ // (expand/contract — a later migration drops them) but are no longer written or read.
128
123
  // • acknowledged_at — NULL until an operator dismisses a resolved `done` epic (acknowledge-epic).
129
- // list_bucket — 'active' | 'history': the page tabs filter on this flat column.
130
- // • ack_open 1 | 0: 1 iff a resolved (`done`, not converging) but unacknowledged epic,
131
- // gating the Dismiss button's `showWhenField`. NULL only on pre-#298 rows
132
- // until `backfillPlanBuckets`.
124
+ // Still written; the sole live input the derivation reads off the row.
125
+ // • list_bucket 'active' | 'history': VESTIGIAL base column; the pages filter the VIEW's.
126
+ // ack_open — 1 | 0: VESTIGIAL base column; the pages gate Dismiss on the VIEW's.
133
127
  acknowledged_at: string | null;
134
128
  list_bucket: string | null;
135
129
  ack_open: number | null;
@@ -190,85 +184,19 @@ export const PLAN_TASK_STATUSES = [
190
184
  ] as const;
191
185
  export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
192
186
 
193
- export const plans = (data: DataLayer) => {
194
- const table = data.table<Plan>("plans", "plan_key");
195
- return new Proxy(table, {
196
- get(target, prop) {
197
- if (prop === "insert") {
198
- return (row: Partial<Plan>) => target.insert({ ...row, ...projectPlanBucket(row) });
199
- }
200
- if (prop === "update") {
201
- return async (id: unknown, patch: Partial<Plan>) => {
202
- // Only re-read + reproject when the patch changes a projection input (status / delivery /
203
- // acknowledged_at) or writes a derived column directly. A projection-irrelevant patch (e.g.
204
- // an `updated_at`- or `wave_label`-only write — including the direct `data.table` writes in
205
- // e.g. `app/retro.ts` that stamp `retro_started_at`) leaves the stored projection correct, so
206
- // skip the extra `get` roundtrip and delegate straight. Any bucket-relevant write
207
- // (status/delivery/acknowledged_at or a derived column) MUST go through this gateway to stay
208
- // reprojected.
209
- if (!patchAffectsPlanProjection(patch)) return target.update(id, patch);
210
- const existing = await target.get(id);
211
- const merged: Partial<Plan> = { ...existing, ...patch };
212
- return target.update(id, { ...patch, ...projectPlanBucket(merged) });
213
- };
214
- }
215
- // Delegate every other method straight through. Bind functions to the real target so the
216
- // gateway's private class fields resolve — a Proxy `this` would not carry them.
217
- const value = Reflect.get(target, prop, target);
218
- return typeof value === "function" ? value.bind(target) : value;
219
- },
220
- });
221
- };
222
-
223
- /** The `plans` fields the bucket projection READS: a patch touching none of these (and none it
224
- * writes) cannot change `list_bucket`/`ack_open`, so the gateway skips the read-back+reproject. Kept
225
- * adjacent to {@link projectPlanBucket} so the two stay in lockstep. */
226
- const PLAN_PROJECTION_INPUT_KEYS: readonly (keyof Plan)[] = ["status", "delivery", "acknowledged_at"];
227
-
228
- /** The `plans` fields the bucket projection WRITES. Included in the reproject trigger so a caller who
229
- * writes a derived column directly (e.g. `list_bucket`/`ack_open`) can never bypass derivation: the
230
- * gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one. */
231
- const PLAN_PROJECTION_OUTPUT_KEYS: readonly (keyof Plan)[] = ["list_bucket", "ack_open"];
232
-
233
- /** True when a patch changes at least one field the bucket projection derives from OR one it writes —
234
- * i.e. the projection must be recomputed (mirrors feature.ts `patchAffectsProjection`). */
235
- function patchAffectsPlanProjection(patch: Partial<Plan>): boolean {
236
- return (
237
- PLAN_PROJECTION_INPUT_KEYS.some((k) => k in patch) ||
238
- PLAN_PROJECTION_OUTPUT_KEYS.some((k) => k in patch)
239
- );
240
- }
241
-
242
- /** Compute the write-time bucket projection columns for a merged `plans` row. Centralised so the
243
- * gateway is the ONE place `deriveEpicBucket` / `epicIsAcknowledgeable` are applied — the page, SQL,
244
- * pollers and workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
245
- function projectPlanBucket(row: Partial<Plan>): Partial<Plan> {
246
- if (!row.status) return {};
247
- return {
248
- list_bucket: deriveEpicBucket(row.status, row.delivery ?? null, row.acknowledged_at ?? null),
249
- ack_open:
250
- epicIsAcknowledgeable(row.status, row.delivery ?? null) && (row.acknowledged_at ?? null) === null
251
- ? 1
252
- : 0,
253
- };
254
- }
255
-
256
- /** Re-project every `plans` row through the gateway so rows written before migration 042 (whose
257
- * `list_bucket`/`ack_open` are NULL) get a correct Active/History bucket. Idempotent and safe to
258
- * re-run: it re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at
259
- * boot (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy
260
- * rows. Returns the count actually stamped. Mirrors `backfillFeatureStages` (app/feature.ts). */
261
- export async function backfillPlanBuckets(data: DataLayer): Promise<number> {
262
- const table = plans(data);
263
- let stamped = 0;
264
- for (const row of await table.all()) {
265
- // Only touch rows the projection has never reached — a legacy row whose `list_bucket` is NULL.
266
- if (row.list_bucket != null) continue;
267
- await table.update(row.plan_key, projectPlanBucket(row));
268
- stamped++;
269
- }
270
- return stamped;
271
- }
187
+ /** The `plans` record gateway (keyed on `plan_key`) a plain record table.
188
+ *
189
+ * The epic-bucket projection (`list_bucket`/`ack_open`) is NO LONGER a write-time projection here
190
+ * (issue #439): it is DERIVED by the `plan_read_model` VIEW (074) from each row's own `status` /
191
+ * `acknowledged_at` and the derived `plan_delivery` signal, mirroring `deriveEpicBucket` /
192
+ * `epicIsAcknowledgeable` (app/delivery.ts). The Epics pages bind the VIEW, never this table's stored
193
+ * derived columns. Removing the write-time projection closes the drift the framework
194
+ * `instanceTracking` reconciler opened (a raw-datasource `{status:"abandoned"}` write on a terminated
195
+ * instance bypassed the projecting gateway and froze the bucket) AND retires the read-time
196
+ * `pollPlanBucket` correction: the VIEW sees the live delivery signal, so a still-converging epic never
197
+ * offers Dismiss without a poller pass. The pure helpers stay the acknowledge-epic guard and the VIEW's
198
+ * test oracle (app/plansReadModel.test.ts). */
199
+ export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
272
200
  export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
273
201
 
274
202
  /** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
@@ -19,7 +19,7 @@ import { DatabaseSync } from "node:sqlite";
19
19
  import { test } from "node:test";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { assert, assertEquals } from "#test-assert";
22
- import { deriveDelivery } from "./delivery.ts";
22
+ import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
23
23
 
24
24
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
25
25
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
@@ -34,7 +34,7 @@ function viewDb(): DatabaseSync {
34
34
  plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
35
35
  status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT,
36
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);
37
+ promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER);
38
38
  CREATE TABLE plan_tasks (
39
39
  id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
40
40
  prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
@@ -44,6 +44,9 @@ function viewDb(): DatabaseSync {
44
44
  db.exec(MIG("059_plan_wave_summary.sql"));
45
45
  db.exec(MIG("060_plan_wave_rollup.sql"));
46
46
  db.exec(MIG("061_plan_delivery_rollup.sql"));
47
+ // 074 redefines plan_read_model to DERIVE list_bucket/ack_open from status + acknowledged_at + the
48
+ // derived plan_delivery signal (issue #439), instead of reading the denormalised base columns.
49
+ db.exec(MIG("074_plan_read_model_derive_bucket.sql"));
47
50
  return db;
48
51
  }
49
52
 
@@ -66,10 +69,16 @@ const MISSING_PR_STATUS = "missing";
66
69
  // Insert a plan plus its tasks (and each task's PR, if any). PR keys are derived so the test rows
67
70
  // stay terse. Returns the flat `pull_requests.status` list `deriveDelivery` consumes (only tasks
68
71
  // 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[] {
72
+ function addPlan(
73
+ db: DatabaseSync,
74
+ plan_key: string,
75
+ status: string,
76
+ tasks: SampleTask[],
77
+ opts: { acknowledged_at?: string | null } = {},
78
+ ): string[] {
70
79
  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");
80
+ "INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
81
+ ).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", opts.acknowledged_at ?? null, "active");
73
82
  const prStatuses: string[] = [];
74
83
  tasks.forEach((t, i) => {
75
84
  const prKey = t.pr || t.danglingPr ? `${plan_key}::pr${i}` : null;
@@ -107,6 +116,16 @@ function counts(db: DatabaseSync, plan_key: string): { prs_opened: number; prs_m
107
116
  return { ...r };
108
117
  }
109
118
 
119
+ // The derived Active/History bucket flags the epics pages bind — read straight off `plan_read_model`
120
+ // (074), plus the delivery signal the derivation folds in, so the assertions can cross-check the
121
+ // VIEW against the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles.
122
+ function bucket(db: DatabaseSync, plan_key: string): { list_bucket: unknown; ack_open: unknown; delivery: string | null } {
123
+ const r = db
124
+ .prepare("SELECT list_bucket, ack_open, delivery FROM plan_read_model WHERE plan_key = ?")
125
+ .get(plan_key) as { list_bucket: unknown; ack_open: unknown; delivery: string | null };
126
+ return { ...r };
127
+ }
128
+
110
129
  function waveLabel(db: DatabaseSync, plan_key: string): Record<string, unknown> | undefined {
111
130
  const r = db.prepare("SELECT wave_count, current_wave, wave_label FROM plan_wave_label WHERE plan_key = ?").get(plan_key) as
112
131
  | Record<string, unknown>
@@ -259,4 +278,92 @@ test("the operator pages read the derived plan_read_model VIEW for the wave/deli
259
278
  assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
260
279
  assert(/\{\{\s*wave_label\s*\}\}/.test(byId("wave-banner").props.header), "the banner surfaces wave_label");
261
280
  assertEquals(byId("wave-banner").props.body, "delivery_label", "the banner body is the delivery_label");
281
+
282
+ // Epic INDEX page (epic.page.json) — the standalone Epics grid must also read the derived VIEW, not
283
+ // the raw `plans` table. `plans` stays a valid schema table, so a regression here (reverting the
284
+ // binding) would leave every OTHER test green while the index silently resumed reading stale
285
+ // list_bucket/ack_open; this pins it (suppressed advisory epic.page.json — issue #439).
286
+ const epicIndex = PAGE("epic.page.json");
287
+ const epicPlans = (epicIndex.nodes ?? []).find((n: { id: string }) => n.id === "epic-plans");
288
+ assert(epicPlans, "epic index must keep the Epics grid");
289
+ assertEquals(epicPlans.props.data.table, "plan_read_model");
290
+ });
291
+
292
+ test("plan_read_model DERIVES list_bucket / ack_open from status + delivery + acknowledged_at, matching deriveEpicBucket / epicIsAcknowledgeable (issue #439)", () => {
293
+ const db = viewDb();
294
+ // done, still converging (a slice in flight): Active, Dismiss suppressed — never ticked off mid-flight.
295
+ const converging = addPlan(db, "o/r#conv", "done", [
296
+ { status: "opened", wave: 0, pr: { status: "merged" } },
297
+ { status: "opened", wave: 1, pr: { status: "converging" } },
298
+ ]);
299
+ // done, fully landed, unacknowledged: Active with Dismiss OPEN (ack_open=1).
300
+ const landed = addPlan(db, "o/r#land", "done", [
301
+ { status: "opened", wave: 0, pr: { status: "merged" } },
302
+ { status: "opened", wave: 0, pr: { status: "merged" } },
303
+ ]);
304
+ // done, landed AND acknowledged: History, Dismiss closed.
305
+ const acked = addPlan(
306
+ db,
307
+ "o/r#ack",
308
+ "done",
309
+ [{ status: "opened", wave: 0, pr: { status: "merged" } }],
310
+ { acknowledged_at: "2026-02-02T00:00:00Z" },
311
+ );
312
+ // resolved-not-landed (one abandoned), unacknowledged: delivery null → acknowledgeable, Active + Dismiss.
313
+ const resolved = addPlan(db, "o/r#res", "done", [
314
+ { status: "opened", wave: 0, pr: { status: "merged" } },
315
+ { status: "opened", wave: 0, pr: { status: "abandoned" } },
316
+ ]);
317
+ // live (dispatched) epic: Active, not acknowledgeable.
318
+ const live = addPlan(db, "o/r#live", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
319
+
320
+ for (const [plan_key, status, prStatuses, ackAt] of [
321
+ ["o/r#conv", "done", converging, null],
322
+ ["o/r#land", "done", landed, null],
323
+ ["o/r#ack", "done", acked, "2026-02-02T00:00:00Z"],
324
+ ["o/r#res", "done", resolved, null],
325
+ ["o/r#live", "dispatched", live, null],
326
+ ] as const) {
327
+ const b = bucket(db, plan_key);
328
+ const expectedDelivery = deriveDelivery(status, prStatuses).delivery;
329
+ assertEquals(b.delivery, expectedDelivery, `${plan_key}: delivery`);
330
+ // Cross-check the VIEW against the pure helpers — the SAME oracle the acknowledge-epic op guards on.
331
+ assertEquals(
332
+ b.list_bucket,
333
+ deriveEpicBucket(status, expectedDelivery, ackAt),
334
+ `${plan_key}: list_bucket must equal deriveEpicBucket`,
335
+ );
336
+ const expectedAckOpen = epicIsAcknowledgeable(status, expectedDelivery) && ackAt === null ? 1 : 0;
337
+ assertEquals(b.ack_open, expectedAckOpen, `${plan_key}: ack_open must equal epicIsAcknowledgeable`);
338
+ }
339
+
340
+ // Pin the human-visible outcomes so a derivation drift can't hide behind the cross-check.
341
+ assertEquals(bucket(db, "o/r#conv"), { list_bucket: "active", ack_open: 0, delivery: "converging" });
342
+ assertEquals(bucket(db, "o/r#land"), { list_bucket: "active", ack_open: 1, delivery: "landed" });
343
+ assertEquals(bucket(db, "o/r#ack"), { list_bucket: "history", ack_open: 0, delivery: "landed" });
344
+ assertEquals(bucket(db, "o/r#res"), { list_bucket: "active", ack_open: 1, delivery: null });
345
+ assertEquals(bucket(db, "o/r#live"), { list_bucket: "active", ack_open: 0, delivery: null });
346
+ });
347
+
348
+ test("RED/GREEN GUARD: a RAW-datasource plans.status write (the instanceTracking reconciler bypass) leaves plan_read_model's bucket CONSISTENT", () => {
349
+ // Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated process
350
+ // instance it writes `{status:"abandoned"}` to `plans` through the RAW datasource — bypassing the
351
+ // (now retired) projecting `plans` gateway. Under the OLD write-time projection the stored
352
+ // `list_bucket`/`ack_open` would freeze at their pre-terminal values; because they are now a VIEW
353
+ // over `status`, the read model stays correct with no write-path for any writer to leave stale.
354
+ const db = viewDb();
355
+ // A live epic mid-flight — Active, its (stale) stored projection says active/converging.
356
+ addPlan(db, "o/r#kill", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
357
+ assertEquals(bucket(db, "o/r#kill").list_bucket, "active");
358
+
359
+ // The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
360
+ // UPDATE, exactly what the raw datasource emits.) It touches neither list_bucket nor ack_open.
361
+ db.prepare("UPDATE plans SET status = 'abandoned' WHERE plan_key = ?").run("o/r#kill");
362
+
363
+ // `abandoned` is a terminal non-`done` status: History, never acknowledgeable — matches the oracle.
364
+ const b = bucket(db, "o/r#kill");
365
+ assertEquals(b.list_bucket, deriveEpicBucket("abandoned", b.delivery, null), "list_bucket tracks status via the VIEW");
366
+ assertEquals(b.list_bucket, "history", "an abandoned epic is filed under History, not wedged in Active");
367
+ assertEquals(b.ack_open, epicIsAcknowledgeable("abandoned", b.delivery) ? 1 : 0);
368
+ assertEquals(b.ack_open, 0, "no phantom Dismiss on a reconciler-cancelled epic");
262
369
  });