@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.
@@ -1,17 +1,19 @@
1
1
  // Tests for the POST /app/api/actions/acknowledge-done operation `acknowledgeDone` (issue #254 §5).
2
- // The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at` via the
3
- // feature_runs gateway, which recomputes `list_bucket` to 'history', dropping the run from Active into
4
- // History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is not parked). Mirrors
5
- // the acknowledge-blocked twin's shape.
2
+ // The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at`, which
3
+ // the `feature_read_model` VIEW (073, issue #439) derives into `list_bucket` = 'history', dropping the
4
+ // run from Active into History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is
5
+ // not parked). Since `list_bucket` is now a VIEW over `status`/`acknowledged_at` (no stored column),
6
+ // these tests assert the operation's real write — the `acknowledged_at` stamp — and cross-check the
7
+ // resulting bucket through the pure `deriveListBucket` oracle the VIEW mirrors.
6
8
  import { test } from "node:test";
7
9
  import { assertEquals } from "#test-assert";
8
10
  import type { AppApi } from "@nanobpm/urban";
9
- import { featureRuns } from "../app/feature.ts";
11
+ import { deriveListBucket } from "../app/stage.ts";
10
12
  import { noopLog } from "../test/log.ts";
11
13
  import handler from "./acknowledgeDone.ts";
12
14
 
