@nanobpm/nano-workforce 0.122.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.123.0](https://github.com/nanobpm/nano-workforce/compare/v0.122.0...v0.123.0) (2026-08-22)
2
+
3
+
4
+ ### Features
5
+
6
+ * derive feature/epic display projections as SQL VIEWs ([#439](https://github.com/nanobpm/nano-workforce/issues/439)) ([#448](https://github.com/nanobpm/nano-workforce/issues/448)) ([5c79718](https://github.com/nanobpm/nano-workforce/commit/5c797184e65250afea1224bb6227d4cb7687ce8b)), closes [#412](https://github.com/nanobpm/nano-workforce/issues/412) [#412](https://github.com/nanobpm/nano-workforce/issues/412) [#412](https://github.com/nanobpm/nano-workforce/issues/412)
7
+
1
8
  # [0.122.0](https://github.com/nanobpm/nano-workforce/compare/v0.121.0...v0.122.0) (2026-08-22)
2
9
 
3
10
 
@@ -3,44 +3,11 @@
3
3
  // (epic #412 retired the stored `plans.delivery` / `plans.delivery_label` columns). It must cleanly
4
4
  // distinguish an epic whose fan-out is `done` but whose slices are still CONVERGING from one where
5
5
  // every slice PR has LANDED, and count abandoned/converged PRs as resolved-not-landed (never
6
- // `landed`). `pollPlanBucket` (app/service.ts) is the reader that applies the delivery-aware
7
- // `list_bucket`/`ack_open` correction from that signal.
6
+ // `landed`). The delivery-aware `list_bucket`/`ack_open` bucket derivation now lives in the
7
+ // `plan_read_model` VIEW (074), cross-checked against the pure helpers in app/plansReadModel.test.ts.
8
8
  import { test } from "node:test";
9
9
  import { assert, assertEquals } from "#test-assert";
10
- import type { DataLayer } from "@nanobpm/urban";
11
10
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
12
- import { pollPlanBucket } from "./service.ts";
13
-
14
- // A tiny in-memory record gateway (all/find/update/insert), mirroring the fake-app style used
15
- // across the app tests (see app/taskDelta.test.ts), enough to exercise the `pollPlanBucket` pass.
16
- function memData(): { data: DataLayer; stores: Record<string, any[]> } {
17
- const stores: Record<string, any[]> = {};
18
- function tbl(name: string, pk = "id") {
19
- const rows = (stores[name] ??= [] as any[]);
20
- const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
21
- return {
22
- async all() {
23
- return rows.slice();
24
- },
25
- async get(id: any) {
26
- return rows.find((r) => r[pk] === id);
27
- },
28
- async find(where: any = {}) {
29
- return rows.filter((r) => match(r, where));
30
- },
31
- async insert(row: any) {
32
- rows.push({ ...row });
33
- return row[pk];
34
- },
35
- async update(id: any, patch: any) {
36
- const r = rows.find((row) => row[pk] === id);
37
- if (r) Object.assign(r, patch);
38
- },
39
- };
40
- }
41
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
42
- return { data, stores };
43
- }
44
11
 
45
12
  test("all slice PRs merged -> landed", () => {
46
13
  const r = deriveDelivery("done", ["merged", "merged", "merged"]);
@@ -107,102 +74,3 @@ test("every non-terminal status counts as in flight", () => {
107
74
  assertEquals(r.prsInFlight, 1, `status ${s}`);
108
75
  }
109
76
  });
110
-
111
- test("pollPlanBucket: a still-converging done epic suppresses the Dismiss flag (ack_open=0) and stays Active", async () => {
112
- const { data, stores } = memData();
113
- // A `done` epic the delivery-free gateway left as a Dismiss candidate (ack_open=1); pollPlanBucket
114
- // derives `converging` at read time and clears it.
115
- stores.plans = [
116
- { plan_key: "epic-1", status: "done", acknowledged_at: null, list_bucket: "active", ack_open: 1 },
117
- ];
118
- stores.plan_tasks = [
119
- { id: 1, plan_key: "epic-1", pr_key: "o/r#1" },
120
- { id: 2, plan_key: "epic-1", pr_key: "o/r#2" },
121
- ];
122
- stores.pull_requests = [
123
- { pr_key: "o/r#1", status: "merged" },
124
- { pr_key: "o/r#2", status: "converging" },
125
- ];
126
-
127
- await pollPlanBucket(data);
128
-
129
- assertEquals(stores.plans[0].list_bucket, "active");
130
- assertEquals(stores.plans[0].ack_open, 0, "converging epic must not offer Dismiss");
131
- });
132
-
133
- test("pollPlanBucket: a fully landed done epic opens the Dismiss flag (ack_open=1), still Active", async () => {
134
- const { data, stores } = memData();
135
- stores.plans = [
136
- { plan_key: "epic-2", status: "done", acknowledged_at: null, list_bucket: "active", ack_open: 0 },
137
- ];
138
- stores.plan_tasks = [
139
- { id: 1, plan_key: "epic-2", pr_key: "o/r#10" },
140
- { id: 2, plan_key: "epic-2", pr_key: "o/r#11" },
141
- ];
142
- stores.pull_requests = [
143
- { pr_key: "o/r#10", status: "merged" },
144
- { pr_key: "o/r#11", status: "merged" },
145
- ];
146
-
147
- await pollPlanBucket(data);
148
-
149
- assertEquals(stores.plans[0].list_bucket, "active");
150
- assertEquals(stores.plans[0].ack_open, 1);
151
- });
152
-
153
- test("pollPlanBucket: a dangling pr_key keeps the epic converging (ack_open stays 0)", async () => {
154
- const { data, stores } = memData();
155
- stores.plans = [
156
- { plan_key: "epic-3", status: "done", acknowledged_at: null, list_bucket: "active", ack_open: 1 },
157
- ];
158
- stores.plan_tasks = [
159
- { id: 1, plan_key: "epic-3", pr_key: "o/r#1" },
160
- { id: 2, plan_key: "epic-3", pr_key: "o/r#2" }, // no matching pull_requests row (DB desync)
161
- ];
162
- stores.pull_requests = [{ pr_key: "o/r#1", status: "merged" }];
163
-
164
- await pollPlanBucket(data);
165
-
166
- // Without the dangling PR counting as in-flight this would wrongly land → open Dismiss.
167
- assertEquals(stores.plans[0].ack_open, 0);
168
- });
169
-
170
- test("pollPlanBucket: an acknowledged landed epic buckets to History", async () => {
171
- const { data, stores } = memData();
172
- stores.plans = [
173
- {
174
- plan_key: "epic-4",
175
- status: "done",
176
- acknowledged_at: "2024-01-01T00:00:00Z",
177
- list_bucket: "active",
178
- ack_open: 1,
179
- },
180
- ];
181
- stores.plan_tasks = [{ id: 1, plan_key: "epic-4", pr_key: "o/r#20" }];
182
- stores.pull_requests = [{ pr_key: "o/r#20", status: "merged" }];
183
-
184
- await pollPlanBucket(data);
185
-
186
- assertEquals(stores.plans[0].list_bucket, "history");
187
- assertEquals(stores.plans[0].ack_open, 0);
188
- });
189
-
190
- test("pollPlanBucket: a steady-state pass rewrites nothing (idempotent)", async () => {
191
- const { data, stores } = memData();
192
- stores.plans = [
193
- {
194
- plan_key: "epic-5",
195
- status: "done",
196
- acknowledged_at: null,
197
- list_bucket: "active",
198
- ack_open: 1,
199
- updated_at: "2020-01-01T00:00:00.000Z",
200
- },
201
- ];
202
- stores.plan_tasks = [{ id: 1, plan_key: "epic-5", pr_key: "o/r#30" }];
203
- stores.pull_requests = [{ pr_key: "o/r#30", status: "merged" }]; // landed → ack_open already 1
204
-
205
- await pollPlanBucket(data);
206
-
207
- assertEquals(stores.plans[0].updated_at, "2020-01-01T00:00:00.000Z", "no-op pass must not re-stamp");
208
- });
package/app/feature.ts CHANGED
@@ -18,7 +18,6 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
20
  import type { ReadinessProbe } from "./readiness.ts";
21
- import { deriveListBucket, deriveStage } from "./stage.ts";
22
21
 
23
22
  /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
24
23
  * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
@@ -68,21 +67,26 @@ export interface FeatureRun {
68
67
  /** Timestamp an operator dismissed a TERMINAL run (§5, `acknowledge-done`); NULL until then. When
69
68
  * set on a terminal row, `list_bucket` flips from 'active' to 'history'. Projection surface. */
70
69
  acknowledged_at: string | null;
71
- /** Projection maintained by the feature_runs gateway (like `delivery_label`): the canonical pipeline
72
- * stage key from `deriveStage` (Requested|Implementing|PR open|Converging|Merging|Done). The page's
73
- * pipeline column binds `activeField` to it. NULL only on legacy rows before `backfillFeatureStages`. */
70
+ /** RETIRED as a write-time projection (issue #439): the pipeline `stage` is now DERIVED by the
71
+ * `feature_read_model` VIEW (073) from `status`/`pr_key`/`converge`/`auto_merge`, mirroring
72
+ * `deriveStage` (app/stage.ts). The Feature page binds the VIEW's `stage`, never this base column,
73
+ * so a raw-datasource `status` write (the `instanceTracking` reconciler) can no longer leave it
74
+ * stale. The base column survives (expand/contract — a later migration drops it) but is no longer
75
+ * written or read; NULL on rows written after the projection was removed. */
74
76
  stage: string | null;
75
- /** Gateway projection: the active stage's render state from `deriveStage` (`ok`|`failed`|`blocked`|
76
- * NULL). The page binds `stateField` to it; NULL means in-progress (renderer shows `active`). */
77
+ /** RETIRED as a write-time projection (issue #439): `stage_state` is DERIVED by `feature_read_model`
78
+ * (073). See `stage`. The page binds the VIEW's `stateField`; this base column is vestigial. */
77
79
  stage_state: string | null;
78
- /** Gateway projection: space-separated set of stage keys NOT in this row's path from `deriveStage`
79
- * (derived from `converge`/`auto_merge`). The page binds `notInPathField` to it. */
80
+ /** RETIRED as a write-time projection (issue #439): `stage_skipped` is DERIVED by `feature_read_model`
81
+ * (073). See `stage`. The page binds the VIEW's `notInPathField`; this base column is vestigial. */
80
82
  stage_skipped: string | null;
81
- /** Gateway projection: a short attention badge (`blocked` / `⚠`) for the active stage, or NULL, from
82
- * `deriveStage`. The page binds `badgeField` to it. */
83
+ /** RETIRED as a write-time projection (issue #439): `attention` is DERIVED by `feature_read_model`
84
+ * (073). See `stage`. The page binds the VIEW's `badgeField`; this base column is vestigial. */
83
85
  attention: string | null;
84
- /** Gateway projection: the Active/History partition label ('active'|'history'), 'history' iff a
85
- * terminal row has been acknowledged. The page's tabs filter on it with flat `in` clauses (§5). */
86
+ /** RETIRED as a write-time projection (issue #439): the Active/History `list_bucket` ('active'|
87
+ * 'history', 'history' iff a terminal row is acknowledged) is DERIVED by `feature_read_model` (073)
88
+ * from `status`/`acknowledged_at`, mirroring `deriveListBucket`. The page's tabs filter the VIEW's
89
+ * `list_bucket`; this base column is vestigial. */
86
90
  list_bucket: string | null;
87
91
  created_at: string;
88
92
  updated_at: string;
@@ -214,122 +218,20 @@ export async function recordFeatureEscalation(
214
218
  * Tasks inbox. */
215
219
  export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
216
220
 
217
- /** The feature_runs fields the projection reads. A patch touching none of these cannot change the
218
- * derived `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`, so the gateway can skip the
219
- * read-back+reproject for it (see the `update` proxy). Kept adjacent to `projectFeatureRun` so the two
220
- * stay in lockstep every field `projectFeatureRun` reads MUST appear here. */
221
- const PROJECTION_INPUT_KEYS: readonly (keyof FeatureRun)[] = [
222
- "status",
223
- "pr_key",
224
- "converge",
225
- "auto_merge",
226
- "acknowledged_at",
227
- ];
228
-
229
- /** The feature_runs fields the projection WRITES. Included in the reproject trigger so a caller who
230
- * writes a derived column directly (e.g. `stage`/`list_bucket`) can never bypass derivation: the
231
- * gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one, keeping
232
- * "the gateway is the one projection source" a true invariant. Must mirror `projectFeatureRun`'s keys. */
233
- const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
234
- "stage",
235
- "stage_state",
236
- "stage_skipped",
237
- "attention",
238
- "list_bucket",
239
- ];
240
-
241
- /** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
242
- * projection must be recomputed. A patch touching only projection-irrelevant fields (e.g. `updated_at`)
243
- * leaves the stored projection correct, since the gateway is the sole write path (see `featureRuns`); a
244
- * patch that writes a derived column directly still forces a reproject so derivation can't be bypassed. */
245
- function patchAffectsProjection(patch: Partial<FeatureRun>): boolean {
246
- return PROJECTION_INPUT_KEYS.some((k) => k in patch) || PROJECTION_OUTPUT_KEYS.some((k) => k in patch);
247
- }
248
-
249
- /** Compute the write-time projection columns for a fully-merged feature_runs row. Centralised so the
250
- * gateway is the ONE place `deriveStage` / `deriveListBucket` are applied — the page, SQL, pollers and
251
- * workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
252
- function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
253
- if (!row.status) return {};
254
- const { stage, state, skipped, attention } = deriveStage({
255
- status: row.status,
256
- pr_key: row.pr_key ?? null,
257
- converge: row.converge ?? null,
258
- auto_merge: row.auto_merge ?? null,
259
- });
260
- return {
261
- stage,
262
- stage_state: state,
263
- stage_skipped: skipped,
264
- attention,
265
- list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
266
- };
267
- }
268
-
269
- /** The feature_runs record gateway (keyed on `feature_key`). Wrapped in a thin projecting proxy so the
270
- * derived pipeline columns (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) CANNOT be
271
- * missed by any writer: `insert` and `update` merge the incoming values over the current stored row,
272
- * then recompute the projection from that post-write field set and write it alongside — exactly the
273
- * `delivery_label`-style write-time projection, but hoisted to the single gateway so the many scattered
274
- * status writers (startFeature, the service pollers/reconcilers, the acknowledge operations, and the
275
- * feature workers) all stay UNCHANGED and automatically get a correct, fresh projection. `update`
276
- * skips the read-back+reproject for a patch that touches no projection input (e.g. an `updated_at`-only
277
- * poller write), avoiding a needless `get` roundtrip — the stored projection is already correct since
278
- * this gateway is the sole write path. Every other method delegates straight through. This is the sole
279
- * runtime/app-layer WRITE path to feature_runs (no app-code raw SQL, no other `data.table("feature_runs")`
280
- * mutation — read-only direct reads in e2e tests, and forward-only data migrations such as
281
- * `db/migrations/036_backfill_titles.sql`, notwithstanding), so
282
- * `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` are
283
- * always populated and correct for every row and every transition. */
284
- export const featureRuns = (data: DataLayer) => {
285
- const table = data.table<FeatureRun>("feature_runs", "feature_key");
286
- return new Proxy(table, {
287
- get(target, prop) {
288
- if (prop === "insert") {
289
- return (row: Partial<FeatureRun>) => target.insert({ ...row, ...projectFeatureRun(row) });
290
- }
291
- if (prop === "update") {
292
- return async (id: unknown, patch: Partial<FeatureRun>) => {
293
- // Only re-read + reproject when the patch changes a projection input. A projection-irrelevant
294
- // patch (e.g. an `updated_at`-only poller write) leaves the stored projection correct — the
295
- // gateway is the sole write path — so skip the extra `get` roundtrip and delegate straight.
296
- if (!patchAffectsProjection(patch)) return target.update(id, patch);
297
- const existing = await target.get(id);
298
- const merged: Partial<FeatureRun> = { ...existing, ...patch };
299
- return target.update(id, { ...patch, ...projectFeatureRun(merged) });
300
- };
301
- }
302
- // Delegate every other method straight through. Bind functions to the real target so the
303
- // gateway's private class fields (`#src`) resolve — a Proxy `this` would not carry them.
304
- const value = Reflect.get(target, prop, target);
305
- return typeof value === "function" ? value.bind(target) : value;
306
- },
307
- });
308
- };
309
-
310
- /** Re-project every feature_runs row through the gateway so rows missing any projection column get
311
- * correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` values. Catches rows written
312
- * before migration 039 (the pipeline columns). Idempotent and safe to re-run: it re-derives from each
313
- * row's own stored fields, so a second pass is a no-op. Runs once at boot (pollOnce) — the gateway keeps
314
- * every future write fresh, so this only needs to catch legacy rows. */
315
- export async function backfillFeatureStages(data: DataLayer): Promise<number> {
316
- const table = featureRuns(data);
317
- const rows = await table.all();
318
- let stamped = 0;
319
- for (const row of rows) {
320
- // Only touch rows the projection has never reached — a legacy row whose `stage` (pre-039) column is
321
- // still NULL. The gateway keeps every write fresh, so a fully-projected row needs no re-write;
322
- // skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
323
- // rows actually backfilled (not the total row count).
324
- if (row.stage != null) continue;
325
- // Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
326
- // would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
327
- // so backfill projects explicitly rather than relying on an empty-patch reproject.)
328
- await table.update(row.feature_key, projectFeatureRun(row));
329
- stamped++;
330
- }
331
- return stamped;
332
- }
221
+ /** The feature_runs record gateway (keyed on `feature_key`) a plain record table.
222
+ *
223
+ * The pipeline projection (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) is NO
224
+ * LONGER a write-time projection here (issue #439): it is a DERIVED SQL VIEW, `feature_read_model`
225
+ * (073), computed from each row's own `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at`. The
226
+ * Feature page binds the VIEW, never this table's stored derived columns. Removing the write-time
227
+ * projection closes the drift the framework `instanceTracking` reconciler opened: it writes
228
+ * `feature_runs.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance),
229
+ * bypassing the old projecting gateway and leaving the display columns frozen — now there is no stored
230
+ * derived column and no write-path for any writer to leave stale. `deriveStage`/`deriveListBucket`
231
+ * (app/stage.ts) remain the canonical implementation the VIEW mirrors and the acknowledge operations
232
+ * reuse; app/featureReadModel.test.ts pins the VIEW to them (including a raw-datasource `status` write
233
+ * that reproduces the reconciler bypass). */
234
+ export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
333
235
 
334
236
  /** The deterministic task id for a single-issue run — the implementation agent branches
335
237
  * `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
@@ -0,0 +1,210 @@
1
+ // Read-model VIEW coverage for the feature-run pipeline projection (issue #439 — the status-driven
2
+ // follow-up to epic #412, "Retire worker-maintained denormalized projections in favour of SQL
3
+ // VIEWs").
4
+ //
5
+ // 039_feature_pipeline_stage.sql denormalised the pipeline projection
6
+ // (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) onto the `feature_runs` row,
7
+ // written by the `featureRuns()` gateway (app/feature.ts) at WRITE TIME from the pure `deriveStage` /
8
+ // `deriveListBucket` (app/stage.ts). That "the gateway is the sole write path" invariant did NOT hold
9
+ // for the framework `instanceTracking` reconciler, which writes `feature_runs.status` through the RAW
10
+ // datasource on a terminated instance — bypassing the gateway and freezing the display columns.
11
+ // 073_feature_read_model.sql retires the write-time projection: the derived columns are now a VIEW
12
+ // over each row's own `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at`, so there is no
13
+ // stored column and no write-path for any writer to leave stale.
14
+ //
15
+ // This exercises the REAL SQLite view (073 applied to an in-memory DB, mirroring
16
+ // app/plansReadModel.test.ts / app/mergesPerDayView.test.ts) and pins that its CASE expressions
17
+ // reproduce `deriveStage` / `deriveListBucket` EXACTLY over the full status matrix — the SAME pure
18
+ // helpers the acknowledge operations guard on — plus a RED/GREEN guard reproducing the reconciler
19
+ // bypass: a RAW-datasource `status` write must leave the projection correct.
20
+ import { readFileSync } from "node:fs";
21
+ import { DatabaseSync } from "node:sqlite";
22
+ import { test } from "node:test";
23
+ import { fileURLToPath } from "node:url";
24
+ import { assert, assertEquals } from "#test-assert";
25
+ import { FEATURE_RUN_STATUSES } from "./feature.ts";
26
+ import { deriveListBucket, deriveStage } from "./stage.ts";
27
+
28
+ const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
29
+ const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
30
+
31
+ // The base `feature_runs` shape the view reads (028 + 030's delivery_label + 035's title + 039's
32
+ // acknowledged_at & the now-vestigial stored stage/… columns), plus migration 073. The stored derived
33
+ // columns are present precisely so the tests can seed STALE values and prove the VIEW ignores them.
34
+ function viewDb(): DatabaseSync {
35
+ const db = new DatabaseSync(":memory:");
36
+ db.exec(
37
+ `CREATE TABLE feature_runs (
38
+ feature_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
39
+ base_branch TEXT, status TEXT, process_key TEXT, pr_key TEXT, converge INTEGER, auto_merge INTEGER,
40
+ outcome TEXT, delivery_label TEXT, acknowledged_at TEXT, created_at TEXT, updated_at TEXT,
41
+ stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT);`,
42
+ );
43
+ db.exec(MIG("073_feature_read_model.sql"));
44
+ return db;
45
+ }
46
+
47
+ interface SampleRun {
48
+ status: string;
49
+ pr_key?: string | null;
50
+ converge?: number;
51
+ auto_merge?: number;
52
+ acknowledged_at?: string | null;
53
+ // Deliberately-stale STORED projection columns (simulating a row the gateway last projected while
54
+ // in a different status). The VIEW must ignore these and re-derive from `status`.
55
+ stored?: Partial<Record<"stage" | "stage_state" | "stage_skipped" | "attention" | "list_bucket", string>>;
56
+ }
57
+
58
+ function addRun(db: DatabaseSync, feature_key: string, run: SampleRun): void {
59
+ const s = run.stored ?? {};
60
+ db.prepare(
61
+ `INSERT INTO feature_runs
62
+ (feature_key, repo, issue_number, issue_url, title, base_branch, status, pr_key, converge,
63
+ auto_merge, acknowledged_at, created_at, updated_at,
64
+ stage, stage_state, stage_skipped, attention, list_bucket)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
66
+ ).run(
67
+ feature_key,
68
+ "o/r",
69
+ 1,
70
+ `https://gh/${feature_key}`,
71
+ `Feature ${feature_key}`,
72
+ "main",
73
+ run.status,
74
+ run.pr_key ?? null,
75
+ run.converge ?? 0,
76
+ run.auto_merge ?? 0,
77
+ run.acknowledged_at ?? null,
78
+ "2026-01-01T00:00:00Z",
79
+ "2026-01-01T00:00:00Z",
80
+ s.stage ?? null,
81
+ s.stage_state ?? null,
82
+ s.stage_skipped ?? null,
83
+ s.attention ?? null,
84
+ s.list_bucket ?? null,
85
+ );
86
+ }
87
+
88
+ function projection(db: DatabaseSync, feature_key: string): Record<string, unknown> {
89
+ const r = db
90
+ .prepare(
91
+ "SELECT stage, stage_state, stage_skipped, attention, list_bucket FROM feature_read_model WHERE feature_key = ?",
92
+ )
93
+ .get(feature_key) as Record<string, unknown>;
94
+ return { ...r };
95
+ }
96
+
97
+ test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key combination", () => {
98
+ const db = viewDb();
99
+ const cases: Array<{ key: string; run: SampleRun }> = [];
100
+ let i = 0;
101
+ for (const status of FEATURE_RUN_STATUSES) {
102
+ for (const converge of [0, 1]) {
103
+ for (const auto_merge of [0, 1]) {
104
+ for (const pr_key of [null, `o/r#pr${i}`]) {
105
+ const key = `o/r#${i++}`;
106
+ cases.push({ key, run: { status, converge, auto_merge, pr_key } });
107
+ addRun(db, key, { status, converge, auto_merge, pr_key });
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ for (const { key, run } of cases) {
114
+ const oracle = deriveStage({
115
+ status: run.status,
116
+ pr_key: run.pr_key ?? null,
117
+ converge: run.converge ?? 0,
118
+ auto_merge: run.auto_merge ?? 0,
119
+ });
120
+ const row = projection(db, key);
121
+ assertEquals(row.stage, oracle.stage, `${key} (status=${run.status}): stage`);
122
+ assertEquals(row.stage_state, oracle.state, `${key} (status=${run.status}): stage_state`);
123
+ assertEquals(row.stage_skipped, oracle.skipped, `${key} (status=${run.status}): stage_skipped`);
124
+ assertEquals(row.attention, oracle.attention, `${key} (status=${run.status}): attention`);
125
+ }
126
+ });
127
+
128
+ test("feature_read_model derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
129
+ const db = viewDb();
130
+ let i = 0;
131
+ const cases: Array<{ key: string; status: string; ackAt: string | null }> = [];
132
+ for (const status of FEATURE_RUN_STATUSES) {
133
+ for (const ackAt of [null, "2026-02-02T00:00:00Z"]) {
134
+ const key = `o/r#lb${i++}`;
135
+ cases.push({ key, status, ackAt });
136
+ addRun(db, key, { status, acknowledged_at: ackAt });
137
+ }
138
+ }
139
+ for (const { key, status, ackAt } of cases) {
140
+ assertEquals(
141
+ projection(db, key).list_bucket,
142
+ deriveListBucket(status, ackAt),
143
+ `${key} (status=${status}, acknowledged=${ackAt !== null}): list_bucket`,
144
+ );
145
+ }
146
+ });
147
+
148
+ test("feature_read_model IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
149
+ const db = viewDb();
150
+ // A merged run whose STORED columns lie (frozen from when it was `running`). The VIEW must re-derive.
151
+ addRun(db, "o/r#stale", {
152
+ status: "merged",
153
+ acknowledged_at: "2026-02-02T00:00:00Z",
154
+ stored: { stage: "Implementing", stage_state: undefined, attention: "⚠", list_bucket: "active" },
155
+ });
156
+ assertEquals(projection(db, "o/r#stale"), {
157
+ stage: "Done",
158
+ stage_state: "ok",
159
+ stage_skipped: "Converging Merging",
160
+ attention: null,
161
+ list_bucket: "history",
162
+ });
163
+ });
164
+
165
+ test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceTracking reconciler bypass) leaves the projection CORRECT (stage=Done, terminal stage_state, attention=null, Dismiss renderable, still Active)", () => {
166
+ // Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated (cancelled)
167
+ // process instance it writes `{status:"abandoned"}` to `feature_runs` through the RAW datasource,
168
+ // bypassing the (now retired) projecting `featureRuns` gateway. Under the OLD write-time projection
169
+ // the stored `stage`/`stage_state`/`attention`/`list_bucket` would FREEZE at their pre-terminal
170
+ // values — the merlin symptom: a cancelled run wedged in Active as a live-looking `Implementing ⚠`,
171
+ // its Dismiss gated shut on a NULL stage_state. Because the projection is now a VIEW over `status`,
172
+ // the read model stays correct with no write-path for any writer to leave it stale.
173
+ const db = viewDb();
174
+ // A live run mid-flight — its (soon-stale) stored projection says Implementing / ⚠ / active.
175
+ addRun(db, "o/r#kill", {
176
+ status: "running",
177
+ stored: { stage: "Implementing", stage_state: undefined, attention: undefined, list_bucket: "active" },
178
+ });
179
+ assertEquals(projection(db, "o/r#kill").stage, "Implementing", "precondition: live run renders Implementing");
180
+
181
+ // The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
182
+ // UPDATE, exactly what the raw datasource emits.) It touches none of the display columns.
183
+ db.prepare("UPDATE feature_runs SET status = 'abandoned' WHERE feature_key = ?").run("o/r#kill");
184
+
185
+ const row = projection(db, "o/r#kill");
186
+ const oracle = deriveStage({ status: "abandoned", pr_key: null, converge: 0, auto_merge: 0 });
187
+ // The projection tracks `status` through the VIEW — the merlin drift can no longer happen.
188
+ assertEquals(row.stage, "Done", "an abandoned run is Done, not wedged at Implementing");
189
+ assertEquals(row.stage, oracle.stage);
190
+ assertEquals(row.stage_state, "failed", "abandoned renders a terminal FAILED state (was frozen NULL)");
191
+ assertEquals(row.stage_state, oracle.state);
192
+ assertEquals(row.attention, null, "the stale ⚠ badge is gone");
193
+ assertEquals(row.attention, oracle.attention);
194
+ // Dismiss's `showWhenField` is `stage_state`: a non-null terminal state makes it renderable.
195
+ assert(row.stage_state != null, "Dismiss is renderable (stage_state is non-null) so the run can be ticked off");
196
+ // Unacknowledged terminal → still Active (History only after the operator dismisses it).
197
+ assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
198
+ assertEquals(row.list_bucket, deriveListBucket("abandoned", null));
199
+ });
200
+
201
+ test("the Feature page binds the derived feature_read_model VIEW (not the raw feature_runs table)", () => {
202
+ // `feature.page.json`'s runs grid is the ONLY thing making the UI consume the derived projection.
203
+ // `feature_runs` remains a valid schema table, so reverting this binding would leave every SQL-view
204
+ // test green while the display silently resumed reading the stale stored columns; pin it here
205
+ // (suppressed advisory feature.page.json — issue #439).
206
+ const page = PAGE("feature.page.json");
207
+ const runs = (page.nodes ?? []).find((n: { id: string }) => n.id === "feature-runs");
208
+ assert(runs, "feature page must keep the Feature runs grid");
209
+ assertEquals(runs.props.data.table, "feature_read_model");
210
+ });