@nanobpm/nano-workforce 0.167.1 → 0.167.3
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 +12 -0
- package/app/acknowledge.ts +98 -0
- package/app/featureReadModel.test.ts +6 -3
- package/app/featureReadModel.ts +15 -5
- package/app/planReadModel.test.ts +80 -1
- package/app/pullRequestReadModel.ts +6 -1
- package/db/migrations/099_feature_read_model_ack_open.sql +58 -0
- package/db/migrations/100_plan_read_model_pass_acknowledged_at.sql +63 -0
- package/operations/acknowledge.parity.test.ts +242 -0
- package/operations/acknowledgeDeliveryGraph.ts +28 -43
- package/operations/acknowledgeDone.ts +27 -40
- package/operations/acknowledgeEpic.ts +28 -53
- package/operations/acknowledgePr.ts +21 -41
- package/package.json +1 -1
- package/pages/feature.page.json +1 -1
- package/pages/overview.page.json +1 -1
- package/operations/acknowledgeDeliveryGraph.test.ts +0 -93
- package/operations/acknowledgeDone.test.ts +0 -110
- package/operations/acknowledgeEpic.test.ts +0 -173
- package/operations/acknowledgePr.test.ts +0 -94
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.167.3](https://github.com/nanobpm/nano-workforce/compare/v0.167.2...v0.167.3) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **acknowledge:** gate all four ack ops on read-model ack_open via one shared helper ([#654](https://github.com/nanobpm/nano-workforce/issues/654)) ([#655](https://github.com/nanobpm/nano-workforce/issues/655)) ([598aa80](https://github.com/nanobpm/nano-workforce/commit/598aa80470d307c7499caa26bc9b6137c6daf1fa)), closes [#652](https://github.com/nanobpm/nano-workforce/issues/652)
|
|
6
|
+
|
|
7
|
+
## [0.167.2](https://github.com/nanobpm/nano-workforce/compare/v0.167.1...v0.167.2) (2026-08-31)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* gate acknowledgePr on the read model's folded status, not the base column ([#653](https://github.com/nanobpm/nano-workforce/issues/653)) ([80446e2](https://github.com/nanobpm/nano-workforce/commit/80446e24af3c62570c041a109eef85eb34f8bb23)), closes [#652](https://github.com/nanobpm/nano-workforce/issues/652)
|
|
12
|
+
|
|
1
13
|
## [0.167.1](https://github.com/nanobpm/nano-workforce/compare/v0.167.0...v0.167.1) (2026-08-31)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The ONE shared "operator dismiss / tick-off" acknowledge helper, gating every acknowledge op on the
|
|
2
|
+
// read model's derived `ack_open` (issue #654). Introduced to kill a CATEGORICAL drift: each of the
|
|
3
|
+
// four acknowledge/dismiss ops (`acknowledgePr` / `acknowledgeDone` / `acknowledgeDeliveryGraph` /
|
|
4
|
+
// `acknowledgeEpic`) used to re-derive terminality from its BASE `status` column, while the Dismiss
|
|
5
|
+
// affordance the UI renders reads the read model's `ack_open` (folded from the terminal-folded
|
|
6
|
+
// `derived_status`). When base ≠ derived — any out-of-band terminate freezes base `status` at
|
|
7
|
+
// `running`/`escalated`/`dispatched` while the read model folds `derived_status='abandoned'` — the
|
|
8
|
+
// button showed "Dismiss" but the op 409'd "not terminal" (manifested for PR, #652; latent for the
|
|
9
|
+
// other three).
|
|
10
|
+
//
|
|
11
|
+
// The fix: gate on the SAME `ack_open` the button consumes, so the affordance and the guard read one
|
|
12
|
+
// oracle and cannot drift. Each op collapses to a one-liner passing its own read-model VIEW + key
|
|
13
|
+
// column; this helper does the rest:
|
|
14
|
+
//
|
|
15
|
+
// 1. Look up the row in the read-model VIEW (the `..._read_model` VIEW exposing `ack_open`) by key.
|
|
16
|
+
// 2. 404 if the row is absent.
|
|
17
|
+
// 3. 409 if `ack_open !== 1` and the row is NOT already acknowledged (a live / non-dismissable row).
|
|
18
|
+
// 4. An already-acknowledged row (`ack_open=0` because `acknowledged_at` is set) is an idempotent
|
|
19
|
+
// no-op → 200 (no double-stamp), not an error.
|
|
20
|
+
// 5. Stamp `acknowledged_at = now()` (+ `updated_at`) on the base RECORD table (writes never go to
|
|
21
|
+
// the VIEW). The VIEW then folds the row to `list_bucket='history'` / `ack_open=0` at read time.
|
|
22
|
+
|
|
23
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
24
|
+
|
|
25
|
+
/** A dismissable surface: the read-model VIEW the Dismiss affordance + this guard both read `ack_open`
|
|
26
|
+
* off, the base RECORD table the `acknowledged_at` stamp lands on, the primary-key column both are
|
|
27
|
+
* keyed on, and a human label for the log/error text. */
|
|
28
|
+
export interface AckSurface {
|
|
29
|
+
/** The read-model VIEW exposing the derived `ack_open` + pass-through `acknowledged_at` — the SINGLE
|
|
30
|
+
* oracle the page's `showWhenField` Dismiss button also consumes (so guard and button cannot drift). */
|
|
31
|
+
readonly view: string;
|
|
32
|
+
/** The base RECORD table the `acknowledged_at` stamp is written to (urban forbids writing a VIEW). */
|
|
33
|
+
readonly baseTable: string;
|
|
34
|
+
/** The primary-key column both the VIEW and the base table are keyed on. */
|
|
35
|
+
readonly keyColumn: string;
|
|
36
|
+
/** Human-readable noun for the op's log lines and 404/409 error bodies (e.g. "pull request"). */
|
|
37
|
+
readonly label: string;
|
|
38
|
+
/** The 409 error message for a NON-dismissable (still-live / not-terminal) row. */
|
|
39
|
+
readonly notDismissableError: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The row shape the helper reads off the read-model VIEW. */
|
|
43
|
+
interface AckViewRow {
|
|
44
|
+
ack_open: number | null;
|
|
45
|
+
acknowledged_at: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The uniform op result the four acknowledge ops return verbatim. */
|
|
49
|
+
export interface AckResult {
|
|
50
|
+
status: number;
|
|
51
|
+
body: { ok: boolean; error?: string; message?: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Acknowledge (operator-dismiss / tick-off) the `key` row of a dismissable {@link AckSurface}, gating
|
|
56
|
+
* on the read model's derived `ack_open` — the ONE oracle the Dismiss button also reads, so the guard
|
|
57
|
+
* and the affordance cannot drift (issue #654). Returns the uniform op result:
|
|
58
|
+
*
|
|
59
|
+
* - 404 — no such row in the read-model VIEW.
|
|
60
|
+
* - 200 (idempotent no-op) — the row is already acknowledged (`acknowledged_at` set), so re-dismissing
|
|
61
|
+
* is a no-op that does NOT re-stamp.
|
|
62
|
+
* - 409 — the row is not dismissable (`ack_open=0` and not acknowledged: still live / non-terminal).
|
|
63
|
+
* - 200 (stamped) — `ack_open=1`: stamp `acknowledged_at = now()` on the base record table; the VIEW
|
|
64
|
+
* folds the row to History (`list_bucket='history'`, `ack_open=0`) on the next read.
|
|
65
|
+
*/
|
|
66
|
+
export async function acknowledgeVia(app: AppApi, surface: AckSurface, key: string): Promise<AckResult> {
|
|
67
|
+
const view = app.data.table<AckViewRow>(surface.view, surface.keyColumn);
|
|
68
|
+
const row = await view.get(key);
|
|
69
|
+
if (!row) {
|
|
70
|
+
app.log.warn(`acknowledge: no such ${surface.label}`, { key });
|
|
71
|
+
return { status: 404, body: { ok: false, error: `no such ${surface.label}` } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The button and this guard consume the SAME derived `ack_open` from the SAME read-model VIEW, so
|
|
75
|
+
// they cannot drift: `ack_open=1` ⇔ the row is terminal-folded AND not yet acknowledged ⇔ dismissable.
|
|
76
|
+
if (Number(row.ack_open) !== 1) {
|
|
77
|
+
// `ack_open=0` is EITHER an already-acknowledged terminal row OR a still-live / non-terminal one.
|
|
78
|
+
// An already-acknowledged row (its `acknowledged_at` set — the whole reason `ack_open` folded to 0)
|
|
79
|
+
// is an idempotent no-op → 200, NOT an error; we do not re-stamp it.
|
|
80
|
+
if (row.acknowledged_at != null) {
|
|
81
|
+
app.log.info(`${surface.label} already acknowledged — idempotent no-op`, { key });
|
|
82
|
+
return { status: 200, body: { ok: true, message: "acknowledged" } };
|
|
83
|
+
}
|
|
84
|
+
app.log.warn(`acknowledge rejected: ${surface.label} is not dismissable`, { key });
|
|
85
|
+
return { status: 409, body: { ok: false, error: surface.notDismissableError } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Stamp the dismissal on the base RECORD table (never the VIEW). `list_bucket` (→ 'history') and
|
|
89
|
+
// `ack_open` (→ 0) are derived by the read-model VIEW from the now-acknowledged row, so we never
|
|
90
|
+
// hand-set them here.
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
await app.data
|
|
93
|
+
.table<{ acknowledged_at: string | null; updated_at: string }>(surface.baseTable, surface.keyColumn)
|
|
94
|
+
.update(key, { acknowledged_at: now, updated_at: now });
|
|
95
|
+
|
|
96
|
+
app.log.info(`operator acknowledged ${surface.label}`, { key });
|
|
97
|
+
return { status: 200, body: { ok: true, message: "acknowledged" } };
|
|
98
|
+
}
|
|
@@ -31,7 +31,7 @@ import { deriveListBucket, deriveStage } from "./stage.ts";
|
|
|
31
31
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
32
32
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
33
33
|
|
|
34
|
-
const MIGRATION_LATEST = "
|
|
34
|
+
const MIGRATION_LATEST = "099_feature_read_model_ack_open.sql";
|
|
35
35
|
|
|
36
36
|
// The base `feature_runs` shape the VIEW reads, plus the `user_tasks` inbox (034) the `attention`
|
|
37
37
|
// derivation `EXISTS`-reads, plus a stand-in for the managed `feature_runs__tracking` derived VIEW
|
|
@@ -118,7 +118,7 @@ function addRun(db: DatabaseSync, feature_key: string, run: SampleRun): void {
|
|
|
118
118
|
function projection(db: DatabaseSync, feature_key: string): Record<string, unknown> {
|
|
119
119
|
const r = db
|
|
120
120
|
.prepare(
|
|
121
|
-
"SELECT stage, stage_state, stage_skipped, attention, list_bucket FROM feature_read_model WHERE feature_key = ?",
|
|
121
|
+
"SELECT stage, stage_state, stage_skipped, attention, list_bucket, ack_open FROM feature_read_model WHERE feature_key = ?",
|
|
122
122
|
)
|
|
123
123
|
.get(feature_key) as Record<string, unknown>;
|
|
124
124
|
return { ...r };
|
|
@@ -267,6 +267,7 @@ test("the migration 080 VIEW IGNORES any stale STORED projection columns — it
|
|
|
267
267
|
stage_skipped: "Converging Merging",
|
|
268
268
|
attention: null,
|
|
269
269
|
list_bucket: "history",
|
|
270
|
+
ack_open: 0,
|
|
270
271
|
});
|
|
271
272
|
});
|
|
272
273
|
|
|
@@ -323,7 +324,8 @@ test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceT
|
|
|
323
324
|
assertEquals(row.stage_state, oracle.state);
|
|
324
325
|
assertEquals(row.attention, null, "the stale ⚠ badge is gone");
|
|
325
326
|
assertEquals(row.attention, oracle.attention);
|
|
326
|
-
assert(row.stage_state != null, "
|
|
327
|
+
assert(row.stage_state != null, "the run is terminal (stage_state is non-null)");
|
|
328
|
+
assertEquals(row.ack_open, 1, "Dismiss is renderable (ack_open=1) so the run can be ticked off");
|
|
327
329
|
assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
|
|
328
330
|
assertEquals(row.list_bucket, deriveListBucket("abandoned", null));
|
|
329
331
|
});
|
|
@@ -355,6 +357,7 @@ test("RED/GREEN #503: a DERIVE-ONLY terminated run (base status frozen at 'runni
|
|
|
355
357
|
assertEquals(row.stage_state, oracle.state);
|
|
356
358
|
assertEquals(row.attention, null, "the stale ⚠ badge is gone once the run is terminated");
|
|
357
359
|
assertEquals(row.list_bucket, "active", "a just-cancelled run sits in Active until dismissed");
|
|
360
|
+
assertEquals(row.ack_open, 1, "the derive-only terminated run is dismissable (ack_open=1) though base status is frozen non-terminal");
|
|
358
361
|
});
|
|
359
362
|
|
|
360
363
|
test("the Feature page binds the derived feature_read_model VIEW (not the raw feature_runs table)", () => {
|
package/app/featureReadModel.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// LATER ADR-0065 rollout steps (3/4), deliberately out of scope here.
|
|
32
32
|
|
|
33
33
|
import { and, caseWhen, col, defineReadModel, type Expr, eq, exists, lit, neq, not, or, pcol, type ReadModel, when } from "@nanobpm/urban";
|
|
34
|
-
import { deriveListBucketExpr } from "./listBucket.ts";
|
|
34
|
+
import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
|
|
35
35
|
|
|
36
36
|
/** The 6 TRULY-terminal statuses that map to the `Done` stage — the single source of truth for the
|
|
37
37
|
* terminal tier of BOTH the pipeline `stage`/`stage_state` derivations and the `list_bucket` history
|
|
@@ -137,16 +137,25 @@ const attention: Expr = caseWhen(
|
|
|
137
137
|
* reproduces this model's `isDone`/`isNotNull` forms exactly). */
|
|
138
138
|
const listBucket: Expr = deriveListBucketExpr(EFFECTIVE_STATUS_COLUMN, STAGE_DONE_STATUSES);
|
|
139
139
|
|
|
140
|
-
/** The
|
|
140
|
+
/** The operator "Dismiss" (tick-off) affordance flag — `1` IFF the run is terminal AND not yet
|
|
141
|
+
* acknowledged (so the page's `showWhenField` Dismiss button renders only for a terminal-but-
|
|
142
|
+
* unacknowledged run), else `0`. The feature twin of the PR/Delivery-Graph/Epic `ack_open`, from the
|
|
143
|
+
* ONE shared oracle (app/listBucket.ts, issue #641) parameterised by the SAME {@link
|
|
144
|
+
* STAGE_DONE_STATUSES} terminal set `list_bucket` uses — so the Dismiss button and the
|
|
145
|
+
* `acknowledgeDone` guard consume the identical predicate and cannot drift (issue #654). */
|
|
146
|
+
const ackOpen: Expr = deriveAckOpenExpr(EFFECTIVE_STATUS_COLUMN, STAGE_DONE_STATUSES);
|
|
147
|
+
|
|
148
|
+
/** The keys of {@link featureReadModel}'s DERIVED columns, in the order migration 099 emits them.
|
|
141
149
|
* Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
|
|
142
|
-
export const FEATURE_READ_MODEL_DERIVED = ["stage", "stage_state", "stage_skipped", "attention", "list_bucket"] as const;
|
|
150
|
+
export const FEATURE_READ_MODEL_DERIVED = ["stage", "stage_state", "stage_skipped", "attention", "list_bucket", "ack_open"] as const;
|
|
143
151
|
export type FeatureReadModelDerivedColumn = (typeof FEATURE_READ_MODEL_DERIVED)[number];
|
|
144
152
|
|
|
145
153
|
/**
|
|
146
154
|
* The declare-once `feature_read_model` derived columns. `selectBaseColumns: false` because the base
|
|
147
|
-
* columns are plain identity pass-throughs enumerated in
|
|
155
|
+
* columns are plain identity pass-throughs enumerated in the latest superseding migration (099, which
|
|
156
|
+
* re-emits the whole VIEW body — see also 076/081) (so the static pages↔schema
|
|
148
157
|
* contract guard, which reads a VIEW's columns off an aliased select-list, sees them); this model owns
|
|
149
|
-
* only the
|
|
158
|
+
* only the six real DERIVATIONS. Both the migration VIEW (`sqlSelectFor`, drift-guarded) and the
|
|
150
159
|
* runtime TS oracle (`fnFor`, behind app/stage.ts) are generated from THIS single declaration.
|
|
151
160
|
*/
|
|
152
161
|
export const featureReadModel: ReadModel = defineReadModel({
|
|
@@ -159,6 +168,7 @@ export const featureReadModel: ReadModel = defineReadModel({
|
|
|
159
168
|
stage_skipped: stageSkipped,
|
|
160
169
|
attention,
|
|
161
170
|
list_bucket: listBucket,
|
|
171
|
+
ack_open: ackOpen,
|
|
162
172
|
},
|
|
163
173
|
});
|
|
164
174
|
|
|
@@ -29,19 +29,26 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
29
29
|
import { test } from "node:test";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { assertReadModelParity, assertRollupParity, type ParityDb, type ParitySample, type RollupInputs } from "@nanobpm/urban";
|
|
32
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
32
33
|
import { assert, assertEquals } from "#test-assert";
|
|
34
|
+
import { acknowledgeVia } from "./acknowledge.ts";
|
|
33
35
|
import { deriveDelivery, deriveEpicBucket, epicIsAcknowledgeable } from "./delivery.ts";
|
|
34
36
|
import { planReadModel, PLAN_READ_MODEL_BASE_ALIAS, PLAN_READ_MODEL_DERIVED } from "./planReadModel.ts";
|
|
35
37
|
import { PLAN_ROLLUPS, planDeliveryCounts, planWaveCounts, planWaveProgress } from "./planRollups.ts";
|
|
38
|
+
import { noopLog } from "../test/log.ts";
|
|
36
39
|
|
|
37
40
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
38
41
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
39
42
|
|
|
40
43
|
const ROLLUPS_MIGRATION = "082_plan_rollups_declare_once.sql";
|
|
41
44
|
const READ_MODEL_MIGRATION = "097_plan_read_model_terminal_dismiss.sql";
|
|
45
|
+
// Passes `acknowledged_at` through the plan_read_model VIEW (issue #654) — the column the shared
|
|
46
|
+
// `acknowledgeVia` helper reads to tell an already-acknowledged epic (idempotent 200) from a live one
|
|
47
|
+
// (409). Supersedes 097's VIEW body verbatim except for that one added base pass-through.
|
|
48
|
+
const READ_MODEL_ACK_PASSTHROUGH_MIGRATION = "100_plan_read_model_pass_acknowledged_at.sql";
|
|
42
49
|
// The forward chain whose net effect the end-to-end tests exercise: the original hand-authored VIEWs
|
|
43
50
|
// (059/060/061/074/080), the declare-once supersessions (082/083), then the terminal-dismiss
|
|
44
|
-
// supersession (097). Mirrors the runtime migrator.
|
|
51
|
+
// supersession (097), then the `acknowledged_at` pass-through (100). Mirrors the runtime migrator.
|
|
45
52
|
const MIGRATION_CHAIN = [
|
|
46
53
|
"059_plan_wave_summary.sql",
|
|
47
54
|
"060_plan_wave_rollup.sql",
|
|
@@ -52,6 +59,7 @@ const MIGRATION_CHAIN = [
|
|
|
52
59
|
"083_plan_read_model_declare_once.sql",
|
|
53
60
|
"084_plan_wave_tasks_effective_status.sql",
|
|
54
61
|
READ_MODEL_MIGRATION,
|
|
62
|
+
READ_MODEL_ACK_PASSTHROUGH_MIGRATION,
|
|
55
63
|
];
|
|
56
64
|
|
|
57
65
|
// The base `plans` / `plan_tasks` / `pull_requests` shapes the VIEWs read, plus a stand-in for the
|
|
@@ -209,6 +217,77 @@ test("DRIFT GUARD: migration 097 embeds each derived column VERBATIM from planRe
|
|
|
209
217
|
}
|
|
210
218
|
});
|
|
211
219
|
|
|
220
|
+
// A minimal `AppApi` over node:sqlite so the shared `acknowledgeVia` helper (app/acknowledge.ts) can be
|
|
221
|
+
// driven against the REAL migration VIEW + base table — not a mock that could paper over a missing VIEW
|
|
222
|
+
// column. `data.table(name, key)` reads/writes `name` keyed on `key`, exactly as urban's does.
|
|
223
|
+
function sqliteApp(db: DatabaseSync): AppApi {
|
|
224
|
+
return {
|
|
225
|
+
data: {
|
|
226
|
+
table: (name: string, key: string) => ({
|
|
227
|
+
get: async (id: unknown) => db.prepare(`SELECT * FROM ${name} WHERE ${key} = ?`).get(id) ?? undefined,
|
|
228
|
+
update: async (id: unknown, patch: Record<string, unknown>) => {
|
|
229
|
+
const cols = Object.keys(patch);
|
|
230
|
+
const set = cols.map((c) => `${c} = ?`).join(", ");
|
|
231
|
+
const r = db.prepare(`UPDATE ${name} SET ${set} WHERE ${key} = ?`).run(...(cols.map((c) => patch[c]) as never[]), id as never);
|
|
232
|
+
return Number(r.changes);
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
236
|
+
log: noopLog(),
|
|
237
|
+
} as unknown as AppApi;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const EPIC_ACK_SURFACE = {
|
|
241
|
+
view: planReadModel.decl.name,
|
|
242
|
+
baseTable: "plans",
|
|
243
|
+
keyColumn: "plan_key",
|
|
244
|
+
label: "epic",
|
|
245
|
+
notDismissableError: "not terminal",
|
|
246
|
+
} as const;
|
|
247
|
+
|
|
248
|
+
test("DRIFT GUARD: migration 100 supersedes the plan_read_model VIEW, adding the acknowledged_at pass-through while re-embedding every derived column VERBATIM from planReadModel.sqlSelectFor", () => {
|
|
249
|
+
const sql = MIG(READ_MODEL_ACK_PASSTHROUGH_MIGRATION);
|
|
250
|
+
assert(/DROP VIEW IF EXISTS plan_read_model;/.test(sql), "100 must DROP the superseded plan_read_model first");
|
|
251
|
+
assert(/CREATE VIEW plan_read_model AS/.test(sql), "100 must (re)create plan_read_model");
|
|
252
|
+
// The one reason this migration exists: expose `acknowledged_at` as a projected column (the shared
|
|
253
|
+
// acknowledgeVia helper reads it to distinguish an already-acknowledged epic from a live one).
|
|
254
|
+
assert(sql.includes("pl.acknowledged_at AS acknowledged_at"), "100 must PROJECT the acknowledged_at pass-through");
|
|
255
|
+
// Every derived column stays byte-identical to the declaration — no drift is smuggled in.
|
|
256
|
+
for (const col of PLAN_READ_MODEL_DERIVED) {
|
|
257
|
+
const emitted = planReadModel.sqlSelectFor(col, { baseAlias: PLAN_READ_MODEL_BASE_ALIAS });
|
|
258
|
+
assert(
|
|
259
|
+
sql.includes(`${emitted} AS ${col}`),
|
|
260
|
+
`migration ${READ_MODEL_ACK_PASSTHROUGH_MIGRATION} no longer embeds the declaration's SQL for "${col}" — regenerate it ` +
|
|
261
|
+
`from app/planReadModel.ts (or add a new superseding migration). Expected to contain:\n ${emitted} AS ${col}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
for (const base of ["plan_key", "repo", "issue_number", "title", "process_key", "epic_phase", "promotion_pr", "promotion_state"]) {
|
|
265
|
+
assert(sql.includes(`pl.${base} AS ${base}`), `100 must keep base column "${base}" as a pass-through`);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("issue #654: the plan_read_model VIEW PROJECTS acknowledged_at so acknowledgeVia can tell an already-acknowledged epic (idempotent 200) from a live one (409)", async () => {
|
|
270
|
+
const db = viewDb();
|
|
271
|
+
const stamp = "2026-02-02T00:00:00Z";
|
|
272
|
+
// A `done` epic already dismissed (its `acknowledged_at` stamped): the VIEW folds it to History with
|
|
273
|
+
// the Dismiss affordance closed (`ack_open=0`).
|
|
274
|
+
addPlan(db, "o/r#1", { status: "done", acknowledged_at: stamp });
|
|
275
|
+
const row = readModel(db, "o/r#1");
|
|
276
|
+
// The bug (Copilot review, PR #655): `plan_read_model` only READ `acknowledged_at` inside its CASE
|
|
277
|
+
// bodies and never PROJECTED it — unlike the PR / feature / delivery-graph read models — so the helper
|
|
278
|
+
// saw `undefined` and could not tell "already acknowledged" from "still live". Assert the projection.
|
|
279
|
+
assert("acknowledged_at" in row, "plan_read_model must PROJECT acknowledged_at (the helper depends on it)");
|
|
280
|
+
assertEquals(row.acknowledged_at, stamp);
|
|
281
|
+
assertEquals(row.ack_open, 0, "an acknowledged terminal epic has ack_open=0");
|
|
282
|
+
|
|
283
|
+
// End-to-end: re-dismissing an already-acknowledged epic through the REAL VIEW is an idempotent 200,
|
|
284
|
+
// NOT a spurious 409 — and it does not re-stamp.
|
|
285
|
+
const res = await acknowledgeVia(sqliteApp(db), EPIC_ACK_SURFACE, "o/r#1");
|
|
286
|
+
assertEquals(res.status, 200, "re-dismissing an already-acknowledged epic is an idempotent 200");
|
|
287
|
+
assertEquals(res.body.ok, true);
|
|
288
|
+
assertEquals(readModel(db, "o/r#1").acknowledged_at, stamp, "idempotent no-op must NOT re-stamp");
|
|
289
|
+
});
|
|
290
|
+
|
|
212
291
|
test("FRAMEWORK PARITY GUARD: each plan-family rollup's VIEW and TS reduce agree (assertRollupParity)", () => {
|
|
213
292
|
// Sample leaf rows spanning: multi-wave plans, every task/PR status, dangling pr_key (no PR row),
|
|
214
293
|
// un-levelized (NULL wave) tasks, and taskless plans — the predicates the rollups turn on.
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
import { defineReadModel, type Expr, type ReadModel } from "@nanobpm/urban";
|
|
23
23
|
import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
|
|
24
24
|
|
|
25
|
+
/** The read model's VIEW name — the single source of truth for the table an operation queries when it
|
|
26
|
+
* must gate on the folded/effective status (e.g. `acknowledgePr`, issue #652) rather than re-derive
|
|
27
|
+
* terminality off the frozen base `pull_requests.status` column. */
|
|
28
|
+
export const PULL_REQUEST_READ_MODEL_NAME = "pull_requests_read_model";
|
|
29
|
+
|
|
25
30
|
/** The base table the read model reads: the auto-provisioned `pull_requests__tracking` derived VIEW
|
|
26
31
|
* (ADR-0065), NOT the raw `pull_requests` table — so the status-classifying derivations read the
|
|
27
32
|
* terminal-folded `derived_status` and a terminated PR drops to History with no worker write. */
|
|
@@ -66,7 +71,7 @@ export type PullRequestReadModelDerivedColumn = (typeof PULL_REQUEST_READ_MODEL_
|
|
|
66
71
|
* generated from THIS single declaration.
|
|
67
72
|
*/
|
|
68
73
|
export const pullRequestReadModel: ReadModel = defineReadModel({
|
|
69
|
-
name:
|
|
74
|
+
name: PULL_REQUEST_READ_MODEL_NAME,
|
|
70
75
|
baseTable: PULL_REQUEST_READ_MODEL_BASE_TABLE,
|
|
71
76
|
selectBaseColumns: false,
|
|
72
77
|
derive: {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
-- Feature-run read model: fold the operator "Dismiss" affordance flag `ack_open` into the
|
|
2
|
+
-- `feature_read_model` VIEW (issue #654). SUPERSEDES 081's VIEW body — every DERIVED column is emitted
|
|
3
|
+
-- VERBATIM from the ONE declaration in app/featureReadModel.ts, now extended with the shared Dismiss-
|
|
4
|
+
-- affordance oracle (app/listBucket.ts `deriveAckOpenExpr`). 081 is a MERGED, IMMUTABLE migration —
|
|
5
|
+
-- never edited; this is a NEW migration superseding its VIEW body (the same pattern by which 081
|
|
6
|
+
-- superseded 076, and 096/097 added `ack_open` to the Delivery-Graph / Plan read models).
|
|
7
|
+
--
|
|
8
|
+
-- WHY. `acknowledgeDone` was the one acknowledge op still gating on the base `feature_runs.status`
|
|
9
|
+
-- allowlist (`STAGE_DONE_STATUSES.includes(run.status)`) while its Dismiss button rendered off the
|
|
10
|
+
-- read model — a latent twin of the PR drift (#652): when an out-of-band terminate freezes base
|
|
11
|
+
-- `status` non-terminal but the tracking VIEW folds `derived_status='abandoned'`, the button showed
|
|
12
|
+
-- Dismiss yet the op 409'd. Issue #654 gates every acknowledge op on the read model's `ack_open` (the
|
|
13
|
+
-- single oracle both the button and the guard consume), which requires `feature_read_model` to expose
|
|
14
|
+
-- `ack_open` uniformly with the other three surfaces. This VIEW adds that declared `ack_open` column.
|
|
15
|
+
--
|
|
16
|
+
-- Every DERIVED column below is emitted VERBATIM from `featureReadModel.sqlSelectFor(col,
|
|
17
|
+
-- { baseAlias: "fr" })` (which ALSO drives the runtime TS via `fnFor`); the `stage`/`stage_state`/
|
|
18
|
+
-- `stage_skipped`/`attention`/`list_bucket` bodies are unchanged from 081 and `ack_open` is the ONE new
|
|
19
|
+
-- derived column (terminal ∧ unacknowledged, from the shared oracle over `STAGE_DONE_STATUSES`). The
|
|
20
|
+
-- drift guard (app/featureReadModel.test.ts) fails if this file stops matching the declaration, and
|
|
21
|
+
-- `assertReadModelParity` proves the SQL and TS lowerings agree.
|
|
22
|
+
--
|
|
23
|
+
-- SEMANTICS unchanged from 081 EXCEPT the one new `ack_open` column: the status-classifying columns
|
|
24
|
+
-- still read the terminal-folded `feature_runs__tracking.derived_status`; base columns stay aliased
|
|
25
|
+
-- identity pass-throughs; `acknowledged_at` still passes through so the read model can classify on it.
|
|
26
|
+
--
|
|
27
|
+
-- Forward-only VIEW redefinition (DROP then CREATE). `feature_runs__tracking` is the managed VIEW urban
|
|
28
|
+
-- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
|
|
29
|
+
-- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
|
|
30
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 098.
|
|
31
|
+
|
|
32
|
+
DROP VIEW IF EXISTS feature_read_model;
|
|
33
|
+
|
|
34
|
+
CREATE VIEW feature_read_model AS
|
|
35
|
+
SELECT
|
|
36
|
+
fr.feature_key AS feature_key,
|
|
37
|
+
fr.repo AS repo,
|
|
38
|
+
fr.issue_number AS issue_number,
|
|
39
|
+
fr.issue_url AS issue_url,
|
|
40
|
+
fr.title AS title,
|
|
41
|
+
fr.base_branch AS base_branch,
|
|
42
|
+
fr.status AS status,
|
|
43
|
+
fr.process_key AS process_key,
|
|
44
|
+
fr.pr_key AS pr_key,
|
|
45
|
+
fr.converge AS converge,
|
|
46
|
+
fr.auto_merge AS auto_merge,
|
|
47
|
+
fr.outcome AS outcome,
|
|
48
|
+
fr.delivery_label AS delivery_label,
|
|
49
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
50
|
+
fr.created_at AS created_at,
|
|
51
|
+
fr.updated_at AS updated_at,
|
|
52
|
+
CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."derived_status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'running'), 0) OR COALESCE(("fr"."derived_status" = 'escalated'), 0) OR COALESCE(("fr"."derived_status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
|
|
53
|
+
CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."derived_status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
|
|
54
|
+
CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
|
|
55
|
+
CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
|
|
56
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
57
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
58
|
+
FROM feature_runs__tracking fr;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Plan (Epic) read model: pass `acknowledged_at` through the `plan_read_model` VIEW (issue #654).
|
|
2
|
+
-- SUPERSEDES 097's VIEW body — every DERIVED column (`delivery`/`list_bucket`/`ack_open`) is emitted
|
|
3
|
+
-- VERBATIM from the ONE declaration in app/planReadModel.ts, unchanged from 097; the ONLY difference is
|
|
4
|
+
-- the added `pl.acknowledged_at AS acknowledged_at` base pass-through. 097 is a MERGED, IMMUTABLE
|
|
5
|
+
-- migration — never edited; this is a NEW migration superseding its VIEW body (the same pattern by which
|
|
6
|
+
-- 097 superseded 083's plan_read_model body, and 099 the feature_read_model body).
|
|
7
|
+
--
|
|
8
|
+
-- WHY. The shared `acknowledgeVia` helper (app/acknowledge.ts, #654) reads BOTH `ack_open` AND
|
|
9
|
+
-- `acknowledged_at` off the read-model VIEW: `ack_open=0` is EITHER an already-acknowledged terminal row
|
|
10
|
+
-- (idempotent no-op → 200) OR a still-live / non-terminal one (→ 409), and it disambiguates the two on
|
|
11
|
+
-- whether `acknowledged_at` is set. The PR / feature / delivery-graph read models (094/099/096) each
|
|
12
|
+
-- already pass `acknowledged_at` through, but `plan_read_model` only READ it inside the `list_bucket` /
|
|
13
|
+
-- `ack_open` CASE bodies and never PROJECTED it — so `acknowledgeEpic` saw `row.acknowledged_at ===
|
|
14
|
+
-- undefined` and re-dismissing an already-acknowledged epic incorrectly 409'd instead of returning the
|
|
15
|
+
-- idempotent 200. This VIEW adds the missing pass-through so all four dismissable surfaces expose the
|
|
16
|
+
-- same columns the one helper depends on (parity, no drift).
|
|
17
|
+
--
|
|
18
|
+
-- SEMANTICS unchanged from 097 EXCEPT the one added `acknowledged_at` pass-through: the status-
|
|
19
|
+
-- classifying columns still read the terminal-folded `plans__tracking.derived_status`; base columns stay
|
|
20
|
+
-- aliased identity pass-throughs; the derived `delivery`/`list_bucket`/`ack_open`/display columns are
|
|
21
|
+
-- byte-identical to 097 (still drift-guarded against app/planReadModel.ts).
|
|
22
|
+
--
|
|
23
|
+
-- Forward-only VIEW redefinition (DROP then CREATE). `plans__tracking` is the managed VIEW urban
|
|
24
|
+
-- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
|
|
25
|
+
-- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
|
|
26
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 099.
|
|
27
|
+
|
|
28
|
+
DROP VIEW IF EXISTS plan_read_model;
|
|
29
|
+
|
|
30
|
+
CREATE VIEW plan_read_model AS
|
|
31
|
+
SELECT
|
|
32
|
+
pl.plan_key AS plan_key,
|
|
33
|
+
pl.repo AS repo,
|
|
34
|
+
pl.issue_number AS issue_number,
|
|
35
|
+
pl.issue_url AS issue_url,
|
|
36
|
+
pl.title AS title,
|
|
37
|
+
COALESCE(pl.derived_status, pl.status) AS status,
|
|
38
|
+
pl.task_count AS task_count,
|
|
39
|
+
pl.process_key AS process_key,
|
|
40
|
+
pl.outcome AS outcome,
|
|
41
|
+
pl.updated_at AS updated_at,
|
|
42
|
+
pl.epic_phase AS epic_phase,
|
|
43
|
+
pl.base_branch AS base_branch,
|
|
44
|
+
pl.wait_gate_label AS wait_gate_label,
|
|
45
|
+
pl.bound_artifacts AS bound_artifacts,
|
|
46
|
+
pl.promotion_pr AS promotion_pr,
|
|
47
|
+
pl.promotion_state AS promotion_state,
|
|
48
|
+
pl.acknowledged_at AS acknowledged_at,
|
|
49
|
+
CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END AS delivery,
|
|
50
|
+
"wp"."wave_count" AS wave_count,
|
|
51
|
+
"wp"."current_wave" AS current_wave,
|
|
52
|
+
CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) OR COALESCE(("pl"."derived_status" = 'failed'), 0) OR COALESCE(("pl"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0), 0))), 0) AND COALESCE(("pl"."acknowledged_at" = "pl"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
53
|
+
CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) OR COALESCE(("pl"."derived_status" = 'failed'), 0) OR COALESCE(("pl"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0), 0))), 0) AND (NOT COALESCE(COALESCE(("pl"."acknowledged_at" = "pl"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
|
|
54
|
+
CASE
|
|
55
|
+
WHEN pl.status IS NOT 'done' OR COALESCE(dc.prs_opened, 0) = 0 THEN NULL
|
|
56
|
+
WHEN COALESCE(dc.prs_in_flight, 0) > 0 THEN dc.prs_merged || '/' || dc.prs_opened || ' slices merged, ' || dc.prs_in_flight || ' converging'
|
|
57
|
+
WHEN dc.prs_merged = dc.prs_opened THEN dc.prs_opened || '/' || dc.prs_opened || ' slices merged'
|
|
58
|
+
ELSE NULL
|
|
59
|
+
END AS delivery_label,
|
|
60
|
+
(wp.current_wave + 1) || '/' || wp.wave_count AS wave_label
|
|
61
|
+
FROM plans__tracking pl
|
|
62
|
+
LEFT JOIN plan_delivery_counts dc ON pl.plan_key = dc.plan_key
|
|
63
|
+
LEFT JOIN plan_wave_progress wp ON pl.plan_key = wp.plan_key;
|