@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.
- package/CHANGELOG.md +14 -0
- package/app/delivery.test.ts +6 -105
- package/app/feature.ts +30 -128
- package/app/featureReadModel.test.ts +210 -0
- 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 +36 -108
- package/app/plansReadModel.test.ts +112 -5
- package/app/service.ts +55 -81
- 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/db/migrations/073_feature_read_model.sql +80 -0
- package/db/migrations/074_plan_read_model_derive_bucket.sql +74 -0
- package/main.ts +5 -4
- package/operations/acknowledgeDone.test.ts +18 -16
- package/operations/acknowledgeDone.ts +10 -8
- package/operations/acknowledgeEpic.test.ts +76 -34
- package/operations/acknowledgeEpic.ts +15 -10
- package/package.json +1 -1
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +1 -1
- package/workers/record-plan/worker.test.ts +11 -9
- package/workers/record-plan/worker.ts +4 -8
- package/workers/record-results/worker.test.ts +7 -4
- 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/app/featureGateway.test.ts +0 -202
- package/app/planGateway.test.ts +0 -106
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
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
14
|
import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
|
|
15
|
-
import { deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
16
15
|
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
17
16
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
18
17
|
import {
|
|
@@ -74,18 +73,11 @@ export interface Plan {
|
|
|
74
73
|
// Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
|
|
75
74
|
// waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
|
|
76
75
|
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;
|
|
76
|
+
// Operator-visibility wave progress is now a DERIVED read model (epic #412): the `wave_count` /
|
|
77
|
+
// `current_wave` / `wave_label` columns (022_plan_wave_progress.sql) were RETIRED — the pages read
|
|
78
|
+
// them from the `plan_wave_label` / `plan_read_model` SQL VIEWs (060/061), and `pollWaitGate`
|
|
79
|
+
// derives "has the epic fanned out?" at read time from `plan_tasks`. There is no write-path and no
|
|
80
|
+
// stored column any more, so nothing on `plans` denormalises wave progress.
|
|
89
81
|
// Per-plan capability token for the coordination blackboard (009_plan_blackboard.sql, #51).
|
|
90
82
|
// Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
|
|
91
83
|
// plans created before the blackboard shipped.
|
|
@@ -96,13 +88,13 @@ export interface Plan {
|
|
|
96
88
|
// admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
|
|
97
89
|
// carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
|
|
98
90
|
base_branch: string | null;
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
// The derived epic delivery signal (029_plan_delivery.sql) was RETIRED as a stored column (epic
|
|
92
|
+
// #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
|
|
93
|
+
// `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
|
|
94
|
+
// (app/delivery.ts). The pages read them off the view; the pollers that still need the signal
|
|
95
|
+
// (`pollPromotion` for `isPromotable`) recompute it at
|
|
96
|
+
// read time via `deriveDelivery`, as does the `plan_read_model` VIEW's bucket derivation (074).
|
|
97
|
+
// 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
|
|
@@ -120,16 +112,18 @@ export interface Plan {
|
|
|
120
112
|
// which has nothing to promote). Display-only; projected by the poller.
|
|
121
113
|
promotion_pr: string | null;
|
|
122
114
|
promotion_state: string | null;
|
|
123
|
-
// Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298).
|
|
124
|
-
// write-time
|
|
125
|
-
// `
|
|
126
|
-
//
|
|
127
|
-
//
|
|
115
|
+
// Active/History partition + operator tick-off (044_plan_list_bucket.sql, #298). RETIRED as a
|
|
116
|
+
// write-time projection (issue #439): `list_bucket`/`ack_open` are now DERIVED by the
|
|
117
|
+
// `plan_read_model` VIEW (074) from `status`, `acknowledged_at`, and the derived `plan_delivery`
|
|
118
|
+
// signal — mirroring the pure `deriveEpicBucket` / `epicIsAcknowledgeable` (app/delivery.ts). The
|
|
119
|
+
// Epics pages bind the VIEW, never these base columns, so a raw-datasource `status` write (the
|
|
120
|
+
// `instanceTracking` reconciler) can no longer leave them stale, and the delivery-aware
|
|
121
|
+
// `pollPlanBucket` correction is retired (the VIEW sees the live signal). The base columns survive
|
|
122
|
+
// (expand/contract — a later migration drops them) but are no longer written or read.
|
|
128
123
|
// • acknowledged_at — NULL until an operator dismisses a resolved `done` epic (acknowledge-epic).
|
|
129
|
-
//
|
|
130
|
-
// •
|
|
131
|
-
//
|
|
132
|
-
// until `backfillPlanBuckets`.
|
|
124
|
+
// Still written; the sole live input the derivation reads off the row.
|
|
125
|
+
// • list_bucket — 'active' | 'history': VESTIGIAL base column; the pages filter the VIEW's.
|
|
126
|
+
// • ack_open — 1 | 0: VESTIGIAL base column; the pages gate Dismiss on the VIEW's.
|
|
133
127
|
acknowledged_at: string | null;
|
|
134
128
|
list_bucket: string | null;
|
|
135
129
|
ack_open: number | null;
|
|
@@ -190,85 +184,19 @@ export const PLAN_TASK_STATUSES = [
|
|
|
190
184
|
] as const;
|
|
191
185
|
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
192
186
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// 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
|
|
208
|
-
// reprojected.
|
|
209
|
-
if (!patchAffectsPlanProjection(patch)) return target.update(id, patch);
|
|
210
|
-
const existing = await target.get(id);
|
|
211
|
-
const merged: Partial<Plan> = { ...existing, ...patch };
|
|
212
|
-
return target.update(id, { ...patch, ...projectPlanBucket(merged) });
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
// Delegate every other method straight through. Bind functions to the real target so the
|
|
216
|
-
// gateway's private class fields resolve — a Proxy `this` would not carry them.
|
|
217
|
-
const value = Reflect.get(target, prop, target);
|
|
218
|
-
return typeof value === "function" ? value.bind(target) : value;
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
/** The `plans` fields the bucket projection READS: a patch touching none of these (and none it
|
|
224
|
-
* writes) cannot change `list_bucket`/`ack_open`, so the gateway skips the read-back+reproject. Kept
|
|
225
|
-
* adjacent to {@link projectPlanBucket} so the two stay in lockstep. */
|
|
226
|
-
const PLAN_PROJECTION_INPUT_KEYS: readonly (keyof Plan)[] = ["status", "delivery", "acknowledged_at"];
|
|
227
|
-
|
|
228
|
-
/** The `plans` fields the bucket projection WRITES. Included in the reproject trigger so a caller who
|
|
229
|
-
* writes a derived column directly (e.g. `list_bucket`/`ack_open`) can never bypass derivation: the
|
|
230
|
-
* gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one. */
|
|
231
|
-
const PLAN_PROJECTION_OUTPUT_KEYS: readonly (keyof Plan)[] = ["list_bucket", "ack_open"];
|
|
232
|
-
|
|
233
|
-
/** True when a patch changes at least one field the bucket projection derives from OR one it writes —
|
|
234
|
-
* i.e. the projection must be recomputed (mirrors feature.ts `patchAffectsProjection`). */
|
|
235
|
-
function patchAffectsPlanProjection(patch: Partial<Plan>): boolean {
|
|
236
|
-
return (
|
|
237
|
-
PLAN_PROJECTION_INPUT_KEYS.some((k) => k in patch) ||
|
|
238
|
-
PLAN_PROJECTION_OUTPUT_KEYS.some((k) => k in patch)
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Compute the write-time bucket projection columns for a merged `plans` row. Centralised so the
|
|
243
|
-
* 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"). */
|
|
245
|
-
function projectPlanBucket(row: Partial<Plan>): Partial<Plan> {
|
|
246
|
-
if (!row.status) return {};
|
|
247
|
-
return {
|
|
248
|
-
list_bucket: deriveEpicBucket(row.status, row.delivery ?? null, row.acknowledged_at ?? null),
|
|
249
|
-
ack_open:
|
|
250
|
-
epicIsAcknowledgeable(row.status, row.delivery ?? null) && (row.acknowledged_at ?? null) === null
|
|
251
|
-
? 1
|
|
252
|
-
: 0,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Re-project every `plans` row through the gateway so rows written before migration 042 (whose
|
|
257
|
-
* `list_bucket`/`ack_open` are NULL) get a correct Active/History bucket. Idempotent and safe to
|
|
258
|
-
* re-run: it re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at
|
|
259
|
-
* boot (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy
|
|
260
|
-
* rows. Returns the count actually stamped. Mirrors `backfillFeatureStages` (app/feature.ts). */
|
|
261
|
-
export async function backfillPlanBuckets(data: DataLayer): Promise<number> {
|
|
262
|
-
const table = plans(data);
|
|
263
|
-
let stamped = 0;
|
|
264
|
-
for (const row of await table.all()) {
|
|
265
|
-
// Only touch rows the projection has never reached — a legacy row whose `list_bucket` is NULL.
|
|
266
|
-
if (row.list_bucket != null) continue;
|
|
267
|
-
await table.update(row.plan_key, projectPlanBucket(row));
|
|
268
|
-
stamped++;
|
|
269
|
-
}
|
|
270
|
-
return stamped;
|
|
271
|
-
}
|
|
187
|
+
/** The `plans` record gateway (keyed on `plan_key`) — a plain record table.
|
|
188
|
+
*
|
|
189
|
+
* The epic-bucket projection (`list_bucket`/`ack_open`) is NO LONGER a write-time projection here
|
|
190
|
+
* (issue #439): it is DERIVED by the `plan_read_model` VIEW (074) from each row's own `status` /
|
|
191
|
+
* `acknowledged_at` and the derived `plan_delivery` signal, mirroring `deriveEpicBucket` /
|
|
192
|
+
* `epicIsAcknowledgeable` (app/delivery.ts). The Epics pages bind the VIEW, never this table's stored
|
|
193
|
+
* derived columns. Removing the write-time projection closes the drift the framework
|
|
194
|
+
* `instanceTracking` reconciler opened (a raw-datasource `{status:"abandoned"}` write on a terminated
|
|
195
|
+
* instance bypassed the projecting gateway and froze the bucket) AND retires the read-time
|
|
196
|
+
* `pollPlanBucket` correction: the VIEW sees the live delivery signal, so a still-converging epic never
|
|
197
|
+
* offers Dismiss without a poller pass. The pure helpers stay the acknowledge-epic guard and the VIEW's
|
|
198
|
+
* test oracle (app/plansReadModel.test.ts). */
|
|
199
|
+
export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
|
|
272
200
|
export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
|
|
273
201
|
|
|
274
202
|
/** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
|
|
@@ -19,7 +19,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
19
19
|
import { test } from "node:test";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { assert, assertEquals } from "#test-assert";
|
|
22
|
-
import { deriveDelivery } from "./delivery.ts";
|
|
22
|
+
import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
23
23
|
|
|
24
24
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
25
25
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
@@ -34,7 +34,7 @@ function viewDb(): DatabaseSync {
|
|
|
34
34
|
plan_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
|
|
35
35
|
status TEXT, task_count INTEGER, process_key TEXT, outcome TEXT, created_at TEXT,
|
|
36
36
|
updated_at TEXT, epic_phase TEXT, base_branch TEXT, wait_gate_label TEXT, bound_artifacts TEXT,
|
|
37
|
-
promotion_pr TEXT, promotion_state TEXT, list_bucket TEXT, ack_open INTEGER);
|
|
37
|
+
promotion_pr TEXT, promotion_state TEXT, acknowledged_at TEXT, list_bucket TEXT, ack_open INTEGER);
|
|
38
38
|
CREATE TABLE plan_tasks (
|
|
39
39
|
id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
|
|
40
40
|
prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
|
|
@@ -44,6 +44,9 @@ function viewDb(): DatabaseSync {
|
|
|
44
44
|
db.exec(MIG("059_plan_wave_summary.sql"));
|
|
45
45
|
db.exec(MIG("060_plan_wave_rollup.sql"));
|
|
46
46
|
db.exec(MIG("061_plan_delivery_rollup.sql"));
|
|
47
|
+
// 074 redefines plan_read_model to DERIVE list_bucket/ack_open from status + acknowledged_at + the
|
|
48
|
+
// derived plan_delivery signal (issue #439), instead of reading the denormalised base columns.
|
|
49
|
+
db.exec(MIG("074_plan_read_model_derive_bucket.sql"));
|
|
47
50
|
return db;
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -66,10 +69,16 @@ const MISSING_PR_STATUS = "missing";
|
|
|
66
69
|
// Insert a plan plus its tasks (and each task's PR, if any). PR keys are derived so the test rows
|
|
67
70
|
// stay terse. Returns the flat `pull_requests.status` list `deriveDelivery` consumes (only tasks
|
|
68
71
|
// that opened a PR), so the delivery assertions can cross-check the view against it.
|
|
69
|
-
function addPlan(
|
|
72
|
+
function addPlan(
|
|
73
|
+
db: DatabaseSync,
|
|
74
|
+
plan_key: string,
|
|
75
|
+
status: string,
|
|
76
|
+
tasks: SampleTask[],
|
|
77
|
+
opts: { acknowledged_at?: string | null } = {},
|
|
78
|
+
): string[] {
|
|
70
79
|
db.prepare(
|
|
71
|
-
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
72
|
-
).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", "active");
|
|
80
|
+
"INSERT INTO plans (plan_key, repo, issue_number, issue_url, status, task_count, updated_at, acknowledged_at, list_bucket) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
81
|
+
).run(plan_key, "o/r", 1, `https://gh/${plan_key}`, status, tasks.length, "2026-01-01T00:00:00Z", opts.acknowledged_at ?? null, "active");
|
|
73
82
|
const prStatuses: string[] = [];
|
|
74
83
|
tasks.forEach((t, i) => {
|
|
75
84
|
const prKey = t.pr || t.danglingPr ? `${plan_key}::pr${i}` : null;
|
|
@@ -107,6 +116,16 @@ function counts(db: DatabaseSync, plan_key: string): { prs_opened: number; prs_m
|
|
|
107
116
|
return { ...r };
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
// The derived Active/History bucket flags the epics pages bind — read straight off `plan_read_model`
|
|
120
|
+
// (074), plus the delivery signal the derivation folds in, so the assertions can cross-check the
|
|
121
|
+
// VIEW against the pure `deriveEpicBucket` / `epicIsAcknowledgeable` oracles.
|
|
122
|
+
function bucket(db: DatabaseSync, plan_key: string): { list_bucket: unknown; ack_open: unknown; delivery: string | null } {
|
|
123
|
+
const r = db
|
|
124
|
+
.prepare("SELECT list_bucket, ack_open, delivery FROM plan_read_model WHERE plan_key = ?")
|
|
125
|
+
.get(plan_key) as { list_bucket: unknown; ack_open: unknown; delivery: string | null };
|
|
126
|
+
return { ...r };
|
|
127
|
+
}
|
|
128
|
+
|
|
110
129
|
function waveLabel(db: DatabaseSync, plan_key: string): Record<string, unknown> | undefined {
|
|
111
130
|
const r = db.prepare("SELECT wave_count, current_wave, wave_label FROM plan_wave_label WHERE plan_key = ?").get(plan_key) as
|
|
112
131
|
| Record<string, unknown>
|
|
@@ -259,4 +278,92 @@ test("the operator pages read the derived plan_read_model VIEW for the wave/deli
|
|
|
259
278
|
assertEquals(byId("epic-plan").props.data.table, "plan_read_model");
|
|
260
279
|
assert(/\{\{\s*wave_label\s*\}\}/.test(byId("wave-banner").props.header), "the banner surfaces wave_label");
|
|
261
280
|
assertEquals(byId("wave-banner").props.body, "delivery_label", "the banner body is the delivery_label");
|
|
281
|
+
|
|
282
|
+
// Epic INDEX page (epic.page.json) — the standalone Epics grid must also read the derived VIEW, not
|
|
283
|
+
// the raw `plans` table. `plans` stays a valid schema table, so a regression here (reverting the
|
|
284
|
+
// binding) would leave every OTHER test green while the index silently resumed reading stale
|
|
285
|
+
// list_bucket/ack_open; this pins it (suppressed advisory epic.page.json — issue #439).
|
|
286
|
+
const epicIndex = PAGE("epic.page.json");
|
|
287
|
+
const epicPlans = (epicIndex.nodes ?? []).find((n: { id: string }) => n.id === "epic-plans");
|
|
288
|
+
assert(epicPlans, "epic index must keep the Epics grid");
|
|
289
|
+
assertEquals(epicPlans.props.data.table, "plan_read_model");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("plan_read_model DERIVES list_bucket / ack_open from status + delivery + acknowledged_at, matching deriveEpicBucket / epicIsAcknowledgeable (issue #439)", () => {
|
|
293
|
+
const db = viewDb();
|
|
294
|
+
// done, still converging (a slice in flight): Active, Dismiss suppressed — never ticked off mid-flight.
|
|
295
|
+
const converging = addPlan(db, "o/r#conv", "done", [
|
|
296
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
297
|
+
{ status: "opened", wave: 1, pr: { status: "converging" } },
|
|
298
|
+
]);
|
|
299
|
+
// done, fully landed, unacknowledged: Active with Dismiss OPEN (ack_open=1).
|
|
300
|
+
const landed = addPlan(db, "o/r#land", "done", [
|
|
301
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
302
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
303
|
+
]);
|
|
304
|
+
// done, landed AND acknowledged: History, Dismiss closed.
|
|
305
|
+
const acked = addPlan(
|
|
306
|
+
db,
|
|
307
|
+
"o/r#ack",
|
|
308
|
+
"done",
|
|
309
|
+
[{ status: "opened", wave: 0, pr: { status: "merged" } }],
|
|
310
|
+
{ acknowledged_at: "2026-02-02T00:00:00Z" },
|
|
311
|
+
);
|
|
312
|
+
// resolved-not-landed (one abandoned), unacknowledged: delivery null → acknowledgeable, Active + Dismiss.
|
|
313
|
+
const resolved = addPlan(db, "o/r#res", "done", [
|
|
314
|
+
{ status: "opened", wave: 0, pr: { status: "merged" } },
|
|
315
|
+
{ status: "opened", wave: 0, pr: { status: "abandoned" } },
|
|
316
|
+
]);
|
|
317
|
+
// live (dispatched) epic: Active, not acknowledgeable.
|
|
318
|
+
const live = addPlan(db, "o/r#live", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
|
|
319
|
+
|
|
320
|
+
for (const [plan_key, status, prStatuses, ackAt] of [
|
|
321
|
+
["o/r#conv", "done", converging, null],
|
|
322
|
+
["o/r#land", "done", landed, null],
|
|
323
|
+
["o/r#ack", "done", acked, "2026-02-02T00:00:00Z"],
|
|
324
|
+
["o/r#res", "done", resolved, null],
|
|
325
|
+
["o/r#live", "dispatched", live, null],
|
|
326
|
+
] as const) {
|
|
327
|
+
const b = bucket(db, plan_key);
|
|
328
|
+
const expectedDelivery = deriveDelivery(status, prStatuses).delivery;
|
|
329
|
+
assertEquals(b.delivery, expectedDelivery, `${plan_key}: delivery`);
|
|
330
|
+
// Cross-check the VIEW against the pure helpers — the SAME oracle the acknowledge-epic op guards on.
|
|
331
|
+
assertEquals(
|
|
332
|
+
b.list_bucket,
|
|
333
|
+
deriveEpicBucket(status, expectedDelivery, ackAt),
|
|
334
|
+
`${plan_key}: list_bucket must equal deriveEpicBucket`,
|
|
335
|
+
);
|
|
336
|
+
const expectedAckOpen = epicIsAcknowledgeable(status, expectedDelivery) && ackAt === null ? 1 : 0;
|
|
337
|
+
assertEquals(b.ack_open, expectedAckOpen, `${plan_key}: ack_open must equal epicIsAcknowledgeable`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Pin the human-visible outcomes so a derivation drift can't hide behind the cross-check.
|
|
341
|
+
assertEquals(bucket(db, "o/r#conv"), { list_bucket: "active", ack_open: 0, delivery: "converging" });
|
|
342
|
+
assertEquals(bucket(db, "o/r#land"), { list_bucket: "active", ack_open: 1, delivery: "landed" });
|
|
343
|
+
assertEquals(bucket(db, "o/r#ack"), { list_bucket: "history", ack_open: 0, delivery: "landed" });
|
|
344
|
+
assertEquals(bucket(db, "o/r#res"), { list_bucket: "active", ack_open: 1, delivery: null });
|
|
345
|
+
assertEquals(bucket(db, "o/r#live"), { list_bucket: "active", ack_open: 0, delivery: null });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("RED/GREEN GUARD: a RAW-datasource plans.status write (the instanceTracking reconciler bypass) leaves plan_read_model's bucket CONSISTENT", () => {
|
|
349
|
+
// Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated process
|
|
350
|
+
// instance it writes `{status:"abandoned"}` to `plans` through the RAW datasource — bypassing the
|
|
351
|
+
// (now retired) projecting `plans` gateway. Under the OLD write-time projection the stored
|
|
352
|
+
// `list_bucket`/`ack_open` would freeze at their pre-terminal values; because they are now a VIEW
|
|
353
|
+
// over `status`, the read model stays correct with no write-path for any writer to leave stale.
|
|
354
|
+
const db = viewDb();
|
|
355
|
+
// A live epic mid-flight — Active, its (stale) stored projection says active/converging.
|
|
356
|
+
addPlan(db, "o/r#kill", "dispatched", [{ status: "opened", wave: 0, pr: { status: "converging" } }]);
|
|
357
|
+
assertEquals(bucket(db, "o/r#kill").list_bucket, "active");
|
|
358
|
+
|
|
359
|
+
// The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
|
|
360
|
+
// UPDATE, exactly what the raw datasource emits.) It touches neither list_bucket nor ack_open.
|
|
361
|
+
db.prepare("UPDATE plans SET status = 'abandoned' WHERE plan_key = ?").run("o/r#kill");
|
|
362
|
+
|
|
363
|
+
// `abandoned` is a terminal non-`done` status: History, never acknowledgeable — matches the oracle.
|
|
364
|
+
const b = bucket(db, "o/r#kill");
|
|
365
|
+
assertEquals(b.list_bucket, deriveEpicBucket("abandoned", b.delivery, null), "list_bucket tracks status via the VIEW");
|
|
366
|
+
assertEquals(b.list_bucket, "history", "an abandoned epic is filed under History, not wedged in Active");
|
|
367
|
+
assertEquals(b.ack_open, epicIsAcknowledgeable("abandoned", b.delivery) ? 1 : 0);
|
|
368
|
+
assertEquals(b.ack_open, 0, "no phantom Dismiss on a reconciler-cancelled epic");
|
|
262
369
|
});
|