@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 +14 -0
- package/app/delivery.test.ts +76 -43
- package/app/lineage.test.ts +0 -5
- package/app/lineage.ts +24 -8
- package/app/mergesPerDay.test.ts +8 -92
- package/app/mergesPerDay.ts +7 -60
- package/app/plan.ts +26 -28
- package/app/planGateway.test.ts +25 -19
- package/app/service.ts +88 -45
- package/db/migrations/070_drop_plan_projection_columns.sql +31 -0
- package/db/migrations/071_drop_merges_per_day_table.sql +21 -0
- package/db/migrations/072_drop_lineage_view_backed_columns.sql +23 -0
- package/openapi.yaml +14 -0
- package/operations/acknowledgeEpic.test.ts +65 -16
- package/operations/acknowledgeEpic.ts +6 -2
- package/operations/previewDeliveryGraph.test.ts +9 -0
- package/operations/previewDeliveryGraph.ts +6 -0
- package/package.json +1 -1
- package/pages/delivery-graphs/delivery-graphs.css +273 -0
- package/pages/delivery-graphs/embed.html +32 -0
- package/pages/delivery-graphs/mount.js +365 -0
- package/pages/delivery-graphs/standalone.html +38 -0
- package/pages/delivery-graphs.page.json +7 -31
- package/scripts/pages-contract.test.ts +8 -16
- package/test/delivery-graphs-embed.test.ts +85 -0
- package/workers/record-plan/worker.test.ts +11 -9
- package/workers/record-plan/worker.ts +4 -8
- package/workers/record-wave/worker.test.ts +13 -11
- package/workers/record-wave/worker.ts +8 -12
- package/workers/select-wave/worker.test.ts +16 -14
- package/workers/select-wave/worker.ts +5 -6
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
|
|
package/app/delivery.test.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// resolved-not-landed (never
|
|
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 {
|
|
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 `
|
|
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("
|
|
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",
|
|
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" },
|
|
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
|
|
127
|
+
await pollPlanBucket(data);
|
|
121
128
|
|
|
122
|
-
|
|
123
|
-
assertEquals(stores.plans[0].
|
|
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("
|
|
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",
|
|
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
|
|
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
|
-
|
|
144
|
-
assertEquals(stores.plans[0].
|
|
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("
|
|
170
|
+
test("pollPlanBucket: an acknowledged landed epic buckets to History", async () => {
|
|
148
171
|
const { data, stores } = memData();
|
|
149
172
|
stores.plans = [
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
(data
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
});
|
package/app/lineage.test.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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,
|
package/app/mergesPerDay.test.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
// Read-model derivation
|
|
1
|
+
// Read-model derivation test for the merged-per-day throughput chart (issue #344).
|
|
2
2
|
//
|
|
3
|
-
// `deriveMergesPerDay` is the single source of truth
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
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
|
-
});
|
package/app/mergesPerDay.ts
CHANGED
|
@@ -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
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
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 (
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
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 /
|
|
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`-
|
|
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/
|
|
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", "
|
|
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,
|
|
248
|
+
list_bucket: deriveEpicBucket(row.status, null, row.acknowledged_at ?? null),
|
|
249
249
|
ack_open:
|
|
250
|
-
epicIsAcknowledgeable(row.status,
|
|
251
|
-
? 1
|
|
252
|
-
: 0,
|
|
250
|
+
epicIsAcknowledgeable(row.status, null) && (row.acknowledged_at ?? null) === null ? 1 : 0,
|
|
253
251
|
};
|
|
254
252
|
}
|
|
255
253
|
|