@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,20 +1,32 @@
1
1
  // Tests for the POST /app/api/actions/acknowledge-epic operation `acknowledgeEpic` (issue #298).
2
- // The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (`delivery=landed`) or
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.
2
+ // The nwf UI's "Dismiss" affordance for a RESOLVED epic — landed (delivery=landed) or
3
+ // resolved-not-landed (delivery=null); only still-`converging` epics are rejected. It stamps
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.
8
+ //
9
+ // Since epic #412 retired the stored `plans.delivery` column, the op derives the delivery signal at
10
+ // READ TIME (`derivePlanDelivery` → the pure `deriveDelivery`) by joining the epic's slice
11
+ // `plan_tasks.pr_key` → `pull_requests.status`. So these tests seed `plan_tasks` + `pull_requests`
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.
7
16
  import { test } from "node:test";
8
17
  import { assertEquals } from "#test-assert";
9
18
  import type { AppApi } from "@nanobpm/urban";
10
- import { plans } from "../app/plan.ts";
19
+ import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
11
20
  import { noopLog } from "../test/log.ts";
12
21
  import handler from "./acknowledgeEpic.ts";
13
22
 
14
- // An in-memory data layer wired through the REAL plans gateway proxy, so the test exercises the
15
- // gateway's list_bucket/ack_open projection exactly as production does.
16
- function memApp(seed: any[]): { app: AppApi; rows: any[] } {
17
- const stores: Record<string, any[]> = { plans: seed };
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.
25
+ function memApp(
26
+ seed: any[],
27
+ extra: Record<string, any[]> = {},
28
+ ): { app: AppApi; rows: any[] } {
29
+ const stores: Record<string, any[]> = { plans: seed, ...extra };
18
30
  function tbl(name: string, pk = "id") {
19
31
  const rows = (stores[name] ??= [] as any[]);
20
32
  const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
@@ -50,53 +62,78 @@ async function call(app: AppApi, body: unknown) {
50
62
  return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
51
63
  }
52
64
 
53
- test("acknowledge-epic: stamps acknowledged_at and flips list_bucket to 'history' on a landed epic", async () => {
54
- const { app, rows } = memApp([{ plan_key: "o/r#1", status: "done", delivery: "landed", acknowledged_at: null }]);
55
- // Seed the projection as the gateway would have on the last write (landed, unacknowledged → active).
56
- await plans(app.data).update("o/r#1", { delivery: "landed" });
57
- assertEquals(rows[0].list_bucket, "active");
58
- assertEquals(rows[0].ack_open, 1);
65
+ test("acknowledge-epic: stamps acknowledged_at and (via the VIEW) buckets a landed epic into History", async () => {
66
+ const { app, rows } = memApp(
67
+ [{ plan_key: "o/r#1", status: "done", acknowledged_at: null }],
68
+ {
69
+ plan_tasks: [{ id: 1, plan_key: "o/r#1", pr_key: "o/r#100" }],
70
+ pull_requests: [{ pr_key: "o/r#100", status: "merged" }], // landed
71
+ },
72
+ );
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);
59
76
 
60
77
  const res = await call(app, { plan_key: "o/r#1" });
61
78
 
62
79
  assertEquals(res.status, 200);
63
80
  assertEquals(res.body.ok, true);
64
81
  assertEquals(typeof rows[0].acknowledged_at, "string");
65
- assertEquals(rows[0].list_bucket, "history");
66
- 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");
67
84
  });
68
85
 
69
86
  test("acknowledge-epic: a still-converging epic is rejected (409) and stays Active", async () => {
70
- const { app, rows } = memApp([{ plan_key: "o/r#2", status: "done", delivery: "converging", acknowledged_at: null }]);
71
- await plans(app.data).update("o/r#2", { delivery: "converging" });
87
+ const { app, rows } = memApp(
88
+ [{ plan_key: "o/r#2", status: "done", acknowledged_at: null }],
89
+ {
90
+ plan_tasks: [
91
+ { id: 1, plan_key: "o/r#2", pr_key: "o/r#200" },
92
+ { id: 2, plan_key: "o/r#2", pr_key: "o/r#201" },
93
+ ],
94
+ pull_requests: [
95
+ { pr_key: "o/r#200", status: "merged" },
96
+ { pr_key: "o/r#201", status: "converging" }, // still in flight → converging
97
+ ],
98
+ },
99
+ );
72
100
 
73
101
  const res = await call(app, { plan_key: "o/r#2" });
74
102
 
75
103
  assertEquals(res.status, 409);
76
104
  assertEquals(res.body.ok, false);
77
- // Untouched: no premature acknowledged_at, still Active.
105
+ // Untouched: no premature acknowledged_at; still Active through the VIEW (converging → active).
78
106
  assertEquals(rows[0].acknowledged_at, null);
79
- assertEquals(rows[0].list_bucket, "active");
107
+ assertEquals(deriveEpicBucket("done", "converging", rows[0].acknowledged_at), "active");
80
108
  });
81
109
 
82
110
  test("acknowledge-epic: a resolved-not-landed epic (delivery=null) is accepted (200) and flips to History", async () => {
83
- const { app, rows } = memApp([{ plan_key: "o/r#2b", status: "done", delivery: null, acknowledged_at: null }]);
84
- // Seed the projection as the gateway would have on the last write (resolved-not-landed, unacknowledged → active).
85
- await plans(app.data).update("o/r#2b", { delivery: null });
86
- assertEquals(rows[0].list_bucket, "active");
87
- assertEquals(rows[0].ack_open, 1);
111
+ const { app, rows } = memApp(
112
+ [{ plan_key: "o/r#2b", status: "done", acknowledged_at: null }],
113
+ {
114
+ plan_tasks: [
115
+ { id: 1, plan_key: "o/r#2b", pr_key: "o/r#210" },
116
+ { id: 2, plan_key: "o/r#2b", pr_key: "o/r#211" },
117
+ ],
118
+ pull_requests: [
119
+ { pr_key: "o/r#210", status: "merged" },
120
+ { pr_key: "o/r#211", status: "abandoned" }, // all terminal, not all merged → delivery null
121
+ ],
122
+ },
123
+ );
124
+ assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "active");
125
+ assertEquals(epicIsAcknowledgeable("done", null), true);
88
126
 
89
127
  const res = await call(app, { plan_key: "o/r#2b" });
90
128
 
91
129
  assertEquals(res.status, 200);
92
130
  assertEquals(res.body.ok, true);
93
131
  assertEquals(typeof rows[0].acknowledged_at, "string");
94
- assertEquals(rows[0].list_bucket, "history");
95
- assertEquals(rows[0].ack_open, 0);
132
+ assertEquals(deriveEpicBucket("done", null, rows[0].acknowledged_at), "history");
96
133
  });
97
134
 
98
135
  test("acknowledge-epic: a live (dispatched) epic is rejected (409)", async () => {
99
- const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched", delivery: null, acknowledged_at: null }]);
136
+ const { app } = memApp([{ plan_key: "o/r#3", status: "dispatched", acknowledged_at: null }]);
100
137
  const res = await call(app, { plan_key: "o/r#3" });
101
138
  assertEquals(res.status, 409);
102
139
  });
@@ -114,16 +151,21 @@ test("acknowledge-epic: no matching epic → 404", async () => {
114
151
  });
115
152
 
116
153
  test("acknowledge-epic: idempotent — re-acknowledging a landed epic keeps it in History", async () => {
117
- const { app, rows } = memApp([{ plan_key: "o/r#5", status: "done", delivery: "landed", acknowledged_at: null }]);
118
- await plans(app.data).update("o/r#5", { delivery: "landed" });
154
+ const { app, rows } = memApp(
155
+ [{ plan_key: "o/r#5", status: "done", acknowledged_at: null }],
156
+ {
157
+ plan_tasks: [{ id: 1, plan_key: "o/r#5", pr_key: "o/r#500" }],
158
+ pull_requests: [{ pr_key: "o/r#500", status: "merged" }], // landed
159
+ },
160
+ );
119
161
 
120
162
  assertEquals((await call(app, { plan_key: "o/r#5" })).status, 200);
121
163
  const firstStamp = rows[0].acknowledged_at;
122
- assertEquals(rows[0].list_bucket, "history");
164
+ assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
123
165
 
124
166
  const res2 = await call(app, { plan_key: "o/r#5" });
125
167
  assertEquals(res2.status, 200);
126
- assertEquals(rows[0].list_bucket, "history");
168
+ assertEquals(deriveEpicBucket("done", "landed", rows[0].acknowledged_at), "history");
127
169
  // Re-stamped (a fresh timestamp) but still resolved.
128
170
  assertEquals(typeof rows[0].acknowledged_at, "string");
129
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
@@ -19,6 +20,7 @@
19
20
 
20
21
  import { epicIsAcknowledgeable } from "../app/delivery.ts";
21
22
  import { plans } from "../app/plan.ts";
23
+ import { derivePlanDelivery } from "../app/service.ts";
22
24
  import { defineOperation } from "../nano-generated/operations.ts";
23
25
 
24
26
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
@@ -43,18 +45,21 @@ export default defineOperation("acknowledgeEpic", async ({ body }, app) => {
43
45
  // affordance. Acknowledging a live/converging epic would pre-seed `acknowledged_at`, so the moment
44
46
  // it later resolved `deriveEpicBucket` would drop it straight into History, skipping the operator
45
47
  // tick-off this op exists to require — and a converging epic must stay visible while its slices land.
46
- if (!epicIsAcknowledgeable(plan.status, plan.delivery ?? null)) {
48
+ // The `plans.delivery` column was retired (epic #412), so derive the signal at read time from the
49
+ // slice PRs (the same pure `deriveDelivery` the `plan_delivery` VIEW encodes).
50
+ const delivery = await derivePlanDelivery(app.data, plan);
51
+ if (!epicIsAcknowledgeable(plan.status, delivery)) {
47
52
  app.log.warn("acknowledge-epic rejected: epic is not resolved", {
48
53
  planKey,
49
54
  status: plan.status,
50
- delivery: plan.delivery ?? null,
55
+ delivery,
51
56
  });
52
57
  return { status: 409, body: { ok: false, error: "epic is not resolved" } };
53
58
  }
54
59
 
55
- // Stamp the dismissal. The gateway recomputes `list_bucket` (→ 'history') and `ack_open` (→ 0) from
56
- // the merged row, so we never hand-set them here. Idempotent: re-acknowledging re-stamps and stays
57
- // 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.
58
63
  const now = new Date().toISOString();
59
64
  await table.update(planKey, { acknowledged_at: now, updated_at: now });
60
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.121.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
  },
@@ -59,7 +59,7 @@ function fakeApp() {
59
59
  return { app, plans, planTasks, planTaskNeeds };
60
60
  }
61
61
 
62
- test("record-plan initializes wave progress fields for a taskful plan", async () => {
62
+ test("record-plan dispatches a taskful plan and levelizes its tasks (wave progress is now VIEW-derived)", async () => {
63
63
  const { app, plans } = fakeApp();
64
64
  await handler(
65
65
  {
@@ -74,22 +74,24 @@ test("record-plan initializes wave progress fields for a taskful plan", async ()
74
74
  app,
75
75
  );
76
76
  assertEquals(plans[0].status, "dispatched");
77
- assertEquals(plans[0].wave_count, 2);
78
- assertEquals(plans[0].current_wave, 0);
79
- assertEquals(plans[0].wave_label, "1/2");
77
+ // Wave progress (wave_count/current_wave/wave_label) was retired as a stored projection (epic
78
+ // #412) — it is derived from `plan_tasks` by the plan_wave_label/plan_read_model VIEWs — so
79
+ // record-plan no longer writes it onto the plans row.
80
+ assertEquals(plans[0].wave_count, undefined);
81
+ assertEquals(plans[0].current_wave, undefined);
82
+ assertEquals(plans[0].wave_label, undefined);
80
83
  });
81
84
 
82
- test("record-plan leaves all three wave progress fields NULL for a taskless plan", async () => {
85
+ test("record-plan marks a taskless plan done (no wave-progress columns written)", async () => {
83
86
  const { app, plans } = fakeApp();
84
87
  await handler(
85
88
  { variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
86
89
  app,
87
90
  );
88
91
  assertEquals(plans[0].status, "done");
89
- // No wave to implement => no misleading wave_count: 0 while current_wave/wave_label are NULL.
90
- assertEquals(plans[0].wave_count, null);
91
- assertEquals(plans[0].current_wave, null);
92
- assertEquals(plans[0].wave_label, null);
92
+ assertEquals(plans[0].wave_count, undefined);
93
+ assertEquals(plans[0].current_wave, undefined);
94
+ assertEquals(plans[0].wave_label, undefined);
93
95
  });
94
96
 
95
97
  test("record-plan persists per-task capability needs into plan_task_needs (issue #289)", async () => {
@@ -141,14 +141,10 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
141
141
  const patch: Record<string, unknown> = {
142
142
  status: tasks.length > 0 ? "dispatched" : "done",
143
143
  task_count: tasks.length,
144
- // Operator-visibility progress projection (issue #137): total waves (N), and the wave the
145
- // fleet is actively implementing (0 at dispatch). `select-wave` advances current_wave per
146
- // wave; a taskless plan gets no wave (NULL) since there is nothing to implement. Display-only.
147
- // All three fields stay NULL until dispatched with tasks — a taskless plan must not leak a
148
- // misleading wave_count: 0 while current_wave/wave_label are NULL (inconsistent projection).
149
- wave_count: tasks.length > 0 ? waveCount : null,
150
- current_wave: tasks.length > 0 ? 0 : null,
151
- wave_label: tasks.length > 0 ? `1/${waveCount}` : null,
144
+ // Operator-visibility wave progress (wave_count / current_wave / wave_label) was RETIRED as a
145
+ // stored projection (epic #412) the epics-index reads it from the `plan_wave_label` /
146
+ // `plan_read_model` SQL VIEWs (060/061) derived from `plan_tasks`, so this worker no longer
147
+ // denormalises it onto the `plans` row.
152
148
  updated_at: ts,
153
149
  };
154
150
  // Domain-phase projection (#261): recording the plan hands the epic to the `review-plan` agent,
@@ -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
  });
@@ -142,8 +142,9 @@ test("record-wave retries the same wave when a task is still pending", async ()
142
142
  trialMergeSkipReason: "wave-still-pending",
143
143
  });
144
144
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
145
- // Retry keeps the projection on the same (still-pending) wave.
146
- assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 1);
145
+ // Wave progress (current_wave/wave_label) was retired as a stored projection (epic #412) — derived
146
+ // from `plan_tasks` by the plan_wave_label VIEW — so record-wave no longer writes it.
147
+ assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
147
148
  // Domain-phase projection (#261): more waves remain, so the epic stays Implementing (wave n/t).
148
149
  assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Implementing (wave 2/2)");
149
150
  });
@@ -171,20 +172,21 @@ test("record-wave pins current_wave to the last index and clears gate_wave on th
171
172
  app,
172
173
  );
173
174
 
174
- // Final wave (2 of 3): no successor wave — gate cleared, projection pinned to N-1 so the
175
- // epics-index reads 3/3 rather than the one-past-the-end nextWave (3).
175
+ // Final wave (2 of 3): no successor wave — gate cleared. Wave progress (current_wave/wave_label)
176
+ // is no longer a stored column (epic #412; derived from `plan_tasks` by the plan_wave_label VIEW),
177
+ // so record-wave writes neither.
176
178
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
177
- assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 2);
178
- assertEquals((planUpdates[0].patch as Record<string, unknown>).wave_label, "3/3");
179
+ assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
180
+ assertEquals("wave_label" in (planUpdates[0].patch as Record<string, unknown>), false);
179
181
  // Domain-phase projection (#261): the final wave landed with no successor and no trial merge, so
180
182
  // the epic enters Finalizing (record-results then advances to the Dispatched terminal).
181
183
  assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Finalizing");
182
184
  });
183
185
 
184
- test("record-wave keeps all wave-progress fields NULL for a taskless plan (waveCount 0)", async () => {
186
+ test("record-wave writes no wave-progress columns for a taskless plan (waveCount 0)", async () => {
185
187
  // A taskless plan runs record-wave with waveCount 0 (the MI `implement` step completed
186
- // immediately). All three progress fields must stay NULL together never current_wave=0 against
187
- // a NULL wave_label, which would clobber record-plan/select-wave's NULL projection.
188
+ // immediately). Wave progress was retired as a stored projection (epic #412), so record-wave never
189
+ // writes current_wave/wave_label regardless.
188
190
  const { app, planUpdates } = fakeApp([]);
189
191
 
190
192
  await handler(
@@ -201,8 +203,8 @@ test("record-wave keeps all wave-progress fields NULL for a taskless plan (waveC
201
203
  );
202
204
 
203
205
  const patch = planUpdates[0].patch as Record<string, unknown>;
204
- assertEquals(patch.current_wave, null);
205
- assertEquals(patch.wave_label, null);
206
+ assertEquals("current_wave" in patch, false);
207
+ assertEquals("wave_label" in patch, false);
206
208
  });
207
209
 
208
210
  test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
@@ -302,17 +302,15 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
302
302
  const nextWave = stillPendingCurrentWave ? currentWave : currentWave + 1;
303
303
  const hasMoreWaves = stillPendingCurrentWave || nextWave < waveCount;
304
304
 
305
- // Operator-visibility projection (issue #137): keep plans.current_wave tracking the wave the
306
- // fleet is on. While more waves remain, point it at the wave about to run (select-wave re-writes
307
- // the same value when it dispatches); on the final wave, pin it to the last index so a finished
308
- // epic reads N/N (nextWave would be waveCount, one past the last band). Display-only.
305
+ // Wave index the epic is now on (issue #137): while more waves remain, the wave about to run; on
306
+ // the final wave, pinned to the last index so a finished epic reads N/N (nextWave would be
307
+ // waveCount, one past the last band). This is a LOCAL value only — it is used below to derive the
308
+ // `epic_phase` (Implementing wave n/t) and the domain phase.
309
309
  const projectedCurrentWave = hasMoreWaves ? nextWave : Math.max(0, waveCount - 1);
310
- // Keep the three progress fields consistent: a taskless plan (waveCount 0 the MI `implement`
311
- // step completed immediately with no waves) has no wave to be on, so current_wave and wave_label
312
- // both stay NULL rather than writing current_wave=0 against a NULL label (and clobbering the NULL
313
- // projection record-plan/select-wave already recorded).
314
- const currentWaveProjection = waveCount > 0 ? projectedCurrentWave : null;
315
- const waveLabel = waveCount > 0 ? `${projectedCurrentWave + 1}/${waveCount}` : null;
310
+ // Operator-visibility wave progress (current_wave / wave_label) was RETIRED as a stored projection
311
+ // (epic #412) it is now derived from `plan_tasks` by the `plan_wave_label` / `plan_read_model`
312
+ // VIEWs (060/061), so this worker no longer denormalises it (select-wave no longer writes it
313
+ // either). `projectedCurrentWave` above is not persisted; it only feeds the phase derivation.
316
314
 
317
315
  // Domain-phase projection (#261): the wave landed — stamp the phase the epic is ENTERING next,
318
316
  // which is data-dependent here (unlike the structural spine writers). A trial merge runs → Trial
@@ -336,8 +334,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
336
334
  try {
337
335
  await plans(app.data).update(planKey, {
338
336
  gate_wave: hasMoreWaves ? currentWave : null,
339
- current_wave: currentWaveProjection,
340
- wave_label: waveLabel,
341
337
  epic_phase: epicPhase,
342
338
  updated_at: ts,
343
339
  });
@@ -67,21 +67,22 @@ async function selectWave(rows: Row[], deps: DepRow[]) {
67
67
  return out as { waveTasks: unknown[] };
68
68
  }
69
69
 
70
- test("select-wave projects the active wave onto plans.current_wave", async () => {
70
+ test("select-wave dispatches the active wave without writing wave-progress columns", async () => {
71
71
  const rows: Row[] = [
72
72
  { id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 1 },
73
73
  ];
74
- const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 0 }];
74
+ const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63" }];
75
75
  const out = await handler(
76
76
  { variables: { planKey: "owner/repo#63", currentWave: 1 }, elementId: "select-wave" } as any,
77
77
  fakeApp(rows, [], plans),
78
78
  );
79
79
  assertEquals((out as { waveTasks: unknown[] }).waveTasks.length, 1);
80
- assertEquals(plans[0].current_wave, 1);
81
- // wave_count is derived from the levelized rows (max wave + 1) and the 1-based "X/N" label
82
- // is pre-formatted for the epics-index at-a-glance column.
83
- assertEquals(plans[0].wave_count, 2);
84
- assertEquals(plans[0].wave_label, "2/2");
80
+ // Wave progress (current_wave/wave_count/wave_label) was retired as a stored projection (epic
81
+ // #412; the columns are dropped by migration 070) it is derived from `plan_tasks` by the
82
+ // plan_wave_label VIEW so select-wave introduces no wave-progress field onto the plan row.
83
+ assertEquals(plans[0].current_wave, undefined);
84
+ assertEquals(plans[0].wave_count, undefined);
85
+ assertEquals(plans[0].wave_label, undefined);
85
86
  // Domain-phase projection (#261): dispatching the wave marks the epic Implementing (wave n/t),
86
87
  // derived from this worker's BPMN element id + the levelize records.
87
88
  assertEquals(plans[0].epic_phase, "Implementing (wave 2/2)");
@@ -124,17 +125,18 @@ test("select-wave leaves bound_artifacts untouched for a root epic (no resolvedA
124
125
  assertEquals(plans[0].bound_artifacts, undefined);
125
126
  });
126
127
 
127
- test("select-wave nulls all three progress fields when there are no levelized rows", async () => {
128
- // No plan_tasks rows => waveCount 0. current_wave must be NULL too (not a stray index against a
129
- // NULL wave_count/wave_label), matching the documented "NULL until dispatched with tasks".
130
- const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 5 }];
128
+ test("select-wave writes no wave-progress columns when there are no levelized rows", async () => {
129
+ // Wave progress was retired as a stored projection (epic #412; the columns are dropped by
130
+ // migration 070; it is derived from `plan_tasks` by the plan_wave_label VIEW), so select-wave
131
+ // introduces no wave-progress field onto the plan row.
132
+ const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63" }];
131
133
  await handler(
132
134
  { variables: { planKey: "owner/repo#63", currentWave: 0 } } as any,
133
135
  fakeApp([], [], plans),
134
136
  );
135
- assertEquals(plans[0].current_wave, null);
136
- assertEquals(plans[0].wave_count, null);
137
- assertEquals(plans[0].wave_label, null);
137
+ assertEquals(plans[0].current_wave, undefined);
138
+ assertEquals(plans[0].wave_count, undefined);
139
+ assertEquals(plans[0].wave_label, undefined);
138
140
  });
139
141
 
140
142
  test("select-wave leaves dependents pending behind a waiting-for-lane dependency", async () => {
@@ -78,17 +78,16 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
78
78
  : [];
79
79
  try {
80
80
  await plans(app.data).update(planKey, {
81
- // Keep the three progress fields consistent: with no levelized rows (waveCount 0) there is
82
- // no wave to implement, so current_wave is NULL too never a stray index against NULL N.
83
- current_wave: waveCount > 0 ? currentWave : null,
84
- wave_count: waveCount > 0 ? waveCount : null,
85
- wave_label: waveCount > 0 ? `${currentWave + 1}/${waveCount}` : null,
81
+ // Operator-visibility wave progress (current_wave / wave_count / wave_label) was RETIRED as a
82
+ // stored projection (epic #412) it is now derived from `plan_tasks` by the `plan_wave_label`
83
+ // / `plan_read_model` VIEWs (060/061), so select-wave no longer denormalises it. This write
84
+ // still stamps the derived domain phase and the inter-epic gate's bound artifacts.
86
85
  ...(epicPhase ? { epic_phase: epicPhase } : {}),
87
86
  ...(boundArtifacts.length > 0 ? { bound_artifacts: JSON.stringify(boundArtifacts) } : {}),
88
87
  updated_at: ts,
89
88
  });
90
89
  } catch (err) {
91
- app.log.error(`select-wave: projecting current_wave failed for ${planKey}`, {
90
+ app.log.error(`select-wave: projecting plan row (epic phase / bound artifacts) failed for ${planKey}`, {
92
91
  err: String(err),
93
92
  });
94
93
  }