13
- // An in-memory data layer wired through the REAL featureRuns gateway proxy, so the test exercises the
14
- // gateway's list_bucket projection exactly as production does.
15
+ // An in-memory data layer wired through the `featureRuns` gateway (now a plain record table), so the
16
+ // test exercises the operation's write path exactly as production does.
15
17
  function memApp(seed: any[]): { app: AppApi; rows: any[] } {
16
18
  const stores: Record<string, any[]> = { feature_runs: seed };
17
19
  function tbl(name: string, pk = "id") {
@@ -49,30 +51,30 @@ async function call(app: AppApi, body: unknown) {
49
51
  return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
50
52
  }
51
53
 
52
- test("acknowledge-done: stamps acknowledged_at and flips list_bucket to 'history' on a terminal row", async () => {
54
+ test("acknowledge-done: stamps acknowledged_at and (via the VIEW) buckets a terminal row into History", async () => {
53
55
  const { app, rows } = memApp([{ feature_key: "o/r#1", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null }]);
54
- // Seed the projection as the gateway would have on the last write (terminal, unacknowledged → active).
55
- await featureRuns(app.data).update("o/r#1", { status: "merged" });
56
- assertEquals(rows[0].list_bucket, "active");
56
+ // Before dismissal a terminal-but-unacknowledged run reads as Active through the VIEW.
57
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
57
58
 
58
59
  const res = await call(app, { feature_key: "o/r#1" });
59
60
 
60
61
  assertEquals(res.status, 200);
61
62
  assertEquals(res.body.ok, true);
62
63
  assertEquals(typeof rows[0].acknowledged_at, "string");
63
- assertEquals(rows[0].list_bucket, "history");
64
+ // The op's only write is the stamp; the VIEW derives 'history' from (terminal status + acknowledged).
65
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
64
66
  });
65
67
 
66
68
  test("acknowledge-done: idempotent-safe — re-acknowledging keeps the row in History", async () => {
67
69
  const { app, rows } = memApp([{ feature_key: "o/r#2", status: "failed", converge: 1, auto_merge: 1, acknowledged_at: null }]);
68
70
  const first = await call(app, { feature_key: "o/r#2" });
69
71
  assertEquals(first.status, 200);
70
- assertEquals(rows[0].list_bucket, "history");
72
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
71
73
  const firstStamp = rows[0].acknowledged_at;
72
74
  // Re-acknowledge — still 200, still history.
73
75
  const second = await call(app, { feature_key: "o/r#2" });
74
76
  assertEquals(second.status, 200);
75
- assertEquals(rows[0].list_bucket, "history");
77
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "history");
76
78
  assertEquals(typeof firstStamp, "string");
77
79
  });
78
80
 
@@ -92,12 +94,12 @@ test("acknowledge-done: no such feature run → 404", async () => {
92
94
 
93
95
  test("acknowledge-done: a non-terminal run → 409, no acknowledged_at stamped", async () => {
94
96
  const { app, rows } = memApp([{ feature_key: "o/r#live", status: "running", converge: 1, auto_merge: 1, acknowledged_at: null }]);
95
- await featureRuns(app.data).update("o/r#live", { status: "running" });
96
97
  const res = await call(app, { feature_key: "o/r#live" });
97
98
  assertEquals(res.status, 409);
98
99
  assertEquals(res.body.ok, false);
99
100
  assertEquals(rows[0].acknowledged_at, null);
100
- assertEquals(rows[0].list_bucket, "active");
101
+ // A live run stays Active through the VIEW (no stamp → not History).
102
+ assertEquals(deriveListBucket(rows[0].status, rows[0].acknowledged_at), "active");
101
103
  });
102
104
 
103
105
  test("acknowledge-done: a converging (redispatch-terminal but live) run → 409", async () => {
@@ -3,13 +3,15 @@
3
3
  // run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
4
4
  // Active list into History. It is the DONE twin of `acknowledgeBlocked` — but a terminal run is NOT
5
5
  // parked at a user task, so this op does NOT complete a user task and touches no engine/ledger: it
6
- // simply stamps `acknowledged_at` on the row via the feature_runs gateway. It rejects (409) a run that
6
+ // simply stamps `acknowledged_at` on the row via the plain `feature_runs` record table (the projecting
7
+ // gateway this PR retired). It rejects (409) a run that
7
8
  // is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
8
9
  //
9
- // The gateway (app/feature.ts) recomputes `list_bucket` on that write a terminal row with
10
- // `acknowledged_at` set flips to 'history' so this op NEVER hand-sets `list_bucket` (or any other
11
- // projection). Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps
12
- // the timestamp and keeps the row in History.
10
+ // The `list_bucket` partition is DERIVED by the `feature_read_model` VIEW (073, issue #439) from
11
+ // `status` + `acknowledged_at`a terminal row with `acknowledged_at` set reads as 'history' — so
12
+ // this op NEVER writes `list_bucket` (or any projection): stamping `acknowledged_at` is the whole
13
+ // contract. Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps the
14
+ // timestamp and keeps the row in History.
13
15
 
14
16
  import { featureRuns } from "../app/feature.ts";
15
17
  import { STAGE_DONE_STATUSES } from "../app/stage.ts";
@@ -42,9 +44,9 @@ export default defineOperation("acknowledgeDone", async ({ body }, app) => {
42
44
  return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
43
45
  }
44
46
 
45
- // Stamp the dismissal. The gateway recomputes `list_bucket` from the merged row (→ 'history' for a
46
- // terminal run), so we never hand-set it here. Idempotent: re-acknowledging re-stamps and stays in
47
- // History.
47
+ // Stamp the dismissal. `list_bucket` is derived by the `feature_read_model` VIEW (→ 'history' for a
48
+ // terminal, acknowledged row), so we never hand-set it here. Idempotent: re-acknowledging re-stamps
49
+ // and stays in History.
48
50
  const now = new Date().toISOString();
49
51
  await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
50
52
 
@@ -1,24 +1,27 @@
1
1
  // Tests for the POST /app/api/actions/acknowledge-epic operation `acknowledgeEpic` (issue #298).
2
2
  // The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (delivery=landed) or
3
3
  // resolved-not-landed (delivery=null); only still-`converging` epics are rejected. It stamps
4
- // `acknowledged_at` via the plans gateway, which recomputes `list_bucket` to 'history' (and
5
- // `ack_open` to 0), dropping the resolved epic from Active into History. Unlike acknowledge-blocked
6
- // it completes NO user task (a resolved epic is not parked). The epic twin of acknowledge-done.
4
+ // `acknowledged_at`, which the `plan_read_model` VIEW (074, issue #439) derives into `list_bucket` =
5
+ // 'history' and `ack_open` = 0, dropping the resolved epic from Active into History. Unlike
6
+ // acknowledge-blocked it completes NO user task (a resolved epic is not parked). The epic twin of
7
+ // acknowledge-done.
7
8
  //
8
9
  // Since epic #412 retired the stored `plans.delivery` column, the op derives the delivery signal at
9
10
  // READ TIME (`derivePlanDelivery` → the pure `deriveDelivery`) by joining the epic's slice
10
11
  // `plan_tasks.pr_key` → `pull_requests.status`. So these tests seed `plan_tasks` + `pull_requests`
11
- // (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic.
12
+ // (not a `plans.delivery` column) to model a landed / converging / resolved-not-landed epic. Because
13
+ // `list_bucket`/`ack_open` are now a VIEW (no stored column), the tests assert the operation's real
14
+ // write — the `acknowledged_at` stamp and the 200/409 gate — and cross-check the resulting bucket
15
+ // through the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles the VIEW mirrors.
12
16
  import { test } from "node:test";
13
17
  import { assertEquals } from "#test-assert";
14
18
  import type { AppApi } from "@nanobpm/urban";
15
- import { plans } from "../app/plan.ts";
19
+ import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
16
20
  import { noopLog } from "../test/log.ts";
17
21
  import handler from "./acknowledgeEpic.ts";
18
22
 
19
- // An in-memory data layer wired through the REAL plans gateway proxy, so the test exercises the
20
- // gateway's list_bucket/ack_open projection exactly as production does. `extra` seeds the join
21
- // surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
23
+ // An in-memory data layer wired through the `plans` gateway (now a plain record table). `extra` seeds
24
+ // the join surfaces (`plan_tasks` / `pull_requests`) the read-time delivery derivation reads.
22
25
  function memApp(
23
26
  seed: any[],
24
27
  extra: Record<string, any[]> = {},
@@ -59,13 +62,7 @@ async function call(app: AppApi, body: unknown) {
59
62
  return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
60
63
  }
61
64
 
62
- /** Fire a projecting gateway write (re-writing `status`) so `list_bucket`/`ack_open` reflect the
63
- * delivery-free gateway projection, mirroring the last real write before the op runs. */
64
- async function project(app: AppApi, key: string, status: string) {
65
- await plans(app.data).update(key, { status });
66
- }
67
-
68
- test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history' on a landed epic", async () => {
65
+ test("acknowledge-epic: stamps acknowledged_at and (via the VIEW) buckets a landed epic into History", async () => {
69
66
  const { app, rows } = memApp(
70
67
  [{ plan_key: "o/r#1", status: "done", acknowledged_at: null }],
71
68
  {
@@ -73,17 +70,17 @@ test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history
73
70
  pull_requests: [{ pr_key: "o/r#100", status: "merged" }], // landed
74
71
  },
75
72
  );
76
- await project(app, "o/r#1", "done");
77
- assertEquals(rows[0].list_bucket, "active");
78
- assertEquals(rows[0].ack_open, 1);
73
+ // Before dismissal a landed-but-unacknowledged epic reads as Active with Dismiss open through the VIEW.
74
+ assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "active");
75
+ assertEquals(epicIsAcknowledgeable("done", "landed"), true);
79
76
 
80
77
  const res = await call(app, { plan_key: "o/r#1" });
81
78
 
82
79
  assertEquals(res.status, 200);
83
80
  assertEquals(res.body.ok, true);
84
81
  assertEquals(typeof rows[0].acknowledged_at, "string");
85
- assertEquals(rows[0].list_bucket, "history");
86
- assertEquals(rows[0].ack_open, 0);
82
+ // The op's only write is the stamp; the VIEW derives 'history' + ack_open 0 from the acknowledged row.
83
+ assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
87
84
  });
88
85
 
89
86
  test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
@@ -100,15 +97,14 @@ test("acknowledge-epic: a still-converging epic is rejected (409) and stays Acti
100
97
  ],
101
98
  },
102
99
  );
103
- await project(app, "o/r#2", "done");
104
100
 
105
101
  const res = await call(app, { plan_key: "o/r#2" });
106
102
 
107
103
  assertEquals(res.status, 409);
108
104
  assertEquals(res.body.ok, false);
109
- // Untouched: no premature acknowledged_at, still Active.
105
+ // Untouched: no premature acknowledged_at; still Active through the VIEW (converging → active).
110
106
  assertEquals(rows[0].acknowledged_at, null);
111
- assertEquals(rows[0].list_bucket, "active");
107
+ assertEquals(deriveEpicBucket("done", "converging", rows[0].acknowledged_at), "active");
112
108
  });
113
109
 
114
110
  test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
@@ -125,17 +121,15 @@ test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (
125
121
  ],
126
122
  },
127
123
  );
128
- await project(app, "o/r#2b", "done");
129
- assertEquals(rows[0].list_bucket, "active");
130
- assertEquals(rows[0].ack_open, 1);
124
+ assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "active");
125
+ assertEquals(epicIsAcknowledgeable("done", null), true);
131
126
 
132
127
  const res = await call(app, { plan_key: "o/r#2b" });
133
128
 
134
129
  assertEquals(res.status, 200);
135
130
  assertEquals(res.body.ok, true);
136
131
  assertEquals(typeof rows[0].acknowledged_at, "string");
137
- assertEquals(rows[0].list_bucket, "history");
138
- assertEquals(rows[0].ack_open, 0);
132
+ assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "history");
139
133
  });
