@nanobpm/nano-workforce 0.82.0 → 0.83.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.
@@ -0,0 +1,39 @@
1
+ name: PR title lint
2
+
3
+ # Squash-merge uses the PR title as the commit subject on `main`, and
4
+ # semantic-release (Angular preset) only cuts a release for `feat:`/`fix:`/`perf:`.
5
+ # A non-conventional title therefore lands on `main` and is silently skipped by
6
+ # the release job — so gate the title against the Conventional Commits grammar.
7
+ on:
8
+ pull_request:
9
+ types: [opened, edited, synchronize, reopened]
10
+
11
+ permissions:
12
+ pull-requests: read
13
+
14
+ jobs:
15
+ lint-title:
16
+ name: Conventional PR title
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: amannn/action-semantic-pull-request@v5
20
+ env:
21
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22
+ with:
23
+ # Keep in lockstep with the semantic-release commit-analyzer preset.
24
+ types: |
25
+ feat
26
+ fix
27
+ perf
28
+ build
29
+ chore
30
+ ci
31
+ docs
32
+ refactor
33
+ revert
34
+ style
35
+ test
36
+ requireScope: false
37
+ subjectPattern: ^(?![A-Z]).+$
38
+ subjectPatternError: |
39
+ The subject "{subject}" must start with a lowercase letter.
package/AGENTS.md CHANGED
@@ -308,6 +308,16 @@ agents:
308
308
  DCO check.
309
309
  - **Conventional Commits.** `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`,
310
310
  `test:`, imperative mood. Review-comment fix-ups are `chore:`, not `fix:`.
311
+ - **PR titles must be Conventional too — they become the release trigger.** PRs
312
+ land on `main` via **squash merge**, so the **PR title is the commit subject**
313
+ semantic-release analyses. Only `feat:` (minor) and `fix:`/`perf:` (patch) cut a
314
+ release; any other type — or a non-conventional title like `Redesign …` or
315
+ `Foundation: …` — lands on `main` and is **silently skipped** by the release job
316
+ (no version, no changelog, no deploy). A user-facing feature **must** be titled
317
+ `feat:`. The `PR title lint` workflow (`.github/workflows/pr-title-lint.yml`)
318
+ enforces this; if a non-conventional title ever slips through, push one empty
319
+ releasable commit (`git commit --allow-empty -s -m "feat(scope): …"`) to release
320
+ the accumulated changes.
311
321
  - **Feature work in a worktree** off `origin/main`, one branch per change; open a
312
322
  PR and reference the closing issue (`Closes #NN`).
313
323
  - **Never `git push --force` on `main`;** use `--force-with-lease` on feature
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.83.0](https://github.com/nanobpm/nano-workforce/compare/v0.82.1...v0.83.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * **feature-view:** release intent-first pipeline view with stage chips ([0342edb](https://github.com/nanobpm/nano-workforce/commit/0342edbf67372450d6c499c1d39dc042e8dda342)), closes [#267](https://github.com/nanobpm/nano-workforce/issues/267) [#266](https://github.com/nanobpm/nano-workforce/issues/266)
7
+
8
+ ## [0.82.1](https://github.com/nanobpm/nano-workforce/compare/v0.82.0...v0.82.1) (2026-08-17)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **plan-fanout:** make the wave-merge barrier level-triggered ([#262](https://github.com/nanobpm/nano-workforce/issues/262)) ([#264](https://github.com/nanobpm/nano-workforce/issues/264)) ([ae939d8](https://github.com/nanobpm/nano-workforce/commit/ae939d8e96f136f820fcae9149c120806ddc6f1b))
14
+
1
15
  # [0.82.0](https://github.com/nanobpm/nano-workforce/compare/v0.81.0...v0.82.0) (2026-08-17)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -565,11 +565,14 @@ the loop runs one parallel `implement` MI fan-out per wave:
565
565
  `dependsOn`), and advances `currentWave`.
566
566
  - **Wave-merge barrier** (`wait-wave-merged`): when a wave has a successor,
567
567
  `record-wave` sets `plans.gate_wave` to that wave's index and the process parks at
