@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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.123.0](https://github.com/nanobpm/nano-workforce/compare/v0.122.0...v0.123.0) (2026-08-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* derive feature/epic display projections as SQL VIEWs ([#439](https://github.com/nanobpm/nano-workforce/issues/439)) ([#448](https://github.com/nanobpm/nano-workforce/issues/448)) ([5c79718](https://github.com/nanobpm/nano-workforce/commit/5c797184e65250afea1224bb6227d4cb7687ce8b)), closes [#412](https://github.com/nanobpm/nano-workforce/issues/412) [#412](https://github.com/nanobpm/nano-workforce/issues/412) [#412](https://github.com/nanobpm/nano-workforce/issues/412)
|
|
7
|
+
|
|
8
|
+
# [0.122.0](https://github.com/nanobpm/nano-workforce/compare/v0.121.0...v0.122.0) (2026-08-22)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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)
|
|
14
|
+
|
|
1
15
|
# [0.121.0](https://github.com/nanobpm/nano-workforce/compare/v0.120.2...v0.121.0) (2026-08-22)
|
|
2
16
|
|
|
3
17
|
|
package/app/delivery.test.ts
CHANGED
|
@@ -1,44 +1,13 @@
|
|
|
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`). The delivery-aware `list_bucket`/`ack_open` bucket derivation now lives in the
|
|
7
|
+
// `plan_read_model` VIEW (074), cross-checked against the pure helpers in app/plansReadModel.test.ts.
|
|
6
8
|
import { test } from "node:test";
|
|
7
9
|
import { assert, assertEquals } from "#test-assert";
|
|
8
|
-
import type { DataLayer } from "@nanobpm/urban";
|
|
9
10
|
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
10
|
-
import { pollDelivery } from "./service.ts";
|
|
11
|
-
|
|
12
|
-
// 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.
|
|
14
|
-
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
15
|
-
const stores: Record<string, any[]> = {};
|
|
16
|
-
function tbl(name: string, pk = "id") {
|
|
17
|
-
const rows = (stores[name] ??= [] as any[]);
|
|
18
|
-
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
19
|
-
return {
|
|
20
|
-
async all() {
|
|
21
|
-
return rows.slice();
|
|
22
|
-
},
|
|
23
|
-
async get(id: any) {
|
|
24
|
-
return rows.find((r) => r[pk] === id);
|
|
25
|
-
},
|
|
26
|
-
async find(where: any = {}) {
|
|
27
|
-
return rows.filter((r) => match(r, where));
|
|
28
|
-
},
|
|
29
|
-
async insert(row: any) {
|
|
30
|
-
rows.push({ ...row });
|
|
31
|
-
return row[pk];
|
|
32
|
-
},
|
|
33
|
-
async update(id: any, patch: any) {
|
|
34
|
-
const r = rows.find((row) => row[pk] === id);
|
|
35
|
-
if (r) Object.assign(r, patch);
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
40
|
-
return { data, stores };
|
|
41
|
-
}
|
|
42
11
|
|
|
43
12
|
test("all slice PRs merged -> landed", () => {
|
|
44
13
|
const r = deriveDelivery("done", ["merged", "merged", "merged"]);
|
|
@@ -105,71 +74,3 @@ test("every non-terminal status counts as in flight", () => {
|
|
|
105
74
|
assertEquals(r.prsInFlight, 1, `status ${s}`);
|
|
106
75
|
}
|
|
107
76
|
});
|
|
108
|
-
|
|
109
|
-
test("pollDelivery: a dangling pr_key (missing PR row) counts as in-flight, never false-landed", async () => {
|
|
110
|
-
const { data, stores } = memData();
|
|
111
|
-
stores.plans = [
|
|
112
|
-
{ plan_key: "epic-1", status: "done", delivery: null, delivery_label: null },
|
|
113
|
-
];
|
|
114
|
-
stores.plan_tasks = [
|
|
115
|
-
{ 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)
|
|
117
|
-
];
|
|
118
|
-
stores.pull_requests = [{ pr_key: "o/r#1", status: "merged" }];
|
|
119
|
-
|
|
120
|
-
await pollDelivery(data);
|
|
121
|
-
|
|
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");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("pollDelivery: all slice PR rows present and merged -> landed", async () => {
|
|
128
|
-
const { data, stores } = memData();
|
|
129
|
-
stores.plans = [
|
|
130
|
-
{ plan_key: "epic-2", status: "done", delivery: null, delivery_label: null },
|
|
131
|
-
];
|
|
132
|
-
stores.plan_tasks = [
|
|
133
|
-
{ id: 1, plan_key: "epic-2", pr_key: "o/r#10" },
|
|
134
|
-
{ id: 2, plan_key: "epic-2", pr_key: "o/r#11" },
|
|
135
|
-
];
|
|
136
|
-
stores.pull_requests = [
|
|
137
|
-
{ pr_key: "o/r#10", status: "merged" },
|
|
138
|
-
{ pr_key: "o/r#11", status: "merged" },
|
|
139
|
-
];
|
|
140
|
-
|
|
141
|
-
await pollDelivery(data);
|
|
142
|
-
|
|
143
|
-
assertEquals(stores.plans[0].delivery, "landed");
|
|
144
|
-
assertEquals(stores.plans[0].delivery_label, "2/2 slices merged");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("pollDelivery: a non-done plan is skipped and any stale projection is cleared", async () => {
|
|
148
|
-
const { data, stores } = memData();
|
|
149
|
-
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" },
|
|
152
|
-
];
|
|
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" }];
|
|
156
|
-
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);
|
|
175
|
-
});
|
package/app/feature.ts
CHANGED
|
@@ -18,7 +18,6 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
|
18
18
|
import { coalesceTitle, fetchIssueTitle } from "./github.ts";
|
|
19
19
|
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
20
20
|
import type { ReadinessProbe } from "./readiness.ts";
|
|
21
|
-
import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
22
21
|
|
|
23
22
|
/** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
|
|
24
23
|
* probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
|
|
@@ -68,21 +67,26 @@ export interface FeatureRun {
|
|
|
68
67
|
/** Timestamp an operator dismissed a TERMINAL run (§5, `acknowledge-done`); NULL until then. When
|
|
69
68
|
* set on a terminal row, `list_bucket` flips from 'active' to 'history'. Projection surface. */
|
|
70
69
|
acknowledged_at: string | null;
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
70
|
+
/** RETIRED as a write-time projection (issue #439): the pipeline `stage` is now DERIVED by the
|
|
71
|
+
* `feature_read_model` VIEW (073) from `status`/`pr_key`/`converge`/`auto_merge`, mirroring
|
|
72
|
+
* `deriveStage` (app/stage.ts). The Feature page binds the VIEW's `stage`, never this base column,
|
|
73
|
+
* so a raw-datasource `status` write (the `instanceTracking` reconciler) can no longer leave it
|
|
74
|
+
* stale. The base column survives (expand/contract — a later migration drops it) but is no longer
|
|
75
|
+
* written or read; NULL on rows written after the projection was removed. */
|
|
74
76
|
stage: string | null;
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
+
/** RETIRED as a write-time projection (issue #439): `stage_state` is DERIVED by `feature_read_model`
|
|
78
|
+
* (073). See `stage`. The page binds the VIEW's `stateField`; this base column is vestigial. */
|
|
77
79
|
stage_state: string | null;
|
|
78
|
-
/**
|
|
79
|
-
* (
|
|
80
|
+
/** RETIRED as a write-time projection (issue #439): `stage_skipped` is DERIVED by `feature_read_model`
|
|
81
|
+
* (073). See `stage`. The page binds the VIEW's `notInPathField`; this base column is vestigial. */
|
|
80
82
|
stage_skipped: string | null;
|
|
81
|
-
/**
|
|
82
|
-
* `
|
|
83
|
+
/** RETIRED as a write-time projection (issue #439): `attention` is DERIVED by `feature_read_model`
|
|
84
|
+
* (073). See `stage`. The page binds the VIEW's `badgeField`; this base column is vestigial. */
|
|
83
85
|
attention: string | null;
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
+
/** RETIRED as a write-time projection (issue #439): the Active/History `list_bucket` ('active'|
|
|
87
|
+
* 'history', 'history' iff a terminal row is acknowledged) is DERIVED by `feature_read_model` (073)
|
|
88
|
+
* from `status`/`acknowledged_at`, mirroring `deriveListBucket`. The page's tabs filter the VIEW's
|
|
89
|
+
* `list_bucket`; this base column is vestigial. */
|
|
86
90
|
list_bucket: string | null;
|
|
87
91
|
created_at: string;
|
|
88
92
|
updated_at: string;
|
|
@@ -214,122 +218,20 @@ export async function recordFeatureEscalation(
|
|
|
214
218
|
* Tasks inbox. */
|
|
215
219
|
export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
|
|
216
220
|
|
|
217
|
-
/** The feature_runs
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
* gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one, keeping
|
|
232
|
-
* "the gateway is the one projection source" a true invariant. Must mirror `projectFeatureRun`'s keys. */
|
|
233
|
-
const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
|
|
234
|
-
"stage",
|
|
235
|
-
"stage_state",
|
|
236
|
-
"stage_skipped",
|
|
237
|
-
"attention",
|
|
238
|
-
"list_bucket",
|
|
239
|
-
];
|
|
240
|
-
|
|
241
|
-
/** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
|
|
242
|
-
* projection must be recomputed. A patch touching only projection-irrelevant fields (e.g. `updated_at`)
|
|
243
|
-
* leaves the stored projection correct, since the gateway is the sole write path (see `featureRuns`); a
|
|
244
|
-
* patch that writes a derived column directly still forces a reproject so derivation can't be bypassed. */
|
|
245
|
-
function patchAffectsProjection(patch: Partial<FeatureRun>): boolean {
|
|
246
|
-
return PROJECTION_INPUT_KEYS.some((k) => k in patch) || PROJECTION_OUTPUT_KEYS.some((k) => k in patch);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Compute the write-time projection columns for a fully-merged feature_runs row. Centralised so the
|
|
250
|
-
* gateway is the ONE place `deriveStage` / `deriveListBucket` are applied — the page, SQL, pollers and
|
|
251
|
-
* workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
|
|
252
|
-
function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
|
|
253
|
-
if (!row.status) return {};
|
|
254
|
-
const { stage, state, skipped, attention } = deriveStage({
|
|
255
|
-
status: row.status,
|
|
256
|
-
pr_key: row.pr_key ?? null,
|
|
257
|
-
converge: row.converge ?? null,
|
|
258
|
-
auto_merge: row.auto_merge ?? null,
|
|
259
|
-
});
|
|
260
|
-
return {
|
|
261
|
-
stage,
|
|
262
|
-
stage_state: state,
|
|
263
|
-
stage_skipped: skipped,
|
|
264
|
-
attention,
|
|
265
|
-
list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** The feature_runs record gateway (keyed on `feature_key`). Wrapped in a thin projecting proxy so the
|
|
270
|
-
* derived pipeline columns (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) CANNOT be
|
|
271
|
-
* missed by any writer: `insert` and `update` merge the incoming values over the current stored row,
|
|
272
|
-
* then recompute the projection from that post-write field set and write it alongside — exactly the
|
|
273
|
-
* `delivery_label`-style write-time projection, but hoisted to the single gateway so the many scattered
|
|
274
|
-
* status writers (startFeature, the service pollers/reconcilers, the acknowledge operations, and the
|
|
275
|
-
* feature workers) all stay UNCHANGED and automatically get a correct, fresh projection. `update`
|
|
276
|
-
* skips the read-back+reproject for a patch that touches no projection input (e.g. an `updated_at`-only
|
|
277
|
-
* poller write), avoiding a needless `get` roundtrip — the stored projection is already correct since
|
|
278
|
-
* this gateway is the sole write path. Every other method delegates straight through. This is the sole
|
|
279
|
-
* runtime/app-layer WRITE path to feature_runs (no app-code raw SQL, no other `data.table("feature_runs")`
|
|
280
|
-
* mutation — read-only direct reads in e2e tests, and forward-only data migrations such as
|
|
281
|
-
* `db/migrations/036_backfill_titles.sql`, notwithstanding), so
|
|
282
|
-
* `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` are
|
|
283
|
-
* always populated and correct for every row and every transition. */
|
|
284
|
-
export const featureRuns = (data: DataLayer) => {
|
|
285
|
-
const table = data.table<FeatureRun>("feature_runs", "feature_key");
|
|
286
|
-
return new Proxy(table, {
|
|
287
|
-
get(target, prop) {
|
|
288
|
-
if (prop === "insert") {
|
|
289
|
-
return (row: Partial<FeatureRun>) => target.insert({ ...row, ...projectFeatureRun(row) });
|
|
290
|
-
}
|
|
291
|
-
if (prop === "update") {
|
|
292
|
-
return async (id: unknown, patch: Partial<FeatureRun>) => {
|
|
293
|
-
// Only re-read + reproject when the patch changes a projection input. A projection-irrelevant
|
|
294
|
-
// patch (e.g. an `updated_at`-only poller write) leaves the stored projection correct — the
|
|
295
|
-
// gateway is the sole write path — so skip the extra `get` roundtrip and delegate straight.
|
|
296
|
-
if (!patchAffectsProjection(patch)) return target.update(id, patch);
|
|
297
|
-
const existing = await target.get(id);
|
|
298
|
-
const merged: Partial<FeatureRun> = { ...existing, ...patch };
|
|
299
|
-
return target.update(id, { ...patch, ...projectFeatureRun(merged) });
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
// Delegate every other method straight through. Bind functions to the real target so the
|
|
303
|
-
// gateway's private class fields (`#src`) resolve — a Proxy `this` would not carry them.
|
|
304
|
-
const value = Reflect.get(target, prop, target);
|
|
305
|
-
return typeof value === "function" ? value.bind(target) : value;
|
|
306
|
-
},
|
|
307
|
-
});
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
/** Re-project every feature_runs row through the gateway so rows missing any projection column get
|
|
311
|
-
* correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` values. Catches rows written
|
|
312
|
-
* before migration 039 (the pipeline columns). Idempotent and safe to re-run: it re-derives from each
|
|
313
|
-
* row's own stored fields, so a second pass is a no-op. Runs once at boot (pollOnce) — the gateway keeps
|
|
314
|
-
* every future write fresh, so this only needs to catch legacy rows. */
|
|
315
|
-
export async function backfillFeatureStages(data: DataLayer): Promise<number> {
|
|
316
|
-
const table = featureRuns(data);
|
|
317
|
-
const rows = await table.all();
|
|
318
|
-
let stamped = 0;
|
|
319
|
-
for (const row of rows) {
|
|
320
|
-
// Only touch rows the projection has never reached — a legacy row whose `stage` (pre-039) column is
|
|
321
|
-
// still NULL. The gateway keeps every write fresh, so a fully-projected row needs no re-write;
|
|
322
|
-
// skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
|
|
323
|
-
// rows actually backfilled (not the total row count).
|
|
324
|
-
if (row.stage != null) continue;
|
|
325
|
-
// Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
|
|
326
|
-
// would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
|
|
327
|
-
// so backfill projects explicitly rather than relying on an empty-patch reproject.)
|
|
328
|
-
await table.update(row.feature_key, projectFeatureRun(row));
|
|
329
|
-
stamped++;
|
|
330
|
-
}
|
|
331
|
-
return stamped;
|
|
332
|
-
}
|
|
221
|
+
/** The feature_runs record gateway (keyed on `feature_key`) — a plain record table.
|
|
222
|
+
*
|
|
223
|
+
* The pipeline projection (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) is NO
|
|
224
|
+
* LONGER a write-time projection here (issue #439): it is a DERIVED SQL VIEW, `feature_read_model`
|
|
225
|
+
* (073), computed from each row's own `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at`. The
|
|
226
|
+
* Feature page binds the VIEW, never this table's stored derived columns. Removing the write-time
|
|
227
|
+
* projection closes the drift the framework `instanceTracking` reconciler opened: it writes
|
|
228
|
+
* `feature_runs.status` through the RAW datasource (`{status:"abandoned"}` on a terminated instance),
|
|
229
|
+
* bypassing the old projecting gateway and leaving the display columns frozen — now there is no stored
|
|
230
|
+
* derived column and no write-path for any writer to leave stale. `deriveStage`/`deriveListBucket`
|
|
231
|
+
* (app/stage.ts) remain the canonical implementation the VIEW mirrors and the acknowledge operations
|
|
232
|
+
* reuse; app/featureReadModel.test.ts pins the VIEW to them (including a raw-datasource `status` write
|
|
233
|
+
* that reproduces the reconciler bypass). */
|
|
234
|
+
export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
|
|
333
235
|
|
|
334
236
|
/** The deterministic task id for a single-issue run — the implementation agent branches
|
|
335
237
|
* `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Read-model VIEW coverage for the feature-run pipeline projection (issue #439 — the status-driven
|
|
2
|
+
// follow-up to epic #412, "Retire worker-maintained denormalized projections in favour of SQL
|
|
3
|
+
// VIEWs").
|
|
4
|
+
//
|
|
5
|
+
// 039_feature_pipeline_stage.sql denormalised the pipeline projection
|
|
6
|
+
// (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) onto the `feature_runs` row,
|
|
7
|
+
// written by the `featureRuns()` gateway (app/feature.ts) at WRITE TIME from the pure `deriveStage` /
|
|
8
|
+
// `deriveListBucket` (app/stage.ts). That "the gateway is the sole write path" invariant did NOT hold
|
|
9
|
+
// for the framework `instanceTracking` reconciler, which writes `feature_runs.status` through the RAW
|
|
10
|
+
// datasource on a terminated instance — bypassing the gateway and freezing the display columns.
|
|
11
|
+
// 073_feature_read_model.sql retires the write-time projection: the derived columns are now a VIEW
|
|
12
|
+
// over each row's own `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at`, so there is no
|
|
13
|
+
// stored column and no write-path for any writer to leave stale.
|
|
14
|
+
//
|
|
15
|
+
// This exercises the REAL SQLite view (073 applied to an in-memory DB, mirroring
|
|
16
|
+
// app/plansReadModel.test.ts / app/mergesPerDayView.test.ts) and pins that its CASE expressions
|
|
17
|
+
// reproduce `deriveStage` / `deriveListBucket` EXACTLY over the full status matrix — the SAME pure
|
|
18
|
+
// helpers the acknowledge operations guard on — plus a RED/GREEN guard reproducing the reconciler
|
|
19
|
+
// bypass: a RAW-datasource `status` write must leave the projection correct.
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { test } from "node:test";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { assert, assertEquals } from "#test-assert";
|
|
25
|
+
import { FEATURE_RUN_STATUSES } from "./feature.ts";
|
|
26
|
+
import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
27
|
+
|
|
28
|
+
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
29
|
+
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
30
|
+
|
|
31
|
+
// The base `feature_runs` shape the view reads (028 + 030's delivery_label + 035's title + 039's
|
|
32
|
+
// acknowledged_at & the now-vestigial stored stage/… columns), plus migration 073. The stored derived
|
|
33
|
+
// columns are present precisely so the tests can seed STALE values and prove the VIEW ignores them.
|
|
34
|
+
function viewDb(): DatabaseSync {
|
|
35
|
+
const db = new DatabaseSync(":memory:");
|
|
36
|
+
db.exec(
|
|
37
|
+
`CREATE TABLE feature_runs (
|
|
38
|
+
feature_key TEXT PRIMARY KEY, repo TEXT, issue_number INTEGER, issue_url TEXT, title TEXT,
|
|
39
|
+
base_branch TEXT, status TEXT, process_key TEXT, pr_key TEXT, converge INTEGER, auto_merge INTEGER,
|
|
40
|
+
outcome TEXT, delivery_label TEXT, acknowledged_at TEXT, created_at TEXT, updated_at TEXT,
|
|
41
|
+
stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT);`,
|
|
42
|
+
);
|
|
43
|
+
db.exec(MIG("073_feature_read_model.sql"));
|
|
44
|
+
return db;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SampleRun {
|
|
48
|
+
status: string;
|
|
49
|
+
pr_key?: string | null;
|
|
50
|
+
converge?: number;
|
|
51
|
+
auto_merge?: number;
|
|
52
|
+
acknowledged_at?: string | null;
|
|
53
|
+
// Deliberately-stale STORED projection columns (simulating a row the gateway last projected while
|
|
54
|
+
// in a different status). The VIEW must ignore these and re-derive from `status`.
|
|
55
|
+
stored?: Partial<Record<"stage" | "stage_state" | "stage_skipped" | "attention" | "list_bucket", string>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function addRun(db: DatabaseSync, feature_key: string, run: SampleRun): void {
|
|
59
|
+
const s = run.stored ?? {};
|
|
60
|
+
db.prepare(
|
|
61
|
+
`INSERT INTO feature_runs
|
|
62
|
+
(feature_key, repo, issue_number, issue_url, title, base_branch, status, pr_key, converge,
|
|
63
|
+
auto_merge, acknowledged_at, created_at, updated_at,
|
|
64
|
+
stage, stage_state, stage_skipped, attention, list_bucket)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
66
|
+
).run(
|
|
67
|
+
feature_key,
|
|
68
|
+
"o/r",
|
|
69
|
+
1,
|
|
70
|
+
`https://gh/${feature_key}`,
|
|
71
|
+
`Feature ${feature_key}`,
|
|
72
|
+
"main",
|
|
73
|
+
run.status,
|
|
74
|
+
run.pr_key ?? null,
|
|
75
|
+
run.converge ?? 0,
|
|
76
|
+
run.auto_merge ?? 0,
|
|
77
|
+
run.acknowledged_at ?? null,
|
|
78
|
+
"2026-01-01T00:00:00Z",
|
|
79
|
+
"2026-01-01T00:00:00Z",
|
|
80
|
+
s.stage ?? null,
|
|
81
|
+
s.stage_state ?? null,
|
|
82
|
+
s.stage_skipped ?? null,
|
|
83
|
+
s.attention ?? null,
|
|
84
|
+
s.list_bucket ?? null,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function projection(db: DatabaseSync, feature_key: string): Record<string, unknown> {
|
|
89
|
+
const r = db
|
|
90
|
+
.prepare(
|
|
91
|
+
"SELECT stage, stage_state, stage_skipped, attention, list_bucket FROM feature_read_model WHERE feature_key = ?",
|
|
92
|
+
)
|
|
93
|
+
.get(feature_key) as Record<string, unknown>;
|
|
94
|
+
return { ...r };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key combination", () => {
|
|
98
|
+
const db = viewDb();
|
|
99
|
+
const cases: Array<{ key: string; run: SampleRun }> = [];
|
|
100
|
+
let i = 0;
|
|
101
|
+
for (const status of FEATURE_RUN_STATUSES) {
|
|
102
|
+
for (const converge of [0, 1]) {
|
|
103
|
+
for (const auto_merge of [0, 1]) {
|
|
104
|
+
for (const pr_key of [null, `o/r#pr${i}`]) {
|
|
105
|
+
const key = `o/r#${i++}`;
|
|
106
|
+
cases.push({ key, run: { status, converge, auto_merge, pr_key } });
|
|
107
|
+
addRun(db, key, { status, converge, auto_merge, pr_key });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const { key, run } of cases) {
|
|
114
|
+
const oracle = deriveStage({
|
|
115
|
+
status: run.status,
|
|
116
|
+
pr_key: run.pr_key ?? null,
|
|
117
|
+
converge: run.converge ?? 0,
|
|
118
|
+
auto_merge: run.auto_merge ?? 0,
|
|
119
|
+
});
|
|
120
|
+
const row = projection(db, key);
|
|
121
|
+
assertEquals(row.stage, oracle.stage, `${key} (status=${run.status}): stage`);
|
|
122
|
+
assertEquals(row.stage_state, oracle.state, `${key} (status=${run.status}): stage_state`);
|
|
123
|
+
assertEquals(row.stage_skipped, oracle.skipped, `${key} (status=${run.status}): stage_skipped`);
|
|
124
|
+
assertEquals(row.attention, oracle.attention, `${key} (status=${run.status}): attention`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("feature_read_model derives list_bucket EXACTLY like deriveListBucket (history iff terminal AND acknowledged)", () => {
|
|
129
|
+
const db = viewDb();
|
|
130
|
+
let i = 0;
|
|
131
|
+
const cases: Array<{ key: string; status: string; ackAt: string | null }> = [];
|
|
132
|
+
for (const status of FEATURE_RUN_STATUSES) {
|
|
133
|
+
for (const ackAt of [null, "2026-02-02T00:00:00Z"]) {
|
|
134
|
+
const key = `o/r#lb${i++}`;
|
|
135
|
+
cases.push({ key, status, ackAt });
|
|
136
|
+
addRun(db, key, { status, acknowledged_at: ackAt });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const { key, status, ackAt } of cases) {
|
|
140
|
+
assertEquals(
|
|
141
|
+
projection(db, key).list_bucket,
|
|
142
|
+
deriveListBucket(status, ackAt),
|
|
143
|
+
`${key} (status=${status}, acknowledged=${ackAt !== null}): list_bucket`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("feature_read_model IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
|
|
149
|
+
const db = viewDb();
|
|
150
|
+
// A merged run whose STORED columns lie (frozen from when it was `running`). The VIEW must re-derive.
|
|
151
|
+
addRun(db, "o/r#stale", {
|
|
152
|
+
status: "merged",
|
|
153
|
+
acknowledged_at: "2026-02-02T00:00:00Z",
|
|
154
|
+
stored: { stage: "Implementing", stage_state: undefined, attention: "⚠", list_bucket: "active" },
|
|
155
|
+
});
|
|
156
|
+
assertEquals(projection(db, "o/r#stale"), {
|
|
157
|
+
stage: "Done",
|
|
158
|
+
stage_state: "ok",
|
|
159
|
+
stage_skipped: "Converging Merging",
|
|
160
|
+
attention: null,
|
|
161
|
+
list_bucket: "history",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceTracking reconciler bypass) leaves the projection CORRECT (stage=Done, terminal stage_state, attention=null, Dismiss renderable, still Active)", () => {
|
|
166
|
+
// Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated (cancelled)
|
|
167
|
+
// process instance it writes `{status:"abandoned"}` to `feature_runs` through the RAW datasource,
|
|
168
|
+
// bypassing the (now retired) projecting `featureRuns` gateway. Under the OLD write-time projection
|
|
169
|
+
// the stored `stage`/`stage_state`/`attention`/`list_bucket` would FREEZE at their pre-terminal
|
|
170
|
+
// values — the merlin symptom: a cancelled run wedged in Active as a live-looking `Implementing ⚠`,
|
|
171
|
+
// its Dismiss gated shut on a NULL stage_state. Because the projection is now a VIEW over `status`,
|
|
172
|
+
// the read model stays correct with no write-path for any writer to leave it stale.
|
|
173
|
+
const db = viewDb();
|
|
174
|
+
// A live run mid-flight — its (soon-stale) stored projection says Implementing / ⚠ / active.
|
|
175
|
+
addRun(db, "o/r#kill", {
|
|
176
|
+
status: "running",
|
|
177
|
+
stored: { stage: "Implementing", stage_state: undefined, attention: undefined, list_bucket: "active" },
|
|
178
|
+
});
|
|
179
|
+
assertEquals(projection(db, "o/r#kill").stage, "Implementing", "precondition: live run renders Implementing");
|
|
180
|
+
|
|
181
|
+
// The reconciler flips status terminal via the RAW table — NOT the gateway. (Simulated with a raw
|
|
182
|
+
// UPDATE, exactly what the raw datasource emits.) It touches none of the display columns.
|
|
183
|
+
db.prepare("UPDATE feature_runs SET status = 'abandoned' WHERE feature_key = ?").run("o/r#kill");
|
|
184
|
+
|
|
185
|
+
const row = projection(db, "o/r#kill");
|
|
186
|
+
const oracle = deriveStage({ status: "abandoned", pr_key: null, converge: 0, auto_merge: 0 });
|
|
187
|
+
// The projection tracks `status` through the VIEW — the merlin drift can no longer happen.
|
|
188
|
+
assertEquals(row.stage, "Done", "an abandoned run is Done, not wedged at Implementing");
|
|
189
|
+
assertEquals(row.stage, oracle.stage);
|
|
190
|
+
assertEquals(row.stage_state, "failed", "abandoned renders a terminal FAILED state (was frozen NULL)");
|
|
191
|
+
assertEquals(row.stage_state, oracle.state);
|
|
192
|
+
assertEquals(row.attention, null, "the stale ⚠ badge is gone");
|
|
193
|
+
assertEquals(row.attention, oracle.attention);
|
|
194
|
+
// Dismiss's `showWhenField` is `stage_state`: a non-null terminal state makes it renderable.
|
|
195
|
+
assert(row.stage_state != null, "Dismiss is renderable (stage_state is non-null) so the run can be ticked off");
|
|
196
|
+
// Unacknowledged terminal → still Active (History only after the operator dismisses it).
|
|
197
|
+
assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
|
|
198
|
+
assertEquals(row.list_bucket, deriveListBucket("abandoned", null));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("the Feature page binds the derived feature_read_model VIEW (not the raw feature_runs table)", () => {
|
|
202
|
+
// `feature.page.json`'s runs grid is the ONLY thing making the UI consume the derived projection.
|
|
203
|
+
// `feature_runs` remains a valid schema table, so reverting this binding would leave every SQL-view
|
|
204
|
+
// test green while the display silently resumed reading the stale stored columns; pin it here
|
|
205
|
+
// (suppressed advisory feature.page.json — issue #439).
|
|
206
|
+
const page = PAGE("feature.page.json");
|
|
207
|
+
const runs = (page.nodes ?? []).find((n: { id: string }) => n.id === "feature-runs");
|
|
208
|
+
assert(runs, "feature page must keep the Feature runs grid");
|
|
209
|
+
assertEquals(runs.props.data.table, "feature_read_model");
|
|
210
|
+
});
|
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,
|