140
134
 
141
135
  test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
@@ -164,15 +158,14 @@ test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it i
164
158
  pull_requests: [{ pr_key: "o/r#500", status: "merged" }], // landed
165
159
  },
166
160
  );
167
- await project(app, "o/r#5", "done");
168
161
 
169
162
  assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
170
163
  const firstStamp = rows[0].acknowledged_at;
171
- assertEquals(rows[0].list_bucket, "history");
164
+ assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
172
165
 
173
166
  const res2 = await call(app, { plan_key: "o/r#5" });
174
167
  assertEquals(res2.status, 200);
175
- assertEquals(rows[0].list_bucket, "history");
168
+ assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
176
169
  // Re-stamped (a fresh timestamp) but still resolved.
177
170
  assertEquals(typeof rows[0].acknowledged_at, "string");
178
171
  void firstStamp;
@@ -4,12 +4,13 @@
4
4
  // resolved-not-landed) directly from the Epic / Overview pages so it drops out of the Active epic list
5
5
  // into History. It is the epic twin of `acknowledgeDone` (the feature-run tick-off) — a resolved epic
6
6
  // is NOT parked at a user task, so this op completes no user task and touches no engine/ledger: it
7
- // simply stamps `acknowledged_at` on the `plans` row via the plans gateway.
7
+ // simply stamps `acknowledged_at` on the `plans` row.
8
8
  //