568
- the `wait-wave-merged` catch event. The poller's `pollWaveGates` pass publishes the
569
- `wave-merged` message (correlated on `planKey`) once **every opened PR in that wave
570
- has merged** (`app/waves.ts` `waveMergeTargets` selects the PRs to wait on;
571
- `blocked`/`skipped`/keyless tasks clear vacuously), then clears `gate_wave`
572
- single-shot. So a `dependsOn` means the dependent wave is not **implemented** until
568
+ the `wait-wave-merged` catch event. The poller's `pollWaveGatesImpl` pass is
569
+ **level-triggered**: it publishes the `wave-merged` message (correlated on `planKey`)
570
+ once **every opened PR in that wave has merged** (`app/waves.ts` `waveMergeTargets`
571
+ selects the PRs to wait on; `blocked`/`skipped`/keyless tasks clear vacuously) **and**
572
+ it observes an OPEN `wait-wave-merged` subscription for the plan so a merge that
573
+ lands while the token is still upstream can't drop the signal. The poller **never**
574
+ clears `gate_wave`; `record-wave` owns the marker's lifecycle (re-arming it to the next
575
+ wave, or clearing it to NULL on the final wave). So a `dependsOn` means the dependent wave is not **implemented** until
573
576
  its prerequisites have **landed on the base branch** — not merely opened. This lets
574
577
  a blocking prerequisite (e.g. app scaffolding) fully converge and merge before the
575
578
  next wave builds on it. `gate_wave` lives in `db/migrations/007_wave_gate.sql`.
@@ -239,6 +239,30 @@ test("startFeature: a settled run is restarted in place (status reset, pr/outcom
239
239
  assertEquals(row.process_key, "PI-2");
240
240
  });
241
241
 
242
+ test("startFeature: an in-place restart clears a stale acknowledged_at (re-earn the tick-off)", async () => {
243
+ const stores = {
244
+ feature_runs: {
245
+ rows: [
246
+ {
247
+ feature_key: "owner/repo#42",
248
+ status: "merged",
249
+ process_key: "PI-OLD",
250
+ pr_key: "owner/repo#100",
251
+ acknowledged_at: "2024-01-01T00:00:00Z",
252
+ converge: 1,
253
+ auto_merge: 1,
254
+ },
255
+ ],
256
+ key: "feature_key",
257
+ },
258
+ };
259
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-3" }) } as any;
260
+ await startFeature(memData(stores), engine, PARSED, "main", true, true);
261
+ const row = stores.feature_runs.rows[0];
262
+ assertEquals(row.status, "running");
263
+ assertEquals(row.acknowledged_at, null);
264
+ });
265
+
242
266
  // Issue #248: the human-readable identity for the feature grids. Every start persists a non-blank
243
267
  // `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
244
268
  // (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
package/app/feature.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  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
+ import { deriveListBucket, deriveStage } from "./stage.ts";
20
21
 
21
22
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
22
23
  export const FEATURE_PROCESS_ID = "feature";
@@ -70,6 +71,25 @@ export interface FeatureRun {
70
71
  * (`record-blocked-ack` / the acknowledge operation) and, as a self-heal, by `pollFeatureBlocked`
71
72
  * when a previously-observed task is completed out-of-band (see `deriveFeatureBlockedPatch`). */
72
73
  blocked_user_task_key: string | null;
74
+ /** Timestamp an operator dismissed a TERMINAL run (§5, `acknowledge-done`); NULL until then. When
75
+ * set on a terminal row, `list_bucket` flips from 'active' to 'history'. Projection surface. */
76
+ acknowledged_at: string | null;
77
+ /** Projection maintained by the feature_runs gateway (like `delivery_label`): the canonical pipeline
78
+ * stage key from `deriveStage` (Requested|Implementing|PR open|Converging|Merging|Done). The page's
79
+ * pipeline column binds `activeField` to it. NULL only on legacy rows before `backfillFeatureStages`. */
80
+ stage: string | null;
81
+ /** Gateway projection: the active stage's render state from `deriveStage` (`ok`|`failed`|`blocked`|
82
+ * NULL). The page binds `stateField` to it; NULL means in-progress (renderer shows `active`). */
83
+ stage_state: string | null;
84
+ /** Gateway projection: space-separated set of stage keys NOT in this row's path from `deriveStage`
85
+ * (derived from `converge`/`auto_merge`). The page binds `notInPathField` to it. */
86
+ stage_skipped: string | null;
87
+ /** Gateway projection: a short attention badge (`blocked` / `⚠`) for the active stage, or NULL, from
88
+ * `deriveStage`. The page binds `badgeField` to it. */
89
+ attention: string | null;
90
+ /** Gateway projection: the Active/History partition label ('active'|'history'), 'history' iff a
91
+ * terminal row has been acknowledged. The page's tabs filter on it with flat `in` clauses (§5). */
92
+ list_bucket: string | null;
73
93
  created_at: string;
74
94
  updated_at: string;
75
95
  }
