@nanobpm/nano-workforce 0.120.2 → 0.122.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,17 @@
1
+ # [0.122.0](https://github.com/nanobpm/nano-workforce/compare/v0.121.0...v0.122.0) (2026-08-22)
2
+
3
+
4
+ ### Features
5
+
6
+ * retire worker-maintained projection write-paths for SQL VIEWs ([#412](https://github.com/nanobpm/nano-workforce/issues/412)) ([#446](https://github.com/nanobpm/nano-workforce/issues/446)) ([d3ac2d9](https://github.com/nanobpm/nano-workforce/commit/d3ac2d91dee8180f83f22bdcb04a167e83b3bde5)), closes [436/#437](https://github.com/nanobpm/nano-workforce/issues/437) [#438](https://github.com/nanobpm/nano-workforce/issues/438) [#436](https://github.com/nanobpm/nano-workforce/issues/436) [#437](https://github.com/nanobpm/nano-workforce/issues/437)
7
+
8
+ # [0.121.0](https://github.com/nanobpm/nano-workforce/compare/v0.120.2...v0.121.0) (2026-08-22)
9
+
10
+
11
+ ### Features
12
+
13
+ * **delivery-graphs:** surface the compile preview in the console (compose → preview → dispatch) ([#445](https://github.com/nanobpm/nano-workforce/issues/445)) ([eb8bc7a](https://github.com/nanobpm/nano-workforce/commit/eb8bc7a609f894ddf83248bf356ab18864f63b62)), closes [#279](https://github.com/nanobpm/nano-workforce/issues/279) [#441](https://github.com/nanobpm/nano-workforce/issues/441)
14
+
1
15
  ## [0.120.2](https://github.com/nanobpm/nano-workforce/compare/v0.120.1...v0.120.2) (2026-08-22)
2
16
 
3
17
 
@@ -1,16 +1,18 @@
1
1
  // Read-model derivation test for the epic delivery signal (issue #171). `deriveDelivery` is the
2
- // single source of truth for the denormalised `plans.delivery` / `plans.delivery_label` columns the
3
- // poller projects. It must cleanly distinguish an epic whose fan-out is `done` but whose slices are
4
- // still CONVERGING from one where every slice PR has LANDED, and count abandoned/converged PRs as
5
- // resolved-not-landed (never `landed`).
2
+ // single source of truth the `plan_delivery` VIEW (061) encodes and the pollers derive at READ TIME
3
+ // (epic #412 retired the stored `plans.delivery` / `plans.delivery_label` columns). It must cleanly
4
+ // distinguish an epic whose fan-out is `done` but whose slices are still CONVERGING from one where
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
8
  import { test } from "node:test";
7
9
  import { assert, assertEquals } from "#test-assert";
8
10
  import type { DataLayer } from "@nanobpm/urban";
9
11
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
10
- import { pollDelivery } from "./service.ts";
12
+ import { pollPlanBucket } from "./service.ts";
11
13
 
12
14
  // A tiny in-memory record gateway (all/find/update/insert), mirroring the fake-app style used
13
- // across the app tests (see app/taskDelta.test.ts), enough to exercise the `pollDelivery` projection.
15
+ // across the app tests (see app/taskDelta.test.ts), enough to exercise the `pollPlanBucket` pass.
14
16
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
15
17
  const stores: Record<string, any[]> = {};
16
18
  function tbl(name: string, pk = "id") {
@@ -106,28 +108,32 @@ test("every non-terminal status counts as in flight", () => {
106
108
  }
107
109
  });
108
110
 
109
- test("pollDelivery: a dangling pr_key (missing PR row) counts as in-flight, never false-landed", async () => {
111
+ test("pollPlanBucket: a still-converging done epic suppresses the Dismiss flag (ack_open=0) and stays Active", async () => {
110
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.
111
115
  stores.plans = [
112
- { plan_key: "epic-1", status: "done", delivery: null, delivery_label: null },
116
+ { plan_key: "epic-1", status: "done", acknowledged_at: null, list_bucket: "active", ack_open: 1 },
113
117
  ];
114
118
  stores.plan_tasks = [
115
119
  { id: 1, plan_key: "epic-1", pr_key: "o/r#1" },
116
- { id: 2, plan_key: "epic-1", pr_key: "o/r#2" }, // no matching pull_requests row (DB desync)
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" },
117
125
  ];
118
- stores.pull_requests = [{ pr_key: "o/r#1", status: "merged" }];
119
126
 
120
- await pollDelivery(data);
127
+ await pollPlanBucket(data);
121
128
 
122
- // Without the dangling PR being treated as in-flight, this would wrongly become `landed` (1/1).
123
- assertEquals(stores.plans[0].delivery, "converging");
124
- assertEquals(stores.plans[0].delivery_label, "1/2 slices merged, 1 converging");
129
+ assertEquals(stores.plans[0].list_bucket, "active");
130
+ assertEquals(stores.plans[0].ack_open, 0, "converging epic must not offer Dismiss");
125
131
  });
126
132
 
127
- test("pollDelivery: all slice PR rows present and merged -> landed", async () => {
133
+ test("pollPlanBucket: a fully landed done epic opens the Dismiss flag (ack_open=1), still Active", async () => {
128
134
  const { data, stores } = memData();
129
135
  stores.plans = [
130
- { plan_key: "epic-2", status: "done", delivery: null, delivery_label: null },
136
+ { plan_key: "epic-2", status: "done", acknowledged_at: null, list_bucket: "active", ack_open: 0 },
131
137
  ];
132
138
  stores.plan_tasks = [
133
139
  { id: 1, plan_key: "epic-2", pr_key: "o/r#10" },
@@ -138,38 +144,65 @@ test("pollDelivery: all slice PR rows present and merged -> landed", async () =>
138
144
  { pr_key: "o/r#11", status: "merged" },
139
145
  ];
140
146
 
141
- await pollDelivery(data);
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);
142
165
 
143
- assertEquals(stores.plans[0].delivery, "landed");
144
- assertEquals(stores.plans[0].delivery_label, "2/2 slices merged");
166
+ // Without the dangling PR counting as in-flight this would wrongly land → open Dismiss.
167
+ assertEquals(stores.plans[0].ack_open, 0);
145
168
  });
146
169
 
147
- test("pollDelivery: a non-done plan is skipped and any stale projection is cleared", async () => {
170
+ test("pollPlanBucket: an acknowledged landed epic buckets to History", async () => {
148
171
  const { data, stores } = memData();
149
172
  stores.plans = [
150
- // Regressed out of `done` while carrying a stale `converging` projection.
151
- { plan_key: "epic-3", status: "in_progress", delivery: "converging", delivery_label: "1/2 slices merged, 1 converging" },
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
+ },
152
180
  ];
153
- // A task join here would be wasted work for a non-done plan; assert it is never consulted.
154
- let taskLookups = 0;
155
- stores.plan_tasks = [{ id: 1, plan_key: "epic-3", pr_key: "o/r#20" }];
181
+ stores.plan_tasks = [{ id: 1, plan_key: "epic-4", pr_key: "o/r#20" }];
156
182
  stores.pull_requests = [{ pr_key: "o/r#20", status: "merged" }];
157
- const origTable = (data as any).table.bind(data);
158
- (data as any).table = (n: string, pk?: string) => {
159
- const t = origTable(n, pk);
160
- if (n === "plan_tasks") {
161
- const origFind = t.find.bind(t);
162
- t.find = async (where: any) => {
163
- taskLookups++;
164
- return origFind(where);
165
- };
166
- }
167
- return t;
168
- };
169
-
170
- await pollDelivery(data);
171
-
172
- assertEquals(stores.plans[0].delivery, null);
173
- assertEquals(stores.plans[0].delivery_label, null);
174
- assertEquals(taskLookups, 0);
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");
175
208
  });
@@ -207,20 +207,17 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
207
207
 
208
208
  const feat = threads.find((t) => t.root_request_key === "o/r#1");
209
209
  assert(feat, "feature thread present");
210
- assertEquals(feat?.kind, "feature");
211
210
  assertEquals(feat?.stage, "converging");
212
211
  assertEquals(feat?.stage_label, "Converging (round 2)");
213
212
  assertEquals(feat?.active, 1);
214
213
  assertEquals(JSON.parse(feat?.pr_keys ?? "[]"), ["o/r#100"]);
215
214
 
216
215
  const epic = threads.find((t) => t.root_request_key === "o/r#2");
217
- assertEquals(epic?.kind, "epic");
218
216
  assertEquals(epic?.stage, "converging");
219
217
  assertEquals(epic?.pr_count, 2);
220
218
  assertEquals(epic?.active, 1);
221
219
 
222
220
  const human = threads.find((t) => t.root_request_key === "o/r#300");
223
- assertEquals(human?.kind, "pr");
224
221
  assertEquals(human?.stage, "merged");
225
222
  assertEquals(human?.active, 0);
226
223
 
@@ -257,7 +254,6 @@ test("pollLineage: a self-rooted PR row (root_request_key === pr_key) projects e
257
254
  const threads: LineageThreadRow[] = stores.lineage_threads;
258
255
  assertEquals(threads.length, 1, "one self-rooted thread");
259
256
  assertEquals(threads[0].root_request_key, "o/r#42", "thread key equals the PR row's root_request_key so the page join drills down");
260
- assertEquals(threads[0].kind, "pr");
261
257
  assertEquals(JSON.parse(threads[0].pr_keys ?? "[]"), ["o/r#42"]);
262
258
  assertEquals(threads[0].pr_count, 1);
263
259
  });
@@ -286,7 +282,6 @@ test("pollLineage: an orphaned non-null root (origin row gone) keys the thread o
286
282
  "o/r#7",
287
283
  "thread key equals the stored root_request_key so the page join drills down, not the pr_key",
288
284
  );
289
- assertEquals(threads[0].kind, "pr");
290
285
  assertEquals(JSON.parse(threads[0].pr_keys ?? "[]").sort(), ["o/r#71", "o/r#72"]);
291
286
  assertEquals(threads[0].pr_count, 2);
292
287
  });
package/app/lineage.ts CHANGED
@@ -311,7 +311,10 @@ interface PrRow {
311
311
 
312
312
  const prRows = (data: DataLayer) => data.table<PrRow>("pull_requests", "pr_key");
313
313
 
314
- /** The denormalised read-table row `pollLineage` projects, one per root. */
314
+ /** The `lineage_thread_view` VIEW row (migration 064) the read shape the Lineage page binds. The
315
+ * view PASSES THROUGH the procedural frontier columns from `lineage_threads` and DERIVES the
316
+ * view-expressible identity columns (`kind`, `issue_url`, and an epic/feature thread's `title`) from
317
+ * the `plans`/`feature_runs` origin joins. */
315
318
  export interface LineageThreadRow {
316
319
  root_request_key: string;
317
320
  kind: string;
@@ -327,8 +330,27 @@ export interface LineageThreadRow {
327
330
  updated_at: string;
328
331
  }
329
332
 
333
+ /** The denormalised BASE `lineage_threads` row `pollLineage` writes. The `kind` / `issue_url` columns
334
+ * were RETIRED (epic #412): the `lineage_thread_view` VIEW (064) DERIVES both from the
335
+ * `plans`/`feature_runs` origin joins, so the poller no longer denormalises them — this write shape
336
+ * is `LineageThreadRow` minus those two view-derived columns. `title` stays: it is a procedural
337
+ * representative-PR pick for a self-rooted PR thread (the view falls back to `lt.title` for `kind`
338
+ * = 'pr'). */
339
+ interface LineageThreadWriteRow {
340
+ root_request_key: string;
341
+ title: string | null;
342
+ stage: string;
343
+ stage_label: string | null;
344
+ process_key: string | null;
345
+ pr_keys: string | null;
346
+ pr_count: number;
347
+ active: number;
348
+ created_at: string;
349
+ updated_at: string;
350
+ }
351
+
330
352
  const lineageThreads = (data: DataLayer) =>
331
- data.table<LineageThreadRow>("lineage_threads", "root_request_key");
353
+ data.table<LineageThreadWriteRow>("lineage_threads", "root_request_key");
332
354
 
333
355
  /** Read-only handle on the `lineage_thread_view` VIEW (migration 064) the Lineage page now binds.
334
356
  * Same shape as `LineageThreadRow`: the view PASSES THROUGH the procedural frontier columns from
@@ -537,9 +559,7 @@ export async function pollLineage(data: DataLayer): Promise<void> {
537
559
  const existing = await table.get(thread.rootRequestKey);
538
560
  if (
539
561
  existing &&
540
- existing.kind === thread.kind &&
541
562
  existing.title === thread.title &&
542
- existing.issue_url === thread.issueUrl &&
543
563
  existing.stage === thread.stage &&
544
564
  existing.stage_label === thread.stageLabel &&
545
565
  existing.process_key === thread.processKey &&
@@ -552,9 +572,7 @@ export async function pollLineage(data: DataLayer): Promise<void> {
552
572
  const ts = now();
553
573
  if (existing) {
554
574
  await table.update(thread.rootRequestKey, {
555
- kind: thread.kind,
556
575
  title: thread.title,
557
- issue_url: thread.issueUrl,
558
576
  stage: thread.stage,
559
577
  stage_label: thread.stageLabel,
560
578
  process_key: thread.processKey,
@@ -566,9 +584,7 @@ export async function pollLineage(data: DataLayer): Promise<void> {
566
584
  } else {
567
585
  await table.insert({
568
586
  root_request_key: thread.rootRequestKey,
569
- kind: thread.kind,
570
587
  title: thread.title,
571
- issue_url: thread.issueUrl,
572
588
  stage: thread.stage,
573
589
  stage_label: thread.stageLabel,
574
590
  process_key: thread.processKey,
@@ -1,16 +1,14 @@
1
- // Read-model derivation + projection test for the merged-per-day throughput chart (issue #344).
1
+ // Read-model derivation test for the merged-per-day throughput chart (issue #344).
2
2
  //
3
- // `deriveMergesPerDay` is the single source of truth behind the denormalised `merges_per_day` table
4
- // the Velocity page reads. It must: count DISTINCT PRs per calendar day (a PR with several `merged`
5
- // audit rows on one day an `already-merged` short-circuit or a retry — counts once); ignore
6
- // `queued`/`blocked` attempts entirely; order days ascending; carry a running burn-up `cumulative`;
7
- // and scale each day's `bar` against the busiest day. `pollMergesPerDay` must project that onto the
8
- // read table idempotently a steady-state re-run writes nothing, and a day dropped from the audit is
9
- // pruned.
3
+ // `deriveMergesPerDay` is the single source of truth the `merges_per_day_view` VIEW (062) mirrors —
4
+ // the worker-maintained `merges_per_day` table + its `pollMergesPerDay` write-path were RETIRED (epic
5
+ // #412). It must: count DISTINCT PRs per calendar day (a PR with several `merged` audit rows on one
6
+ // day — an `already-merged` short-circuit or a retry counts once); ignore `queued`/`blocked`
7
+ // attempts entirely; order days ascending; carry a running burn-up `cumulative`; and scale each day's
8
+ // `bar` against the busiest day.
10
9
  import { test } from "node:test";
11
10
  import { assert, assertEquals } from "#test-assert";
12
- import type { DataLayer } from "@nanobpm/urban";
13
- import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./mergesPerDay.ts";
11
+ import { deriveMergesPerDay, type MergeAuditRow } from "./mergesPerDay.ts";
14
12
 
15
13
  // The bucketing is now LOCAL-calendar-day (issue #361: use the viewer's timezone, not UTC). The
16
14
  // derivation buckets in an explicit IANA `timeZone` argument (via `Intl.DateTimeFormat`), so these
@@ -19,49 +17,6 @@ import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./merg
19
17
  // reorder unrelated date-handling tests. The UTC-based assertions pass `"UTC"`; the
20
18
  // timezone-specific ones pass the zone they exercise.
21
19
 
22
- // A tiny in-memory record gateway (all/find/insert/update/delete), mirroring the fake-app style used
23
- // across the app tests (see app/delivery.test.ts), enough to exercise the `pollMergesPerDay`
24
- // projection.
25
- function memData(): { data: DataLayer; stores: Record<string, any[]>; writes: () => number } {
26
- const stores: Record<string, any[]> = {};
27
- let writes = 0;
28
- function tbl(name: string, pk = "id") {
29
- const rows = (stores[name] ??= [] as any[]);
30
- return {
31
- async all() {
32
- return rows.slice();
33
- },
34
- async get(id: any) {
35
- return rows.find((r) => r[pk] === id);
36
- },
37
- async find(where: any = {}) {
38
- return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
39
- },
40
- async insert(row: any) {
41
- writes++;
42
- rows.push({ ...row });
43
- return row[pk];
44
- },
45
- async update(id: any, patch: any) {
46
- writes++;
47
- const r = rows.find((row) => row[pk] === id);
48
- if (r) Object.assign(r, patch);
49
- return 1;
50
- },
51
- async delete(id: any) {
52
- const i = rows.findIndex((row) => row[pk] === id);
53
- if (i >= 0) {
54
- writes++;
55
- rows.splice(i, 1);
56
- }
57
- return 1;
58
- },
59
- };
60
- }
61
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
62
- return { data, stores, writes: () => writes };
63
- }
64
-
65
20
  const merged = (pr_key: string, at: string): MergeAuditRow => ({ pr_key, outcome: "merged", at });
66
21
 
67
22
  test("counts DISTINCT merged PRs per calendar day", () => {
@@ -216,42 +171,3 @@ test("an invalid IANA timeZone falls back to the host zone instead of throwing (
216
171
  assertEquals(days[0].merged, 1);
217
172
  assert(/^\d{4}-\d{2}-\d{2}$/.test(days[0].day));
218
173
  });
219
-
220
- test("pollMergesPerDay projects the aggregate onto merges_per_day", async () => {
221
- const { data, stores } = memData();
222
- stores.merges = [
223
- { id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" },
224
- { id: 2, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:05:00Z" }, // dup same day
225
- { id: 3, pr_key: "o/r#2", outcome: "merged", at: "2026-01-02T09:00:00Z" },
226
- { id: 4, pr_key: "o/r#3", outcome: "queued", at: "2026-01-02T09:10:00Z" }, // ignored
227
- ];
228
- await pollMergesPerDay(data, "UTC");
229
- const rows = (stores.merges_per_day ?? []).slice().sort((x, y) => x.day.localeCompare(y.day));
230
- assertEquals(rows.map((r) => [r.day, r.merged, r.cumulative]), [
231
- ["2026-01-01", 1, 1],
232
- ["2026-01-02", 1, 2],
233
- ]);
234
- for (const r of rows) assert(typeof r.updated_at === "string" && r.updated_at.length > 0);
235
- });
236
-
237
- test("pollMergesPerDay is idempotent — a steady-state re-run writes nothing", async () => {
238
- const { data, stores, writes } = memData();
239
- stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
240
- await pollMergesPerDay(data, "UTC");
241
- const afterFirst = writes();
242
- assert(afterFirst > 0, "the first pass must project at least one row");
243
- await pollMergesPerDay(data, "UTC");
244
- assertEquals(writes(), afterFirst, "a steady-state re-run must not write");
245
- });
246
-
247
- test("pollMergesPerDay prunes a day that no longer derives from the audit", async () => {
248
- const { data, stores } = memData();
249
- stores.merges_per_day = [
250
- { day: "2025-12-31", merged: 3, cumulative: 3, bar: "███", updated_at: "old" },
251
- ];
252
- stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
253
- await pollMergesPerDay(data, "UTC");
254
- const days = (stores.merges_per_day ?? []).map((r: any) => r.day);
255
- assert(!days.includes("2025-12-31"), "a stale day must be pruned");
256
- assert(days.includes("2026-01-01"), "the derived day must be present");
257
- });
@@ -14,23 +14,18 @@
14
14
  // SELECT date(at, 'localtime') AS day, COUNT(DISTINCT pr_key) AS merged
15
15
  // FROM merges WHERE outcome = 'merged' GROUP BY date(at, 'localtime');
16
16
  //
17
- // Two halves, mirroring the `deriveDelivery`/`pollDelivery` and `deriveLineage`/`pollLineage`
18
- // convention:
17
+ // The `deriveMergesPerDay` PURE function (issue #344), formerly the derive-half of the
18
+ // `deriveMergesPerDay`/`pollMergesPerDay` split. The merges-per-day read model is now a DERIVED SQL
19
+ // VIEW (`merges_per_day_view`, 062) — the worker-maintained `merges_per_day` table and its
20
+ // `pollMergesPerDay` write-path were RETIRED (epic #412). This pure derivation stays: it is the
21
+ // single source of truth the view's SQL mirrors, and is still exercised by its unit tests +
22
+ // the view's read-model guard.
19
23
  // • `deriveMergesPerDay` — a PURE function: merge audit rows → one ordered `MergeDay` per calendar
20
24
  // day (merged count, burn-up cumulative, a proportional bar string). No I/O, fully tested. Counts
21
25
  // `COUNT(DISTINCT pr_key)` — a PR with several `merged` rows on one day (an `already-merged`
22
26
  // short-circuit or retry) counts once — and ignores `queued`/`blocked` rows entirely.
23
- // • `pollMergesPerDay` — the gateway glue: read the `merges` rows and project them onto the
24
- // `merges_per_day` read table (051_merges_per_day.sql) the schema-driven Velocity page binds. A
25
- // denormalised flat table because Urban's datasource cannot read a SQL VIEW (gateway.ts
26
- // `schema()` whitelists `type='table'` only — same reason `lineage_threads`/`plans.delivery` are
27
- // flat tables). Writes only when a day's projection actually changes, so a steady-state pass is a
28
- // no-op.
29
- import type { DataLayer } from "@nanobpm/urban";
30
27
 
31
- const now = () => new Date().toISOString();
32
-
33
- /** The subset of a `merges` audit row (004_merge.sql) the projection reads. */
28
+ /** The subset of a `merges` audit row (004_merge.sql) the derivation reads. */
34
29
  export interface MergeAuditRow {
35
30
  pr_key: string;
36
31
  outcome: string;
@@ -166,51 +161,3 @@ export function deriveMergesPerDay(rows: readonly MergeAuditRow[], timeZone?: st
166
161
  }
167
162
  return out;
168
163
  }
169
-
170
- /** The denormalised read-table row `pollMergesPerDay` projects, one per calendar day. */
171
- interface MergesPerDayRow extends MergeDay {
172
- updated_at: string;
173
- }
174
-
175
- const mergesPerDay = (data: DataLayer) => data.table<MergesPerDayRow>("merges_per_day", "day");
176
- const mergesAudit = (data: DataLayer) => data.table<MergeAuditRow>("merges", "id");
177
-
178
- /** Idempotent read-model pass: recompute merged-per-day from the `merges` audit table and denormalise
179
- * it onto the `merges_per_day` read table the Velocity page reads. Additive/derived only — never
180
- * touches `merges`. Upserts a day only when its projection actually changes (so a steady-state pass is
181
- * a no-op) and prunes any stale day row that no longer derives (defensive — days are append-only in
182
- * practice, but a purge/rewrite of the audit must not leave a phantom). Buckets in `timeZone` (an
183
- * IANA zone) when given; the production caller omits it to use the host's resolved zone. */
184
- export async function pollMergesPerDay(data: DataLayer, timeZone?: string): Promise<void> {
185
- try {
186
- // Only `outcome === "merged"` rows contribute to the aggregate, so filter at the read rather than
187
- // scanning queued/blocked rows as the audit grows (deriveMergesPerDay ignores non-merged rows too).
188
- const audit = await mergesAudit(data).find({ outcome: "merged" });
189
- const want = deriveMergesPerDay(audit, timeZone);
190
- const wantByDay = new Map(want.map((d) => [d.day, d]));
191
-
192
- const existing = await mergesPerDay(data).all();
193
- const existingByDay = new Map(existing.map((r) => [r.day, r]));
194
-
195
- for (const d of want) {
196
- const cur = existingByDay.get(d.day);
197
- if (!cur) {
198
- await mergesPerDay(data).insert({ ...d, updated_at: now() });
199
- } else if (cur.merged !== d.merged || cur.cumulative !== d.cumulative || cur.bar !== d.bar) {
200
- await mergesPerDay(data).update(d.day, {
201
- merged: d.merged,
202
- cumulative: d.cumulative,
203
- bar: d.bar,
204
- updated_at: now(),
205
- });
206
- }
207
- }
208
-
209
- // Prune any projected day that no longer derives from the audit trail.
210
- for (const r of existing) {
211
- if (!wantByDay.has(r.day)) await mergesPerDay(data).delete(r.day);
212
- }
213
- } catch (err) {
214
- console.error(`[poller] merges-per-day: ${err}`);
215
- }
216
- }
package/app/plan.ts CHANGED
@@ -74,18 +74,11 @@ export interface Plan {
74
74
  // Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
75
75
  // waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
76
76
  gate_wave: number | null;
77
- // Operator-visibility wave progress (022_plan_wave_progress.sql, #137): denormalised so the
78
- // epics-index can show wave X/N at a glance. `wave_count` is the total waves (N); `current_wave`
79
- // is the 0-based index of the wave the fleet is actively implementing (advanced by select-wave,
80
- // pinned to wave_count-1 on completion). Display-only never gates control flow. NULL until the
81
- // plan is dispatched with tasks.
82
- wave_count: number | null;
83
- current_wave: number | null;
84
- // Pre-formatted 1-based "X/N" progress string for the epics-index at-a-glance column
85
- // (022_plan_wave_progress.sql, #137). The dataGrid has no per-cell templating (nano-ide#214),
86
- // so this is projected alongside the numeric columns by the same worker writes. NULL until
87
- // dispatched with tasks.
88
- wave_label: string | null;
77
+ // Operator-visibility wave progress is now a DERIVED read model (epic #412): the `wave_count` /
78
+ // `current_wave` / `wave_label` columns (022_plan_wave_progress.sql) were RETIRED the pages read
79
+ // them from the `plan_wave_label` / `plan_read_model` SQL VIEWs (060/061), and `pollWaitGate`
80
+ // derives "has the epic fanned out?" at read time from `plan_tasks`. There is no write-path and no
81
+ // stored column any more, so nothing on `plans` denormalises wave progress.
89
82
  // Per-plan capability token for the coordination blackboard (009_plan_blackboard.sql, #51).
90
83
  // Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
91
84
  // plans created before the blackboard shipped.
@@ -96,13 +89,12 @@ export interface Plan {
96
89
  // admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
97
90
  // carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
98
91
  base_branch: string | null;
99
- // Derived epic delivery signal (029_plan_delivery.sql, #171): separates "fan-out dispatched to
100
- // convergence" (status=done) from "all slice PRs actually merged". Recomputed idempotently by the
101
- // poller's `pollDelivery` pass by joining each plan_tasks.pr_key pull_requests.status — never
102
- // written by the plan lifecycle. `delivery` is 'converging' | 'landed' | NULL (see deriveDelivery
103
- // in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
104
- delivery: string | null;
105
- delivery_label: string | null;
92
+ // The derived epic delivery signal (029_plan_delivery.sql) was RETIRED as a stored column (epic
93
+ // #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
94
+ // `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
95
+ // (app/delivery.ts). The pages read them off the view; the pollers that still need the signal
96
+ // (`pollPlanBucket` for list_bucket/ack_open, `pollPromotion` for `isPromotable`) recompute it at
97
+ // read time via `deriveDelivery`. There is no stored column and no write-path any more.
106
98
  // Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
107
99
  // Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
108
100
  // projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
@@ -199,12 +191,12 @@ export const plans = (data: DataLayer) => {
199
191
  }
200
192
  if (prop === "update") {
201
193
  return async (id: unknown, patch: Partial<Plan>) => {
202
- // Only re-read + reproject when the patch changes a projection input (status / delivery /
194
+ // Only re-read + reproject when the patch changes a projection input (status /
203
195
  // acknowledged_at) or writes a derived column directly. A projection-irrelevant patch (e.g.
204
- // an `updated_at`- or `wave_label`-only write — including the direct `data.table` writes in
196
+ // an `updated_at`-only write — including the direct `data.table` writes in
205
197
  // e.g. `app/retro.ts` that stamp `retro_started_at`) leaves the stored projection correct, so
206
198
  // skip the extra `get` roundtrip and delegate straight. Any bucket-relevant write
207
- // (status/delivery/acknowledged_at or a derived column) MUST go through this gateway to stay
199
+ // (status/acknowledged_at or a derived column) MUST go through this gateway to stay
208
200
  // reprojected.
209
201
  if (!patchAffectsPlanProjection(patch)) return target.update(id, patch);
210
202
  const existing = await target.get(id);
@@ -223,7 +215,7 @@ export const plans = (data: DataLayer) => {
223
215
  /** The `plans` fields the bucket projection READS: a patch touching none of these (and none it
224
216
  * writes) cannot change `list_bucket`/`ack_open`, so the gateway skips the read-back+reproject. Kept
225
217
  * adjacent to {@link projectPlanBucket} so the two stay in lockstep. */
226
- const PLAN_PROJECTION_INPUT_KEYS: readonly (keyof Plan)[] = ["status", "delivery", "acknowledged_at"];
218
+ const PLAN_PROJECTION_INPUT_KEYS: readonly (keyof Plan)[] = ["status", "acknowledged_at"];
227
219
 
228
220
  /** The `plans` fields the bucket projection WRITES. Included in the reproject trigger so a caller who
229
221
  * writes a derived column directly (e.g. `list_bucket`/`ack_open`) can never bypass derivation: the
@@ -241,15 +233,21 @@ function patchAffectsPlanProjection(patch: Partial<Plan>): boolean {
241
233
 
242
234
  /** Compute the write-time bucket projection columns for a merged `plans` row. Centralised so the
243
235
  * gateway is the ONE place `deriveEpicBucket` / `epicIsAcknowledgeable` are applied — the page, SQL,
244
- * pollers and workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
236
+ * pollers and workers never re-derive the mapping (AGENTS.md "derivation over duplication").
237
+ *
238
+ * The `delivery` signal was retired as a stored column (epic #412), so the write-time projection can
239
+ * no longer read it — it derives the bucket with `delivery` treated as UNKNOWN (null). This is
240
+ * provably safe for `list_bucket`: `deriveEpicBucket` returns the same bucket with `delivery=null` as
241
+ * with the real signal for every reachable `(status, acknowledged_at)` state. `ack_open` is only a
242
+ * *candidate* here (any unacknowledged `done` epic); the delivery-aware correction — clearing it
243
+ * while an epic is still `converging` — is applied at read time by `pollPlanBucket` (app/service.ts),
244
+ * which recomputes `delivery` via `deriveDelivery`. */
245
245
  function projectPlanBucket(row: Partial<Plan>): Partial<Plan> {
246
246
  if (!row.status) return {};
247
247
  return {
248
- list_bucket: deriveEpicBucket(row.status, row.delivery ?? null, row.acknowledged_at ?? null),
248
+ list_bucket: deriveEpicBucket(row.status, null, row.acknowledged_at ?? null),
249
249
  ack_open:
250
- epicIsAcknowledgeable(row.status, row.delivery ?? null) && (row.acknowledged_at ?? null) === null
251
- ? 1
252
- : 0,
250
+ epicIsAcknowledgeable(row.status, null) && (row.acknowledged_at ?? null) === null ? 1 : 0,
253
251
  };
254
252
  }
255
253