9
- // The gateway (app/plan.ts) recomputes `list_bucket`/`ack_open` on that write a landed, now-
10
- // acknowledged epic flips `list_bucket` to 'history' and `ack_open` to 0 so this op NEVER hand-sets
11
- // a derived projection. Keyed on the row's `plan_key`. Idempotent-safe: re-acknowledging re-stamps
12
- // the timestamp and keeps the row in History.
9
+ // `list_bucket`/`ack_open` are DERIVED by the `plan_read_model` VIEW (074, issue #439) from
10
+ // `status` + `acknowledged_at` + the derived `plan_delivery` signala landed, now-acknowledged epic
11
+ // reads `list_bucket` = 'history' and `ack_open` = 0 so this op NEVER writes a derived projection.
12
+ // Keyed on the row's `plan_key`. Idempotent-safe: re-acknowledging re-stamps the timestamp and keeps
13
+ // the row in History.
13
14
  //
14
15
  // It rejects (409) an epic that is NOT yet resolved — i.e. anything the `epicIsAcknowledgeable`
15
16
  // guard refuses: a non-`done` status (`planning`/`dispatched`), or `done` but still `converging`. A
@@ -56,9 +57,9 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
56
57
  return { status: 409, body: { ok: false, error: "epic is not resolved" } };
57
58
  }
58
59
 
59
- // Stamp the dismissal. The gateway recomputes `list_bucket` (→ 'history') and `ack_open` (→ 0) from
60
- // the merged row, so we never hand-set them here. Idempotent: re-acknowledging re-stamps and stays
61
- // in History.
60
+ // Stamp the dismissal. `list_bucket` (→ 'history') and `ack_open` (→ 0) are derived by the
61
+ // `plan_read_model` VIEW from the resolved, now-acknowledged row, so we never hand-set them here.
62
+ // Idempotent: re-acknowledging re-stamps and stays in History.
62
63
  const now = new Date().toISOString();
63
64
  await table.update(planKey, { acknowledged_at: now, updated_at: now });
64
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.122.0",
3
+ "version": "0.123.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -99,7 +99,7 @@
99
99
  "data": {
100
100
  "kind": "datasource",
101
101
  "source": "app",
102
- "table": "plans",
102
+ "table": "plan_read_model",
103
103
  "orderBy": { "field": "updated_at", "dir": "desc" },
104
104
  "filter": [{ "field": "list_bucket", "in": ["active"] }]
105
105
  },
