@nanobpm/nano-workforce 0.165.0 → 0.167.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 +12 -0
- package/app/backfillAcknowledgedAt.test.ts +121 -0
- package/app/delivery.ts +15 -13
- package/app/deliveryGraphReadModel.test.ts +44 -8
- package/app/deliveryGraphReadModel.ts +19 -2
- package/app/deliveryGraphRun.ts +5 -0
- package/app/epicBucket.test.ts +9 -3
- package/app/featureReadModel.ts +7 -11
- package/app/fineGrainedCells.test.ts +35 -17
- package/app/listBucket.ts +86 -0
- package/app/planReadModel.test.ts +29 -19
- package/app/planReadModel.ts +32 -26
- package/app/pullRequestReadModel.test.ts +208 -0
- package/app/pullRequestReadModel.ts +76 -0
- package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
- package/db/migrations/094_pull_requests_read_model.sql +65 -0
- package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
- package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
- package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
- package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
- package/openapi.yaml +98 -0
- package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
- package/operations/acknowledgeDeliveryGraph.ts +58 -0
- package/operations/acknowledgePr.test.ts +94 -0
- package/operations/acknowledgePr.ts +62 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +12 -3
- package/pages/home.page.json +18 -27
- package/pages/overview.page.json +26 -17
- package/resources/processes/feature.bpmn +8 -6
- package/scripts/pages-contract.test.ts +80 -18
- package/workers/record-results/worker.test.ts +5 -3
|
@@ -38,9 +38,10 @@ const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migratio
|
|
|
38
38
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
39
39
|
|
|
40
40
|
const ROLLUPS_MIGRATION = "082_plan_rollups_declare_once.sql";
|
|
41
|
-
const READ_MODEL_MIGRATION = "
|
|
41
|
+
const READ_MODEL_MIGRATION = "097_plan_read_model_terminal_dismiss.sql";
|
|
42
42
|
// The forward chain whose net effect the end-to-end tests exercise: the original hand-authored VIEWs
|
|
43
|
-
// (059/060/061/074/080)
|
|
43
|
+
// (059/060/061/074/080), the declare-once supersessions (082/083), then the terminal-dismiss
|
|
44
|
+
// supersession (097). Mirrors the runtime migrator.
|
|
44
45
|
const MIGRATION_CHAIN = [
|
|
45
46
|
"059_plan_wave_summary.sql",
|
|
46
47
|
"060_plan_wave_rollup.sql",
|
|
@@ -48,8 +49,9 @@ const MIGRATION_CHAIN = [
|
|
|
48
49
|
"074_plan_read_model_derive_bucket.sql",
|
|
49
50
|
"080_plan_read_model_derive_terminal.sql",
|
|
50
51
|
ROLLUPS_MIGRATION,
|
|
51
|
-
|
|
52
|
+
"083_plan_read_model_declare_once.sql",
|
|
52
53
|
"084_plan_wave_tasks_effective_status.sql",
|
|
54
|
+
READ_MODEL_MIGRATION,
|
|
53
55
|
];
|
|
54
56
|
|
|
55
57
|
// The base `plans` / `plan_tasks` / `pull_requests` shapes the VIEWs read, plus a stand-in for the
|
|
@@ -173,7 +175,7 @@ test("DRIFT GUARD: migration 082 embeds each rollup's VIEW DDL VERBATIM from rol
|
|
|
173
175
|
}
|
|
174
176
|
});
|
|
175
177
|
|
|
176
|
-
test("DRIFT GUARD: migration
|
|
178
|
+
test("DRIFT GUARD: migration 097 embeds each derived column VERBATIM from planReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
|
|
177
179
|
const sql = MIG(READ_MODEL_MIGRATION);
|
|
178
180
|
for (const col of PLAN_READ_MODEL_DERIVED) {
|
|
179
181
|
const emitted = planReadModel.sqlSelectFor(col, { baseAlias: PLAN_READ_MODEL_BASE_ALIAS });
|
|
@@ -183,29 +185,27 @@ test("DRIFT GUARD: migration 083 embeds each derived column VERBATIM from planRe
|
|
|
183
185
|
`from app/planReadModel.ts (or add a new superseding migration). Expected to contain:\n ${emitted} AS ${col}`,
|
|
184
186
|
);
|
|
185
187
|
}
|
|
186
|
-
// DROP+CREATE that supersedes
|
|
188
|
+
// DROP+CREATE that supersedes 083's plan_read_model VIEW body (the terminal-dismiss #641 arm), keeping
|
|
187
189
|
// every base column an aliased pass-through so the static pages↔schema contract guard still sees them.
|
|
188
|
-
assert(/DROP VIEW IF EXISTS plan_read_model;/.test(sql), "
|
|
189
|
-
assert(/
|
|
190
|
-
assert(/DROP VIEW IF EXISTS plan_wave_label;/.test(sql), "083 must fold in (drop) the retired plan_wave_label");
|
|
191
|
-
assert(/CREATE VIEW plan_read_model AS/.test(sql), "083 must (re)create plan_read_model");
|
|
190
|
+
assert(/DROP VIEW IF EXISTS plan_read_model;/.test(sql), "097 must DROP the superseded plan_read_model first");
|
|
191
|
+
assert(/CREATE VIEW plan_read_model AS/.test(sql), "097 must (re)create plan_read_model");
|
|
192
192
|
for (const base of ["plan_key", "repo", "issue_number", "title", "process_key", "epic_phase", "promotion_pr", "promotion_state"]) {
|
|
193
|
-
assert(sql.includes(`pl.${base} AS ${base}`), `
|
|
193
|
+
assert(sql.includes(`pl.${base} AS ${base}`), `097 must pass base column "${base}" through the VIEW`);
|
|
194
194
|
}
|
|
195
195
|
// The hand-authored display strings (D3 — no TS twin) live in this VIEW over the derived columns.
|
|
196
|
-
assert(sql.includes("AS delivery_label"), "
|
|
197
|
-
assert(sql.includes("AS wave_label"), "
|
|
196
|
+
assert(sql.includes("AS delivery_label"), "097 must carry the hand-authored delivery_label display column");
|
|
197
|
+
assert(sql.includes("AS wave_label"), "097 must carry the hand-authored wave_label display column");
|
|
198
198
|
// The FROM/JOIN relation names are DERIVED from the declaration (baseTable + each lookup's rollup name
|
|
199
199
|
// + join keys), not hand-hardcoded — so renaming `baseTable` or a rollup `.name` (which would make 082
|
|
200
|
-
// create a different-named VIEW) breaks this guard instead of silently leaving
|
|
200
|
+
// create a different-named VIEW) breaks this guard instead of silently leaving 097 pointing at a
|
|
201
201
|
// stale/missing relation.
|
|
202
202
|
const alias = PLAN_READ_MODEL_BASE_ALIAS;
|
|
203
|
-
assert(sql.includes(`FROM ${planReadModel.decl.baseTable} ${alias}`), `
|
|
203
|
+
assert(sql.includes(`FROM ${planReadModel.decl.baseTable} ${alias}`), `097's FROM must be the declaration's baseTable "${planReadModel.decl.baseTable}" (aliased ${alias})`);
|
|
204
204
|
for (const lk of planReadModel.decl.lookups) {
|
|
205
205
|
const rollupName = lk.rollup.decl.name;
|
|
206
206
|
const on = lk.on.map((k) => `${alias}.${k.base} = ${lk.as}.${k.rollup}`).join(" AND ");
|
|
207
207
|
const join = `LEFT JOIN ${rollupName} ${lk.as} ON ${on}`;
|
|
208
|
-
assert(sql.includes(join), `
|
|
208
|
+
assert(sql.includes(join), `097 must LEFT JOIN the declaration's "${rollupName}" lookup exactly as "${join}"`);
|
|
209
209
|
}
|
|
210
210
|
});
|
|
211
211
|
|
|
@@ -352,18 +352,28 @@ test("the migration 083 VIEW IGNORES stale STORED list_bucket / ack_open columns
|
|
|
352
352
|
assertEquals(row.ack_open, 0, "already acknowledged ⇒ no open Dismiss");
|
|
353
353
|
});
|
|
354
354
|
|
|
355
|
-
test("RED/GREEN #503: a DERIVE-ONLY terminated epic (base status frozen 'dispatched', derived_status='abandoned')
|
|
355
|
+
test("RED/GREEN #503 (+#641): a DERIVE-ONLY terminated epic (base status frozen 'dispatched', derived_status='abandoned') is classified off derived_status — Active+dismissable until acknowledged, then History", () => {
|
|
356
356
|
// ADR-0065: cancel/terminate is DERIVE-ONLY — `plans__tracking.derived_status` recomputes `abandoned`
|
|
357
|
-
// on READ while the base `plans.status` stays frozen at its last transient.
|
|
358
|
-
//
|
|
357
|
+
// on READ while the base `plans.status` stays frozen at its last transient. The bucket classifies off
|
|
358
|
+
// `derived_status`, so a terminated epic is handled on engine truth with no poller pass. Under #641
|
|
359
|
+
// (uniform acknowledge-to-dismiss) a terminated epic now STAYS Active with a Dismiss affordance until
|
|
360
|
+
// an operator ticks it off — mirroring features/PRs/DGs — rather than dropping straight to History.
|
|
359
361
|
const db = viewDb();
|
|
360
362
|
addPlan(db, "o/r#term", { status: "dispatched", stored: { list_bucket: "active" } });
|
|
361
363
|
assertEquals(readModel(db, "o/r#term").list_bucket, "active", "precondition: a live dispatched epic is Active");
|
|
362
364
|
|
|
363
365
|
db.prepare("UPDATE plans SET derived_status_override = 'abandoned' WHERE plan_key = ?").run("o/r#term");
|
|
364
366
|
const row = readModel(db, "o/r#term");
|
|
365
|
-
assertEquals(row.list_bucket, "
|
|
367
|
+
assertEquals(row.list_bucket, "active", "a derive-only terminated (unacknowledged) epic stays Active until dismissed (#641)");
|
|
368
|
+
assertEquals(row.ack_open, 1, "…and carries the Dismiss affordance");
|
|
366
369
|
assertEquals(row.list_bucket, deriveEpicBucket("abandoned", row.delivery === "converging" ? "converging" : null, null), "list_bucket tracks derived_status via the VIEW");
|
|
370
|
+
|
|
371
|
+
// Acknowledging it (the operator tick-off) settles it to History — the derived_status-driven, no-
|
|
372
|
+
// worker-write resolution the #503 phantom fix guaranteed, now gated on an explicit dismiss.
|
|
373
|
+
db.prepare("UPDATE plans SET acknowledged_at = '2026-02-02T00:00:00Z' WHERE plan_key = ?").run("o/r#term");
|
|
374
|
+
const acked = readModel(db, "o/r#term");
|
|
375
|
+
assertEquals(acked.list_bucket, "history", "a dismissed terminated epic is History (classified off derived_status, no poller pass)");
|
|
376
|
+
assertEquals(acked.ack_open, 0, "…and its Dismiss affordance is retracted");
|
|
367
377
|
});
|
|
368
378
|
|
|
369
379
|
test("REGRESSION (Copilot #493): a DERIVE-ONLY terminated slice PR (base status frozen 'converging', derived_status='abandoned') is counted RESOLVED — the VIEW joins pull_requests__tracking.derived_status", () => {
|
package/app/planReadModel.ts
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
// worker stamps its own BPMN element's phase) with no SQL twin — it is not a per-row function of the
|
|
30
30
|
// plan row, so it stays hand-authored and out of this declaration.
|
|
31
31
|
|
|
32
|
-
import { and, caseWhen, col, defineReadModel, type Expr, eq, gt,
|
|
32
|
+
import { and, caseWhen, col, defineReadModel, type Expr, eq, gt, lit, not, or, type ReadModel, rcol, when } from "@nanobpm/urban";
|
|
33
|
+
import { deriveAckOpenFromTerminal, deriveListBucketFromTerminal, terminalStatusIn } from "./listBucket.ts";
|
|
34
|
+
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
33
35
|
import { planDeliveryCounts, planWaveProgress } from "./planRollups.ts";
|
|
34
36
|
|
|
35
37
|
/** The base table the read model reads: the auto-provisioned `plans__tracking` derived VIEW (ADR-0065,
|
|
@@ -61,7 +63,6 @@ export const EFFECTIVE_STATUS_COLUMN = "derived_status";
|
|
|
61
63
|
* …)`'s call sites, which pass the base status. */
|
|
62
64
|
const BASE_STATUS_COLUMN = "status";
|
|
63
65
|
|
|
64
|
-
const ds = col(EFFECTIVE_STATUS_COLUMN);
|
|
65
66
|
const bs = col(BASE_STATUS_COLUMN);
|
|
66
67
|
|
|
67
68
|
/** The derived epic `delivery` signal — the byte-for-byte twin of the retired `plan_delivery` VIEW's
|
|
@@ -82,32 +83,37 @@ const delivery: Expr = caseWhen(
|
|
|
82
83
|
lit(null),
|
|
83
84
|
);
|
|
84
85
|
|
|
85
|
-
/** The
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
when(and(eq(ds, lit("done")), isNull(col("acknowledged_at"))), lit("active")),
|
|
97
|
-
when(eq(ds, lit("done")), lit("history")),
|
|
98
|
-
],
|
|
99
|
-
lit("history"),
|
|
86
|
+
/** The epic's "dismissable-terminal" predicate — terminal AND actually tick-off-able: the epic's
|
|
87
|
+
* terminal-folded `derived_status` is in {@link PLAN_TERMINAL_STATUSES} (`done`/`failed`/`abandoned`)
|
|
88
|
+
* AND its fan-out is NOT still `converging`. This is the epic-specific refinement the shared oracle
|
|
89
|
+
* (app/listBucket.ts) takes: a `done`-but-still-`converging` epic is terminal by status yet must NOT be
|
|
90
|
+
* dismissable mid-flight (a stray/premature ack must not drag it to History) — PRs/Delivery-Graphs have
|
|
91
|
+
* no such mid-flight terminal, so their predicate is just "terminal". `not(eq(delivery, 'converging'))`
|
|
92
|
+
* is the null-safe `delivery IS NOT 'converging'` (a NULL/`landed` delivery ⇒ resolved ⇒ dismissable)
|
|
93
|
+
* under the shared "NULL → false" rule. */
|
|
94
|
+
const dismissableTerminal: Expr = and(
|
|
95
|
+
terminalStatusIn(EFFECTIVE_STATUS_COLUMN, PLAN_TERMINAL_STATUSES),
|
|
96
|
+
not(eq(delivery, lit("converging"))),
|
|
100
97
|
);
|
|
101
98
|
|
|
102
|
-
/** The
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
99
|
+
/** The Active/History partition (`deriveEpicBucket`, app/delivery.ts) — the ONE shared oracle
|
|
100
|
+
* (app/listBucket.ts, issue #641) over the epic's {@link dismissableTerminal} predicate, so every
|
|
101
|
+
* "Active …" grid partitions with the identical acknowledge-to-dismiss rule: `history` IFF the epic is
|
|
102
|
+
* dismissable-terminal AND acknowledged; otherwise `active` (live `planning`/`dispatched` epics, a
|
|
103
|
+
* `done`-but-still-`converging` epic — terminal but not dismissable mid-flight, so it stays active — and
|
|
104
|
+
* the #641 gap this closes: a terminal-non-`done` `failed`/`abandoned` epic that is UNACKNOWLEDGED,
|
|
105
|
+
* which before fell straight to History skipping the tick-off). Classifies on the terminal-folded
|
|
106
|
+
* `derived_status` so a cancelled epic is handled on engine truth. */
|
|
107
|
+
const listBucket: Expr = deriveListBucketFromTerminal(dismissableTerminal);
|
|
108
|
+
|
|
109
|
+
/** The operator "Dismiss" (acknowledge) affordance flag (`epicIsAcknowledgeable` ∧ unacknowledged):
|
|
110
|
+
* `1` iff the epic is {@link dismissableTerminal} and not yet acknowledged; else `0`. Shares the exact
|
|
111
|
+
* predicate with {@link listBucket} (a row is dismissable precisely while it would still be `active` on
|
|
112
|
+
* the terminal branch). Extended from `done`-only to the full terminal set for #641: a `failed`/
|
|
113
|
+
* `abandoned` epic (whose `delivery` is always non-`converging`) is now dismissable too, so the
|
|
114
|
+
* terminal-non-`done` arm of {@link listBucket} has a Dismiss affordance to move it to History —
|
|
115
|
+
* mirroring features/PRs/DGs. */
|
|
116
|
+
const ackOpen: Expr = deriveAckOpenFromTerminal(dismissableTerminal);
|
|
111
117
|
|
|
112
118
|
/** The wave frontier columns — bare pass-throughs of the `plan_wave_progress` rollup lookup (a
|
|
113
119
|
* taskless plan has no rollup row, so the LEFT-JOIN miss reads NULL, matching the workers' behaviour).
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Read-model coverage for the Convergence (PRs) Active/History projection — the declared `list_bucket`
|
|
2
|
+
// (active/history) + `ack_open` (the Dismiss affordance flag) that give the PR surfaces the SAME
|
|
3
|
+
// acknowledge-to-dismiss behaviour Features/Epics/Delivery-Graphs already have, authored via Urban's
|
|
4
|
+
// ADR-0065 declare-once primitive (app/pullRequestReadModel.ts). Issue #641; the exemplars are
|
|
5
|
+
// app/featureReadModel.test.ts and app/deliveryGraphReadModel.test.ts.
|
|
6
|
+
//
|
|
7
|
+
// Guards:
|
|
8
|
+
// 1. DRIFT GUARD — migration 094 embeds each derived column VERBATIM from
|
|
9
|
+
// `pullRequestReadModel.sqlSelectFor(...)` and passes EVERY base column through, so the checked-in
|
|
10
|
+
// VIEW cannot drift from the declaration.
|
|
11
|
+
// 2. FRAMEWORK PARITY GUARD — `assertReadModelParity` proves the SQL and TS lowerings the ONE
|
|
12
|
+
// declaration compiles to agree over the status × acknowledged matrix.
|
|
13
|
+
// 3. END-TO-END BEHAVIOUR on the REAL migration VIEW (094 applied to an in-memory DB): a live PR is
|
|
14
|
+
// active with no Dismiss; a terminal-but-unacknowledged PR STAYS active and offers Dismiss; once
|
|
15
|
+
// acknowledged it drops to History; an out-of-band-terminated PR classifies on engine truth.
|
|
16
|
+
// 4. PAGE BINDINGS — the Convergence surfaces (overview + home) bind the derived VIEW and bucket on
|
|
17
|
+
// the derived `list_bucket`, not a base-`status` allowlist over the raw `pull_requests` table.
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { DatabaseSync } from "node:sqlite";
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { assertReadModelParity, type ParityDb, type ParitySample } from "@nanobpm/urban";
|
|
24
|
+
import { assert, assertEquals } from "#test-assert";
|
|
25
|
+
import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.ts";
|
|
26
|
+
import {
|
|
27
|
+
PR_TERMINAL_STATUSES,
|
|
28
|
+
PULL_REQUEST_READ_MODEL_BASE_ALIAS,
|
|
29
|
+
PULL_REQUEST_READ_MODEL_DERIVED,
|
|
30
|
+
pullRequestReadModel,
|
|
31
|
+
} from "./pullRequestReadModel.ts";
|
|
32
|
+
|
|
33
|
+
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
34
|
+
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
35
|
+
|
|
36
|
+
const READ_MODEL_MIGRATION = "094_pull_requests_read_model.sql";
|
|
37
|
+
|
|
38
|
+
// The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
|
|
39
|
+
// hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
|
|
40
|
+
function baseColumns(): string[] {
|
|
41
|
+
const db = new DatabaseSync(":memory:");
|
|
42
|
+
applyMigrationSet(db, readMigrationSetFromDisk());
|
|
43
|
+
const cols = (db.prepare("PRAGMA table_info(pull_requests)").all() as { name: string }[]).map((r) => r.name);
|
|
44
|
+
db.close();
|
|
45
|
+
return cols;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A minimal in-memory DB carrying the base `pull_requests` shape the VIEW reads, plus a stand-in for
|
|
49
|
+
// the managed `pull_requests__tracking` derived VIEW urban provisions at mount (re-exporting `pr.*`
|
|
50
|
+
// plus the terminal-folded `derived_status`). `derived_status_override` models the reconciler's
|
|
51
|
+
// `onTerminated` edge (a terminated instance ⇒ `abandoned` while base `status` stays frozen). Then
|
|
52
|
+
// migration 094 (the read model VIEW) is applied on top.
|
|
53
|
+
function viewDb(): DatabaseSync {
|
|
54
|
+
const db = new DatabaseSync(":memory:");
|
|
55
|
+
const cols = baseColumns().filter((c) => c !== "pr_key");
|
|
56
|
+
db.exec(
|
|
57
|
+
`CREATE TABLE pull_requests (
|
|
58
|
+
pr_key TEXT PRIMARY KEY,
|
|
59
|
+
${cols.map((c) => `${c} TEXT`).join(",\n ")},
|
|
60
|
+
derived_status_override TEXT);
|
|
61
|
+
CREATE VIEW pull_requests__tracking AS
|
|
62
|
+
SELECT p.*, COALESCE(p.derived_status_override, p.status) AS derived_status FROM pull_requests p;`,
|
|
63
|
+
);
|
|
64
|
+
db.exec(MIG(READ_MODEL_MIGRATION));
|
|
65
|
+
return db;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addPr(
|
|
69
|
+
db: DatabaseSync,
|
|
70
|
+
pr_key: string,
|
|
71
|
+
opts: { status: string; acknowledged_at?: string | null; derived_status_override?: string | null },
|
|
72
|
+
): void {
|
|
73
|
+
db.prepare(
|
|
74
|
+
"INSERT INTO pull_requests (pr_key, status, acknowledged_at, derived_status_override) VALUES (?, ?, ?, ?)",
|
|
75
|
+
).run(pr_key, opts.status, opts.acknowledged_at ?? null, opts.derived_status_override ?? null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function bucket(db: DatabaseSync, pr_key: string): { list_bucket: string; ack_open: number; status: string } {
|
|
79
|
+
const r = db
|
|
80
|
+
.prepare("SELECT list_bucket, ack_open, status FROM pull_requests_read_model WHERE pr_key = ?")
|
|
81
|
+
.get(pr_key) as { list_bucket: string; ack_open: number; status: string };
|
|
82
|
+
return { list_bucket: r.list_bucket, ack_open: r.ack_open, status: r.status };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parityDb(db: DatabaseSync): ParityDb {
|
|
86
|
+
return {
|
|
87
|
+
exec: (sql) => db.exec(sql),
|
|
88
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) => db.prepare(sql).all(...(params as never[])) as T[],
|
|
89
|
+
run: (sql, params: unknown[] = []) => {
|
|
90
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
91
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── 1. DRIFT GUARD ────────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
test("DRIFT GUARD: migration 094 embeds each derived column VERBATIM from pullRequestReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
|
|
99
|
+
const sql = MIG(READ_MODEL_MIGRATION);
|
|
100
|
+
const alias = PULL_REQUEST_READ_MODEL_BASE_ALIAS;
|
|
101
|
+
for (const c of PULL_REQUEST_READ_MODEL_DERIVED) {
|
|
102
|
+
const emitted = pullRequestReadModel.sqlSelectFor(c, { baseAlias: alias });
|
|
103
|
+
assert(
|
|
104
|
+
sql.includes(`${emitted} AS ${c}`),
|
|
105
|
+
`migration ${READ_MODEL_MIGRATION} no longer embeds the declaration's SQL for "${c}" — regenerate it from ` +
|
|
106
|
+
`app/pullRequestReadModel.ts. Expected to contain:\n ${emitted} AS ${c}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
assert(/DROP VIEW IF EXISTS pull_requests_read_model;/.test(sql), "094 must DROP the VIEW first");
|
|
110
|
+
assert(/CREATE VIEW pull_requests_read_model AS/.test(sql), "094 must (re)create pull_requests_read_model");
|
|
111
|
+
// Base identity pass-throughs — DERIVED from the REAL `pull_requests` schema, NOT a hand-kept list:
|
|
112
|
+
// the VIEW must re-export EVERY base column so the static pages↔schema contract guard sees them (and a
|
|
113
|
+
// future regeneration can't drop one without failing here). `status` is the one exception — exposed as
|
|
114
|
+
// the effective COALESCE below, not a bare pass-through — so it is asserted separately.
|
|
115
|
+
const cols = baseColumns();
|
|
116
|
+
assert(cols.length > 0, "the migration chain must create the pull_requests base table");
|
|
117
|
+
for (const base of cols) {
|
|
118
|
+
if (base === "status") continue;
|
|
119
|
+
assert(sql.includes(`pr.${base} AS ${base}`), `094 must pass base column "${base}" through the VIEW (derived from the real pull_requests schema)`);
|
|
120
|
+
}
|
|
121
|
+
assert(sql.includes("COALESCE(pr.derived_status, pr.status) AS status"), "094 must expose the effective status so the pages' Status cell + any status reader track a terminated PR");
|
|
122
|
+
assert(sql.includes(`FROM ${pullRequestReadModel.decl.baseTable} ${alias}`), `094's FROM must be the declaration's baseTable "${pullRequestReadModel.decl.baseTable}"`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── 2. FRAMEWORK PARITY GUARD ──────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
test("FRAMEWORK PARITY GUARD: pullRequestReadModel's SQL and TS lowerings agree over the status × acknowledged matrix (assertReadModelParity)", () => {
|
|
128
|
+
const samples: ParitySample[] = [];
|
|
129
|
+
for (const status of ["waiting_review", "converging", "merged", "converged", "abandoned", "closed", "failed"]) {
|
|
130
|
+
for (const derived_status of [status, "abandoned"]) {
|
|
131
|
+
for (const acknowledged_at of [null, "2026-02-02T00:00:00Z"]) {
|
|
132
|
+
samples.push({ baseRow: { pr_key: "self", status, derived_status, acknowledged_at }, lookups: {} });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const db = new DatabaseSync(":memory:");
|
|
137
|
+
assertReadModelParity(pullRequestReadModel, parityDb(db), samples, { sql: { baseAlias: PULL_REQUEST_READ_MODEL_BASE_ALIAS } });
|
|
138
|
+
db.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── 3. END-TO-END BEHAVIOUR on the real migration VIEW ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
test("ACKNOWLEDGE-TO-DISMISS: a live PR is active with no Dismiss; a terminal-but-unacknowledged PR STAYS active + offers Dismiss; once acknowledged it drops to history", () => {
|
|
144
|
+
const db = viewDb();
|
|
145
|
+
// Live (in-flight convergence states) — active, no dismiss.
|
|
146
|
+
addPr(db, "live-review", { status: "waiting_review" });
|
|
147
|
+
addPr(db, "live-conv", { status: "converging" });
|
|
148
|
+
// Terminal, not yet dismissed — the uniform rule keeps each ACTIVE (not History) with the Dismiss flag.
|
|
149
|
+
for (const s of PR_TERMINAL_STATUSES) addPr(db, `term-${s}`, { status: s });
|
|
150
|
+
// Terminal AND acknowledged — dropped to History, Dismiss retracted.
|
|
151
|
+
addPr(db, "merged-ack", { status: "merged", acknowledged_at: "2026-03-03T00:00:00Z" });
|
|
152
|
+
// Derive-only terminated (base frozen at an in-flight status, engine truth 'abandoned'), unacknowledged.
|
|
153
|
+
addPr(db, "derive-term", { status: "converging", derived_status_override: "abandoned" });
|
|
154
|
+
// A stray ack on a still-live PR must NOT drag it to History (ack only bites once terminal).
|
|
155
|
+
addPr(db, "live-stray-ack", { status: "converging", acknowledged_at: "2026-03-03T00:00:00Z" });
|
|
156
|
+
|
|
157
|
+
assertEquals(bucket(db, "live-review"), { list_bucket: "active", ack_open: 0, status: "waiting_review" });
|
|
158
|
+
assertEquals(bucket(db, "live-conv"), { list_bucket: "active", ack_open: 0, status: "converging" });
|
|
159
|
+
for (const s of PR_TERMINAL_STATUSES) {
|
|
160
|
+
assertEquals(bucket(db, `term-${s}`), { list_bucket: "active", ack_open: 1, status: s });
|
|
161
|
+
}
|
|
162
|
+
assertEquals(bucket(db, "merged-ack"), { list_bucket: "history", ack_open: 0, status: "merged" });
|
|
163
|
+
assertEquals(bucket(db, "derive-term"), { list_bucket: "active", ack_open: 1, status: "abandoned" });
|
|
164
|
+
assertEquals(bucket(db, "live-stray-ack"), { list_bucket: "active", ack_open: 0, status: "converging" });
|
|
165
|
+
db.close();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── 4. PAGE BINDINGS ──────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function grids(page: unknown): Array<Record<string, unknown>> {
|
|
171
|
+
const out: Array<Record<string, unknown>> = [];
|
|
172
|
+
const walk = (node: unknown): void => {
|
|
173
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
174
|
+
if (node && typeof node === "object") {
|
|
175
|
+
const o = node as Record<string, unknown>;
|
|
176
|
+
if (o.type === "dataGrid") out.push(o);
|
|
177
|
+
for (const v of Object.values(o)) walk(v);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
walk(page);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const { pageName, title } of [
|
|
185
|
+
{ pageName: "overview.page.json", title: "Active PR convergences" },
|
|
186
|
+
{ pageName: "home.page.json", title: "Pull requests" },
|
|
187
|
+
]) {
|
|
188
|
+
test(`${pageName} '${title}' grid binds the derived pull_requests_read_model VIEW and buckets on list_bucket (not a base-status allowlist over the raw table)`, () => {
|
|
189
|
+
const page = PAGE(pageName);
|
|
190
|
+
const grid = grids(page).find((g) => (g.props as Record<string, unknown>)?.title === title);
|
|
191
|
+
assert(grid, `${pageName} must carry the "${title}" grid`);
|
|
192
|
+
const props = grid.props as Record<string, unknown>;
|
|
193
|
+
const data = props.data as Record<string, unknown>;
|
|
194
|
+
assertEquals(data.table, "pull_requests_read_model");
|
|
195
|
+
|
|
196
|
+
// Every activeness filter (main + tabs) buckets on `list_bucket`; none re-encodes a base-status allowlist.
|
|
197
|
+
const tabs = (Array.isArray(props.tabs) ? props.tabs : []) as Array<Record<string, unknown>>;
|
|
198
|
+
const filters = [data.filter, ...tabs.map((t) => t.filter)].filter(Array.isArray) as Array<Array<Record<string, unknown>>>;
|
|
199
|
+
let sawListBucket = false;
|
|
200
|
+
for (const f of filters) {
|
|
201
|
+
for (const pred of f) {
|
|
202
|
+
assert(pred.field !== "status", `${pageName} "${title}" must not filter a base-status allowlist — bucket on list_bucket`);
|
|
203
|
+
if (pred.field === "list_bucket") sawListBucket = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
assert(sawListBucket, `${pageName} "${title}" must filter the derived list_bucket`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The `pull_requests_read_model` derived read model — DECLARED ONCE and compiled to BOTH backends via
|
|
2
|
+
// Urban's ADR-0065 reconciling-read-model primitive (`defineReadModel`, `@nanobpm/urban`). The
|
|
3
|
+
// exemplars are app/featureReadModel.ts / app/deliveryGraphReadModel.ts; this is the Convergence-PR
|
|
4
|
+
// twin (issue #641).
|
|
5
|
+
//
|
|
6
|
+
// WHY IT EXISTS. Before #641 the Convergence (PRs) surfaces — Overview's "Active PR convergences" and
|
|
7
|
+
// home's "Pull requests" — filtered the RAW `pull_requests` table on a hand-synced base-`status`
|
|
8
|
+
// allowlist (the 7 in-flight convergence states), so a PR dropped out of Active the instant its
|
|
9
|
+
// `status` reached a terminal state, with NO operator dismiss. That is the last-but-one base-`status`
|
|
10
|
+
// allowlist #637 set out to retire. This model gives PRs the SAME acknowledge-to-dismiss behaviour as
|
|
11
|
+
// Features/Epics/Delivery-Graphs: a terminal PR STAYS in Active until an operator dismisses it
|
|
12
|
+
// (`acknowledgePr` stamps `acknowledged_at`), then drops to History — the derived `list_bucket`
|
|
13
|
+
// (app/listBucket.ts, the ONE shared oracle) is the single activeness predicate every "Active …" grid
|
|
14
|
+
// now filters.
|
|
15
|
+
//
|
|
16
|
+
// BASE TABLE. Reads the auto-provisioned `pull_requests__tracking` derived VIEW (ADR-0065), NOT the raw
|
|
17
|
+
// `pull_requests` table: it re-exports `pull_requests.*` plus a terminal-folded `derived_status` (the
|
|
18
|
+
// `instanceTracking` reconciler's `onTerminated → abandoned` edge, recomputed on read), so a
|
|
19
|
+
// terminated PR classifies on ENGINE TRUTH — exactly as `feature_read_model` reads
|
|
20
|
+
// `feature_runs__tracking`.
|
|
21
|
+
|
|
22
|
+
import { defineReadModel, type Expr, type ReadModel } from "@nanobpm/urban";
|
|
23
|
+
import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
|
|
24
|
+
|
|
25
|
+
/** The base table the read model reads: the auto-provisioned `pull_requests__tracking` derived VIEW
|
|
26
|
+
* (ADR-0065), NOT the raw `pull_requests` table — so the status-classifying derivations read the
|
|
27
|
+
* terminal-folded `derived_status` and a terminated PR drops to History with no worker write. */
|
|
28
|
+
export const PULL_REQUEST_READ_MODEL_BASE_TABLE = "pull_requests__tracking";
|
|
29
|
+
|
|
30
|
+
/** The base alias the managed VIEW gives `pull_requests__tracking` — pinned so the emitted derived-
|
|
31
|
+
* column SQL (`pr."col"`) matches the migration exactly (the drift guard compares against this alias). */
|
|
32
|
+
export const PULL_REQUEST_READ_MODEL_BASE_ALIAS = "pr";
|
|
33
|
+
|
|
34
|
+
/** The effective (terminal-folded) status column the bucket/ack derivations classify on — the tracking
|
|
35
|
+
* VIEW's `derived_status`. Single source of truth for the name so the derivations can't drift from it. */
|
|
36
|
+
export const EFFECTIVE_STATUS_COLUMN = "derived_status";
|
|
37
|
+
|
|
38
|
+
/** The PR statuses that are TERMINAL for the Active/History partition (issue #641). A PR in any of
|
|
39
|
+
* these has finished its convergence/merge lifecycle: `merged` (landed), `converged` (review-only
|
|
40
|
+
* consensus, no auto-merge), `abandoned` (cancelled / out-of-band terminated — the reconciler's
|
|
41
|
+
* `onTerminated` edge), `closed` (the PR was closed on GitHub without merging), `failed` (a terminal
|
|
42
|
+
* merge/convergence failure). Distinct from `deliveryStatuses.ts`'s `TERMINAL_STATUSES` (the epic-
|
|
43
|
+
* delivery in-flight fold, which counts `converged`/`merged`/`abandoned` only): this is the FULL PR
|
|
44
|
+
* terminal tier the acknowledge-to-dismiss rule folds to History. */
|
|
45
|
+
export const PR_TERMINAL_STATUSES: readonly string[] = ["merged", "converged", "abandoned", "closed", "failed"];
|
|
46
|
+
|
|
47
|
+
/** The Active/History partition — `history` IFF the PR is terminal AND acknowledged, else `active`
|
|
48
|
+
* (live PRs + terminal-but-UNACKNOWLEDGED PRs that stay actionable until dismissed). The ONE shared
|
|
49
|
+
* oracle (app/listBucket.ts) parameterised by {@link PR_TERMINAL_STATUSES}. */
|
|
50
|
+
const listBucket: Expr = deriveListBucketExpr(EFFECTIVE_STATUS_COLUMN, PR_TERMINAL_STATUSES);
|
|
51
|
+
|
|
52
|
+
/** The operator "Dismiss" affordance flag — `1` IFF the PR is terminal AND not yet acknowledged (so the
|
|
53
|
+
* page's `showWhenField` Dismiss button renders only for a terminal-but-unacknowledged PR), else `0`. */
|
|
54
|
+
const ackOpen: Expr = deriveAckOpenExpr(EFFECTIVE_STATUS_COLUMN, PR_TERMINAL_STATUSES);
|
|
55
|
+
|
|
56
|
+
/** The keys of {@link pullRequestReadModel}'s DERIVED columns, in the order the migration emits them.
|
|
57
|
+
* Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
|
|
58
|
+
export const PULL_REQUEST_READ_MODEL_DERIVED = ["list_bucket", "ack_open"] as const;
|
|
59
|
+
export type PullRequestReadModelDerivedColumn = (typeof PULL_REQUEST_READ_MODEL_DERIVED)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The declare-once `pull_requests_read_model` derived columns. `selectBaseColumns: false` because the
|
|
63
|
+
* base columns are plain identity pass-throughs enumerated in the migration (so the static
|
|
64
|
+
* pages↔schema contract guard, which reads a VIEW's columns off an aliased select-list, sees them).
|
|
65
|
+
* Both the migration VIEW (`sqlSelectFor`, drift-guarded) and the runtime TS oracle (`fnFor`) are
|
|
66
|
+
* generated from THIS single declaration.
|
|
67
|
+
*/
|
|
68
|
+
export const pullRequestReadModel: ReadModel = defineReadModel({
|
|
69
|
+
name: "pull_requests_read_model",
|
|
70
|
+
baseTable: PULL_REQUEST_READ_MODEL_BASE_TABLE,
|
|
71
|
+
selectBaseColumns: false,
|
|
72
|
+
derive: {
|
|
73
|
+
list_bucket: listBucket,
|
|
74
|
+
ack_open: ackOpen,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- Convergence (PRs): add the `acknowledged_at` dismissal stamp + backfill currently-terminal rows
|
|
2
|
+
-- (issue #641). This is the PR half of making all four "Active …" grids behave uniformly — a row STAYS
|
|
3
|
+
-- in Active until an operator dismisses it (acknowledge-to-dismiss), then drops to History — completing
|
|
4
|
+
-- the direction set in #637 by retiring the last two base-`status` allowlists (Convergence + Delivery
|
|
5
|
+
-- Graphs). The twin stamp already exists on `feature_runs` (073) and `plans` (074).
|
|
6
|
+
--
|
|
7
|
+
-- BACKFILL IS MANDATORY (the highest-risk item). Merlin carries hundreds of already-terminal PRs (~411
|
|
8
|
+
-- merged + 35 abandoned + 5 converged at authoring). Without the backfill, repointing the Active grids
|
|
9
|
+
-- at the derived `list_bucket` (094) — which folds an UNACKNOWLEDGED terminal PR into `active` — would
|
|
10
|
+
-- dump every one of those historical PRs into Active on the next boot. So we stamp `acknowledged_at` on
|
|
11
|
+
-- every CURRENTLY-terminal row here: they load in History from day one, and only PRs that reach a
|
|
12
|
+
-- terminal state AFTER this migration require an operator dismiss (their `acknowledged_at` stays NULL
|
|
13
|
+
-- until `acknowledgePr`). Stamp = COALESCE(merged_at, converged_at, updated_at): the best available
|
|
14
|
+
-- "when it settled" timestamp, and `updated_at` is NOT NULL so the stamp is never NULL for a matched
|
|
15
|
+
-- row.
|
|
16
|
+
--
|
|
17
|
+
-- Terminal set = the PR terminal tier {merged, converged, abandoned, closed, failed}
|
|
18
|
+
-- (app/pullRequestReadModel.ts `PR_TERMINAL_STATUSES`). Classified on the base `status` (the stored
|
|
19
|
+
-- ground truth at migration time; the reconciler's `derived_status` is a read-time projection). The
|
|
20
|
+
-- `acknowledged_at IS NULL` guard keeps the backfill idempotent — re-running never re-stamps a row an
|
|
21
|
+
-- operator later dismissed with a different timestamp.
|
|
22
|
+
--
|
|
23
|
+
-- The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered after 092.
|
|
24
|
+
|
|
25
|
+
ALTER TABLE pull_requests ADD COLUMN acknowledged_at TEXT;
|
|
26
|
+
|
|
27
|
+
UPDATE pull_requests
|
|
28
|
+
SET acknowledged_at = COALESCE(merged_at, converged_at, updated_at)
|
|
29
|
+
WHERE acknowledged_at IS NULL
|
|
30
|
+
AND status IN ('merged', 'converged', 'abandoned', 'closed', 'failed');
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Convergence (PRs) read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452) — the
|
|
2
|
+
-- `pull_requests_read_model` VIEW that gives the Convergence surfaces the SAME acknowledge-to-dismiss
|
|
3
|
+
-- Active/History partition Features/Epics/Delivery-Graphs already have (issue #641).
|
|
4
|
+
--
|
|
5
|
+
-- Before #641 the Overview "Active PR convergences" and home "Pull requests" grids filtered the RAW
|
|
6
|
+
-- `pull_requests` table on a hand-synced base-`status` allowlist (the 7 in-flight convergence states),
|
|
7
|
+
-- so a PR dropped out of Active the instant its `status` reached terminal, with NO operator dismiss —
|
|
8
|
+
-- the last-but-one base-`status` allowlist #637 set out to retire. This VIEW introduces a declared
|
|
9
|
+
-- `list_bucket` (active/history) + `ack_open` (the Dismiss affordance flag) derived from the ONE shared
|
|
10
|
+
-- oracle (app/listBucket.ts): a terminal PR STAYS in `active` until `acknowledged_at` is stamped
|
|
11
|
+
-- (`acknowledgePr`), then folds to `history`.
|
|
12
|
+
--
|
|
13
|
+
-- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
|
|
14
|
+
-- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`), which
|
|
15
|
+
-- ALSO drives the runtime TS via `fnFor` — the two lowerings fall out of the same closed-DSL AST and
|
|
16
|
+
-- cannot diverge. A drift guard (app/pullRequestReadModel.test.ts) fails if this file stops matching the
|
|
17
|
+
-- declaration, and `assertReadModelParity` proves the SQL and TS lowerings agree.
|
|
18
|
+
--
|
|
19
|
+
-- SEMANTICS. The status-classifying `list_bucket`/`ack_open` read the terminal-folded `derived_status`
|
|
20
|
+
-- off the auto-provisioned `pull_requests__tracking` derived VIEW (ADR-0065), so an out-of-band-
|
|
21
|
+
-- terminated PR classifies on ENGINE TRUTH (`abandoned`) rather than a frozen base `status`. Base
|
|
22
|
+
-- columns stay aliased identity pass-throughs (so the static pages↔schema contract guard sees the
|
|
23
|
+
-- VIEW's columns), sourced off `pull_requests__tracking`'s re-export of the base `pull_requests.*`;
|
|
24
|
+
-- `status` is exposed as the effective `COALESCE(derived_status, status)` so the pages' Status cell and
|
|
25
|
+
-- any status reader track a terminated PR. `acknowledged_at` (093) passes through so the read model can
|
|
26
|
+
-- classify on it.
|
|
27
|
+
--
|
|
28
|
+
-- Forward-only VIEW definition (DROP then CREATE). `pull_requests__tracking` is the managed VIEW urban
|
|
29
|
+
-- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
|
|
30
|
+
-- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
|
|
31
|
+
-- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 093.
|
|
32
|
+
|
|
33
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
34
|
+
|
|
35
|
+
CREATE VIEW pull_requests_read_model AS
|
|
36
|
+
SELECT
|
|
37
|
+
pr.pr_key AS pr_key,
|
|
38
|
+
pr.repo AS repo,
|
|
39
|
+
pr.number AS number,
|
|
40
|
+
pr.url AS url,
|
|
41
|
+
pr.title AS title,
|
|
42
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
43
|
+
pr.current_round AS current_round,
|
|
44
|
+
pr.process_key AS process_key,
|
|
45
|
+
pr.waiting_since AS waiting_since,
|
|
46
|
+
pr.last_review_id AS last_review_id,
|
|
47
|
+
pr.outcome AS outcome,
|
|
48
|
+
pr.created_at AS created_at,
|
|
49
|
+
pr.updated_at AS updated_at,
|
|
50
|
+
pr.converged_at AS converged_at,
|
|
51
|
+
pr.merged_at AS merged_at,
|
|
52
|
+
pr.active_worker AS active_worker,
|
|
53
|
+
pr.lease_until AS lease_until,
|
|
54
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
55
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
56
|
+
pr.abandon_token AS abandon_token,
|
|
57
|
+
pr.incident_key AS incident_key,
|
|
58
|
+
pr.incident_message AS incident_message,
|
|
59
|
+
pr.last_round_head AS last_round_head,
|
|
60
|
+
pr.root_request_key AS root_request_key,
|
|
61
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
62
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
63
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
64
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
65
|
+
FROM pull_requests__tracking pr;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Delivery Graphs: add the `acknowledged_at` dismissal stamp + backfill currently-terminal runs (issue
|
|
2
|
+
-- #641). The Delivery-Graph half of the uniform acknowledge-to-dismiss behaviour (see 093 for the PR
|
|
3
|
+
-- half and the rationale): a terminal run STAYS in Active until an operator dismisses it, then drops to
|
|
4
|
+
-- History — retiring the `status IN ('awaiting-approval','running')` allowlist the pages filtered.
|
|
5
|
+
--
|
|
6
|
+
-- BACKFILL (mandatory, same risk as 093). Repointing the Active grids at the derived `list_bucket`
|
|
7
|
+
-- (096) folds an UNACKNOWLEDGED terminal run into `active`, so without a backfill every historical
|
|
8
|
+
-- terminal run would flood Active on boot. Stamp `acknowledged_at = updated_at` (a delivery-graph run
|
|
9
|
+
-- has no merged/converged timestamp; `updated_at` is its last-touch and is NOT NULL) on every currently-
|
|
10
|
+
-- terminal run so they load in History, and only runs that reach terminal AFTER this migration require
|
|
11
|
+
-- an operator dismiss.
|
|
12
|
+
--
|
|
13
|
+
-- Terminal set = {done, failed, abandoned} (app/deliveryGraphRun.ts `DELIVERY_GRAPH_TERMINAL_STATUSES`).
|
|
14
|
+
-- Classified on the base `status` (the stored ground truth at migration time). The `acknowledged_at IS
|
|
15
|
+
-- NULL` guard keeps the backfill idempotent.
|
|
16
|
+
--
|
|
17
|
+
-- The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered after 094.
|
|
18
|
+
|
|
19
|
+
ALTER TABLE delivery_graph_runs ADD COLUMN acknowledged_at TEXT;
|
|
20
|
+
|
|
21
|
+
UPDATE delivery_graph_runs
|
|
22
|
+
SET acknowledged_at = updated_at
|
|
23
|
+
WHERE acknowledged_at IS NULL
|
|
24
|
+
AND status IN ('done', 'failed', 'abandoned');
|