@@ -251,7 +271,128 @@ export function deriveFeatureBlockedPatch(
251
271
  return Object.keys(patch).length > 0 ? patch : null;
252
272
  }
253
273
 
254
- export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
274
+ /** The feature_runs fields the projection reads. A patch touching none of these cannot change the
275
+ * derived `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`, so the gateway can skip the
276
+ * read-back+reproject for it (see the `update` proxy). Kept adjacent to `projectFeatureRun` so the two
277
+ * stay in lockstep — every field `projectFeatureRun` reads MUST appear here. */
278
+ const PROJECTION_INPUT_KEYS: readonly (keyof FeatureRun)[] = [
279
+ "status",
280
+ "pr_key",
281
+ "converge",
282
+ "auto_merge",
283
+ "escalation_question",
284
+ "escalation_user_task_key",
285
+ "blocked_user_task_key",
286
+ "acknowledged_at",
287
+ ];
288
+
289
+ /** The feature_runs fields the projection WRITES. Included in the reproject trigger so a caller who
290
+ * writes a derived column directly (e.g. `stage`/`list_bucket`) can never bypass derivation: the
291
+ * gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one, keeping
292
+ * "the gateway is the one projection source" a true invariant. Must mirror `projectFeatureRun`'s keys. */
293
+ const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
294
+ "stage",
295
+ "stage_state",
296
+ "stage_skipped",
297
+ "attention",
298
+ "list_bucket",
299
+ ];
300
+
301
+ /** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
302
+ * projection must be recomputed. A patch touching only projection-irrelevant fields (e.g. `updated_at`)
303
+ * leaves the stored projection correct, since the gateway is the sole write path (see `featureRuns`); a
304
+ * patch that writes a derived column directly still forces a reproject so derivation can't be bypassed. */
305
+ function patchAffectsProjection(patch: Partial<FeatureRun>): boolean {
306
+ return PROJECTION_INPUT_KEYS.some((k) => k in patch) || PROJECTION_OUTPUT_KEYS.some((k) => k in patch);
307
+ }
308
+
309
+ /** Compute the write-time projection columns for a fully-merged feature_runs row. Centralised so the
310
+ * gateway is the ONE place `deriveStage` / `deriveListBucket` are applied — the page, SQL, pollers and
311
+ * workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
312
+ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
313
+ if (!row.status) return {};
314
+ const { stage, state, skipped, attention } = deriveStage({
315
+ status: row.status,
316
+ pr_key: row.pr_key ?? null,
317
+ converge: row.converge ?? null,
318
+ auto_merge: row.auto_merge ?? null,
319
+ escalation_question: row.escalation_question ?? null,
320
+ escalation_user_task_key: row.escalation_user_task_key ?? null,
321
+ blocked_user_task_key: row.blocked_user_task_key ?? null,
322
+ });
323
+ return {
324
+ stage,
325
+ stage_state: state,
326
+ stage_skipped: skipped,
327
+ attention,
328
+ list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
329
+ };
330
+ }
331
+
332
+ /** The feature_runs record gateway (keyed on `feature_key`). Wrapped in a thin projecting proxy so the
333
+ * derived pipeline columns (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) CANNOT be
334
+ * missed by any writer: `insert` and `update` merge the incoming values over the current stored row,
335
+ * then recompute the projection from that post-write field set and write it alongside — exactly the
336
+ * `delivery_label`-style write-time projection, but hoisted to the single gateway so the many scattered
337
+ * status writers (startFeature, the service pollers/reconcilers, the acknowledge operations, and the
338
+ * feature workers) all stay UNCHANGED and automatically get a correct, fresh projection. `update`
339
+ * skips the read-back+reproject for a patch that touches no projection input (e.g. an `updated_at`-only
340
+ * poller write), avoiding a needless `get` roundtrip — the stored projection is already correct since
341
+ * this gateway is the sole write path. Every other method delegates straight through. This is the sole
342
+ * runtime/app-layer WRITE path to feature_runs (no app-code raw SQL, no other `data.table("feature_runs")`
343
+ * mutation — read-only direct reads in e2e tests, and forward-only data migrations such as
344
+ * `db/migrations/036_backfill_titles.sql`, notwithstanding), so
345
+ * `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` are
346
+ * always populated and correct for every row and every transition. */
347
+ export const featureRuns = (data: DataLayer) => {
348
+ const table = data.table<FeatureRun>("feature_runs", "feature_key");
349
+ return new Proxy(table, {
350
+ get(target, prop) {
351
+ if (prop === "insert") {
352
+ return (row: Partial<FeatureRun>) => target.insert({ ...row, ...projectFeatureRun(row) });
353
+ }
354
+ if (prop === "update") {
355
+ return async (id: unknown, patch: Partial<FeatureRun>) => {
356
+ // Only re-read + reproject when the patch changes a projection input. A projection-irrelevant
357
+ // patch (e.g. an `updated_at`-only poller write) leaves the stored projection correct — the
358
+ // gateway is the sole write path — so skip the extra `get` roundtrip and delegate straight.
359
+ if (!patchAffectsProjection(patch)) return target.update(id, patch);
360
+ const existing = await target.get(id);
361
+ const merged: Partial<FeatureRun> = { ...existing, ...patch };
362
+ return target.update(id, { ...patch, ...projectFeatureRun(merged) });
363
+ };
364
+ }
365
+ // Delegate every other method straight through. Bind functions to the real target so the
366
+ // gateway's private class fields (`#src`) resolve — a Proxy `this` would not carry them.
367
+ const value = Reflect.get(target, prop, target);
368
+ return typeof value === "function" ? value.bind(target) : value;
369
+ },
370
+ });
371
+ };
372
+
373
+ /** Re-project every feature_runs row through the gateway so rows written before migration 039 (whose
374
+ * projection columns are NULL) get correct `stage`/`stage_state`/`stage_skipped`/`attention`/
375
+ * `list_bucket` values. Idempotent and safe to re-run: it re-derives from each row's own stored fields,
376
+ * so a second pass is a no-op. Runs once at boot (pollOnce) — the gateway keeps every future write
377
+ * fresh, so this only needs to catch legacy rows once. */
378
+ export async function backfillFeatureStages(data: DataLayer): Promise<number> {
379
+ const table = featureRuns(data);
380
+ const rows = await table.all();
381
+ let stamped = 0;
382
+ for (const row of rows) {
383
+ // Only touch rows the projection has never reached — a legacy pre-039 row whose `stage` column is
384
+ // still NULL. The gateway keeps every write fresh, so an already-projected row needs no re-write;
385
+ // skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
386
+ // rows actually backfilled (not the total row count).
387
+ if (row.stage != null) continue;
388
+ // Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
389
+ // would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
390
+ // so backfill projects explicitly rather than relying on an empty-patch reproject.)
391
+ await table.update(row.feature_key, projectFeatureRun(row));
392
+ stamped++;
393
+ }
394
+ return stamped;
395
+ }
255
396
 