@@ -102,7 +102,7 @@
102
102
  "data": {
103
103
  "kind": "datasource",
104
104
  "source": "app",
105
- "table": "feature_runs",
105
+ "table": "feature_read_model",
106
106
  "orderBy": { "field": "updated_at", "dir": "desc" },
107
107
  "filter": [{ "field": "list_bucket", "in": ["active"] }]
108
108
  },
@@ -9,6 +9,7 @@
9
9
  import { test } from "node:test";
10
10
  import { assertEquals, assertRejects } from "#test-assert";
11
11
  import { BpmnError } from "@nanobpm/urban";
12
+ import { deriveEpicBucket } from "../../app/delivery.ts";
12
13
  import { noopLog } from "../../test/log.ts";
13
14
  import handler from "./worker.ts";
14
15
  import type { PlanTaskStatus } from "../../app/plan.ts";
@@ -77,8 +78,9 @@ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () =
77
78
  const plan = app._plans.at(-1) as Record<string, unknown>;
78
79
  assertEquals(plan.status, "failed");
79
80
  assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
80
- // The gateway projects the bucket: a failed epic settles to History (no tick-off needed).
81
- assertEquals(plan.list_bucket, "history");
81
+ // `list_bucket` is derived by the `plan_read_model` VIEW (074): a failed epic reads as History
82
+ // (no tick-off needed). Cross-checked against the pure `deriveEpicBucket` oracle the VIEW mirrors.
83
+ assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "history");
82
84
  });
83
85
 
84
86
  test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
@@ -102,6 +104,7 @@ test("at least one opened PR finalizes cleanly (no throw)", async () => {
102
104
  const plan = app._plans.at(-1) as Record<string, unknown>;
103
105
  assertEquals(plan.status, "done");
104
106
  assertEquals(plan.outcome, "1 PR(s) dispatched to convergence");
105
- // A just-`done` epic (delivery not yet projected) stays in Active it must not vanish (#298).
106
- assertEquals(plan.list_bucket, "active");
107
+ // A just-`done` epic (delivery not yet converging, unacknowledged) reads as Active through the VIEW
108
+ // it must not vanish (#298). Cross-checked against the pure `deriveEpicBucket` oracle.
109
+ assertEquals(deriveEpicBucket(plan.status as string, null, plan.acknowledged_at as string | null), "active");
107
110
  });
@@ -1,202 +0,0 @@
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
- // A legacy row parked at a LIVE escalation: its projection columns are absent/NULL, so backfill MUST
166
- // reproject its pipeline stage (issue #254). Issue #332 dropped the escalation_* denormalisation.
167
- rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1 });
168
-
169
- const stamped = await backfillFeatureStages(data);
170
- assertEquals(stamped, 3);
171
-
172
- const terminal = rows.find((r) => r.feature_key === "o/r#8");
173
- assertEquals(terminal.stage, "Done");
174
- assertEquals(terminal.stage_state, "ok");
175
- assertEquals(terminal.list_bucket, "history");
176
-
177
- const live = rows.find((r) => r.feature_key === "o/r#9");
178
- assertEquals(live.stage, "Implementing");
179
- assertEquals(live.stage_state, null);
180
- assertEquals(live.stage_skipped, "Converging Merging");
181
- assertEquals(live.list_bucket, "active");
182
-
183
- const escalated = rows.find((r) => r.feature_key === "o/r#esc");
184
- assertEquals(escalated.stage, "Implementing");
185
- assertEquals(escalated.attention, "⚠");
186
- });
187
-
188
- test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
189
- const { data, rows } = memData();
190
- // One legacy row (no projection) + one already-projected row (gateway kept it fresh).
191
- rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
192
- 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" });
193
-
194
- const stamped = await backfillFeatureStages(data);
195
- // Only the legacy row is stamped; the already-projected row is skipped.
196
- assertEquals(stamped, 1);
197
- const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
198
- assertEquals(legacy.stage, "Done");
199
- // The already-projected row was not re-written (its sentinel updated_at is untouched).
200
- const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
201
- assertEquals(fresh.updated_at, "0");
202
- });