256
397
  /** The deterministic task id for a single-issue run — the implementation agent branches
257
398
  * `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
@@ -307,6 +448,11 @@ export async function startFeature(
307
448
  auto_merge: autoMerge ? 1 : 0,
308
449
  outcome: null,
309
450
  delivery_label: null,
451
+ // Clear the operator tick-off so a re-dispatched run is NOT silently dropped into History when
452
+ // it next settles: a stale `acknowledged_at` from the prior terminal run would make
453
+ // `deriveListBucket` flip the row to 'history' the moment it completes again, skipping the
454
+ // intended operator dismissal. A fresh run must re-earn its tick-off.
455
+ acknowledged_at: null,
310
456
  escalation_question: null,
311
457
  escalation_user_task_key: null,
312
458
  blocked_user_task_key: null,
@@ -0,0 +1,195 @@
1
+ // Tests for the feature_runs gateway's write-time pipeline projection (issue #254 §1/§3) and the
2
+ // one-shot backfill. The gateway wraps the plain table so EVERY writer — no matter which module —
3
+ // automatically gets a fresh `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`
4
+ // projection, without passing them: the single write path is the only place `deriveStage` /
5
+ // `deriveListBucket` are applied.
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import type { DataLayer } from "@nanobpm/urban";
9
+ import { backfillFeatureStages, featureRuns } from "./feature.ts";
10
+
11
+ // An in-memory record gateway with the same semantics the real Table exposes (get/all/find/insert/
12
+ // update). The featureRuns proxy wraps whatever data.table returns, so this exercises the real proxy.
13
+ function memData(): { data: DataLayer; rows: any[] } {
14
+ const rows: any[] = [];
15
+ function tbl(_name: string, pk = "id") {
16
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
17
+ return {
18
+ async all() {
19
+ return rows.slice();
20
+ },
21
+ async get(id: any) {
22
+ return rows.find((r) => r[pk] === id);
23
+ },
24
+ async find(where: any = {}) {
25
+ return rows.filter((r) => match(r, where));
26
+ },
27
+ async insert(row: any) {
28
+ rows.push({ ...row });
29
+ return row[pk];
30
+ },
31
+ async update(id: any, patch: any) {
32
+ const r = rows.find((row) => row[pk] === id);
33
+ if (r) Object.assign(r, patch);
34
+ return r ? 1 : 0;
35
+ },
36
+ };
37
+ }
38
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
39
+ return { data, rows };
40
+ }
41
+
42
+ test("update with only {status:'opened'} projects PR open / null / active without the caller passing them", async () => {
43
+ const { data, rows } = memData();
44
+ rows.push({ feature_key: "o/r#1", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
45
+ await featureRuns(data).update("o/r#1", { status: "opened" });
46
+ assertEquals(rows[0].stage, "PR open");
47
+ assertEquals(rows[0].stage_state, null);
48
+ assertEquals(rows[0].list_bucket, "active");
49
+ });
50
+
51
+ test("update {status:'failed'} projects Done / failed", async () => {
52
+ const { data, rows } = memData();
53
+ rows.push({ feature_key: "o/r#2", status: "running", converge: 1, auto_merge: 1 });
54
+ await featureRuns(data).update("o/r#2", { status: "failed" });
55
+ assertEquals(rows[0].stage, "Done");
56
+ assertEquals(rows[0].stage_state, "failed");
57
+ });
58
+
59
+ test("update {status:'blocked'} projects Done / blocked", async () => {
60
+ const { data, rows } = memData();
61
+ rows.push({ feature_key: "o/r#3", status: "running", converge: 1, auto_merge: 1 });
62
+ await featureRuns(data).update("o/r#3", { status: "blocked" });
63
+ assertEquals(rows[0].stage, "Done");
64
+ assertEquals(rows[0].stage_state, "blocked");
65
+ });
66
+
67
+ test("update {status:'escalated'} on a row with no pr_key projects Implementing / null", async () => {
68
+ const { data, rows } = memData();
69
+ rows.push({ feature_key: "o/r#4", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
70
+ await featureRuns(data).update("o/r#4", { status: "escalated" });
71
+ assertEquals(rows[0].stage, "Implementing");
72
+ assertEquals(rows[0].stage_state, null);
73
+ });
74
+
75
+ test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
76
+ const { data, rows } = memData();
77
+ rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
78
+ await featureRuns(data).update("o/r#5", { status: "running" });
79
+ assert(rows[0].stage_skipped.includes("Converging"));
80
+ assert(rows[0].stage_skipped.includes("Merging"));
81
+ });
82
+
83
+ test("insert projects the pipeline columns from status", async () => {
84
+ const { data, rows } = memData();
85
+ await featureRuns(data).insert({ feature_key: "o/r#6", status: "running", converge: 1, auto_merge: 1 } as any);
86
+ assertEquals(rows[0].stage, "Implementing");
87
+ assertEquals(rows[0].stage_state, null);
88
+ assertEquals(rows[0].list_bucket, "active");
89
+ });
90
+
91
+ test("setting acknowledged_at on a terminal row flips list_bucket to 'history'", async () => {
92
+ const { data, rows } = memData();
93
+ rows.push({ feature_key: "o/r#7", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
94
+ // Terminal but unacknowledged → still Active.
95
+ await featureRuns(data).update("o/r#7", { status: "merged" });
96
+ assertEquals(rows[0].list_bucket, "active");
97
+ // Acknowledge → History.
98
+ await featureRuns(data).update("o/r#7", { acknowledged_at: "2024-01-01T00:00:00Z" });
99
+ assertEquals(rows[0].list_bucket, "history");
100
+ assertEquals(rows[0].stage, "Done");
101
+ });
102
+
103
+ test("update touching only projection-irrelevant fields skips the read-back and reproject", async () => {
104
+ const { data, rows } = memData();
105
+ rows.push({
106
+ feature_key: "o/r#skip",
107
+ status: "merged",
108
+ converge: 1,
109
+ auto_merge: 1,
110
+ acknowledged_at: null,
111
+ stage: "Done",
112
+ stage_state: "ok",
113
+ stage_skipped: "",
114
+ attention: null,
115
+ list_bucket: "active",
116
+ });
117
+ // Count get() calls to prove the projection-irrelevant path does no read-back roundtrip.
118
+ let gets = 0;
119
+ const raw = (data as any).table;
120
+ (data as any).table = (n: string, pk?: string) => {
121
+ const t = raw(n, pk);
122
+ const origGet = t.get;
123
+ t.get = async (id: any) => {
124
+ gets++;
125
+ return origGet.call(t, id);
126
+ };
127
+ return t;
128
+ };
129
+ await featureRuns(data).update("o/r#skip", { updated_at: "2024-06-01T00:00:00Z" });
130
+ assertEquals(gets, 0);
131
+ assertEquals(rows[0].updated_at, "2024-06-01T00:00:00Z");
132
+ // Untouched projection stays as stored.
133
+ assertEquals(rows[0].list_bucket, "active");
134
+
135
+ // A projection-input change (acknowledged_at) DOES read back and reproject.
136
+ await featureRuns(data).update("o/r#skip", { acknowledged_at: "2024-06-01T00:00:00Z" });
137
+ assertEquals(gets, 1);
138
+ assertEquals(rows[0].list_bucket, "history");
139
+ });
140
+
141
+ test("a direct write of a projection output field forces reprojection and overrides the raw value", async () => {
142
+ const { data, rows } = memData();
143
+ rows.push({
144
+ feature_key: "o/r#bypass",
145
+ status: "running",
146
+ pr_key: null,
147
+ converge: 1,
148
+ auto_merge: 1,
149
+ stage: "Implementing",
150
+ stage_state: null,
151
+ list_bucket: "active",
152
+ });
153
+ // A caller tries to bypass derivation by writing the derived column directly. The gateway must NOT
154
+ // persist the raw value: it re-reads, recomputes from the merged inputs, and overrides it.
155
+ await featureRuns(data).update("o/r#bypass", { stage: "Done", list_bucket: "history" });
156
+ assertEquals(rows[0].stage, "Implementing");
157
+ assertEquals(rows[0].list_bucket, "active");
158
+ });
159
+
160
+ test("backfillFeatureStages stamps legacy terminal and live rows with helper-derived values", async () => {
161
+ const { data, rows } = memData();
162
+ // Legacy rows written before migration 039 → projection columns absent/NULL.
163
+ rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
164
+ rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
165
+
166
+ const stamped = await backfillFeatureStages(data);
167
+ assertEquals(stamped, 2);
168
+
169
+ const terminal = rows.find((r) => r.feature_key === "o/r#8");
170
+ assertEquals(terminal.stage, "Done");
171
+ assertEquals(terminal.stage_state, "ok");
172
+ assertEquals(terminal.list_bucket, "history");
173
+
174
+ const live = rows.find((r) => r.feature_key === "o/r#9");
175
+ assertEquals(live.stage, "Implementing");
176
+ assertEquals(live.stage_state, null);
177
+ assertEquals(live.stage_skipped, "Converging Merging");
178
+ assertEquals(live.list_bucket, "active");
179
+ });
180
+
181
+ test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
182
+ const { data, rows } = memData();
183
+ // One legacy row (no projection) + one already-projected row (gateway kept it fresh).
184
+ rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
185
+ rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
186
+
187
+ const stamped = await backfillFeatureStages(data);
188
+ // Only the legacy row is stamped; the already-projected row is skipped.
189
+ assertEquals(stamped, 1);
190
+ const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
191
+ assertEquals(legacy.stage, "Done");
192
+ // The already-projected row was not re-written (its sentinel updated_at is untouched).
193
+ const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
194
+ assertEquals(fresh.updated_at, "0");
195
+ });