@nanobpm/nano-workforce 0.166.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 +6 -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/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/scripts/pages-contract.test.ts +80 -18
- package/workers/record-results/worker.test.ts +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.167.0](https://github.com/nanobpm/nano-workforce/compare/v0.166.0...v0.167.0) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **active-lists:** uniform active-until-dismissed for PRs, delivery graphs & epics ([#649](https://github.com/nanobpm/nano-workforce/issues/649)) ([9ac7135](https://github.com/nanobpm/nano-workforce/commit/9ac71353335d0c09e9959826d71d5c796e364da1)), closes [#637](https://github.com/nanobpm/nano-workforce/issues/637)
|
|
6
|
+
|
|
1
7
|
## [0.166.0](https://github.com/nanobpm/nano-workforce/compare/v0.165.0...v0.166.0) (2026-08-31)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Backfill coverage for the acknowledge-to-dismiss migrations (issue #641). The HIGHEST-RISK item:
|
|
2
|
+
// repointing the four "Active …" grids at the derived `list_bucket` — which folds an UNACKNOWLEDGED
|
|
3
|
+
// terminal row into `active` — would flood every historical terminal PR / delivery-graph run into
|
|
4
|
+
// Active on the next boot. Migrations 093 (PRs) and 095 (delivery graphs) prevent that by stamping
|
|
5
|
+
// `acknowledged_at` on every CURRENTLY-terminal row, so they load in History from day one, while rows
|
|
6
|
+
// that reach terminal AFTER the migration stay in Active until an operator dismisses them.
|
|
7
|
+
//
|
|
8
|
+
// This test reproduces the real upgrade path: apply the migration chain UP TO (but not including) the
|
|
9
|
+
// `acknowledged_at` additions, seed pre-existing rows the way a live DB carries them (terminal + live,
|
|
10
|
+
// NO acknowledged_at column yet), then apply the remaining migrations (093/094/095/096/097 …) and read
|
|
11
|
+
// the derived read-model VIEWs to prove the resulting Active/History partition.
|
|
12
|
+
import { DatabaseSync } from "node:sqlite";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.ts";
|
|
16
|
+
|
|
17
|
+
// The pre-`acknowledged_at` schema slice (everything numbered below 093, lexically) — the base
|
|
18
|
+
// `pull_requests` (001) and `delivery_graph_runs` (058) tables exist here, WITHOUT the dismissal stamp.
|
|
19
|
+
function migrationsBefore093() {
|
|
20
|
+
return readMigrationSetFromDisk().filter((f) => f.name < "093");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function backfillDb(): DatabaseSync {
|
|
24
|
+
const db = new DatabaseSync(":memory:");
|
|
25
|
+
const files = readMigrationSetFromDisk();
|
|
26
|
+
// Phase 1: schema as it stood before the dismissal stamp.
|
|
27
|
+
applyMigrationSet(db, migrationsBefore093());
|
|
28
|
+
|
|
29
|
+
// Seed pre-existing rows exactly as a live DB carries them — no acknowledged_at column yet.
|
|
30
|
+
const insPr = (pr_key: string, status: string) =>
|
|
31
|
+
db
|
|
32
|
+
.prepare(
|
|
33
|
+
"INSERT INTO pull_requests (pr_key, repo, number, url, status, created_at, updated_at, merged_at) VALUES (?, 'o/r', 1, 'https://x', ?, '2025-01-01T00:00:00Z', '2025-06-01T00:00:00Z', ?)",
|
|
34
|
+
)
|
|
35
|
+
.run(pr_key, status, status === "merged" ? "2025-06-01T00:00:00Z" : null);
|
|
36
|
+
// Terminal (must backfill → History) + live (must stay Active).
|
|
37
|
+
for (const s of ["merged", "converged", "abandoned", "closed", "failed"]) insPr(`pre-${s}`, s);
|
|
38
|
+
insPr("pre-live", "converging");
|
|
39
|
+
|
|
40
|
+
const insDg = (run_key: string, status: string) =>
|
|
41
|
+
db
|
|
42
|
+
.prepare(
|
|
43
|
+
"INSERT INTO delivery_graph_runs (run_key, digest, status, created_at, updated_at) VALUES (?, 'dig', ?, '2025-01-01T00:00:00Z', '2025-06-01T00:00:00Z')",
|
|
44
|
+
)
|
|
45
|
+
.run(run_key, status);
|
|
46
|
+
for (const s of ["done", "failed", "abandoned"]) insDg(`pre-${s}`, s);
|
|
47
|
+
insDg("pre-live", "running");
|
|
48
|
+
|
|
49
|
+
// Phase 2: apply the remaining migrations — 093/095 add the column + backfill the pre-existing
|
|
50
|
+
// terminal rows, 094/096 (re)create the read-model VIEWs. applyMigrationSet skips the already-applied.
|
|
51
|
+
applyMigrationSet(db, files);
|
|
52
|
+
|
|
53
|
+
// Stand-ins for the managed `<table>__tracking` derived VIEWs urban provisions at mount (pass-through
|
|
54
|
+
// `derived_status := base.status`, modelling settled rows) — the read-model VIEWs read these.
|
|
55
|
+
db.exec(
|
|
56
|
+
`CREATE VIEW pull_requests__tracking AS SELECT p.*, p.status AS derived_status FROM pull_requests p;
|
|
57
|
+
CREATE VIEW delivery_graph_runs__tracking AS SELECT d.*, d.status AS derived_status FROM delivery_graph_runs d;`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// A row that reaches terminal AFTER the migration — acknowledged_at stays NULL, so it must stay Active.
|
|
61
|
+
db.prepare(
|
|
62
|
+
"INSERT INTO pull_requests (pr_key, repo, number, url, status, created_at, updated_at) VALUES ('post-merged', 'o/r', 2, 'https://y', 'merged', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
|
|
63
|
+
).run();
|
|
64
|
+
db.prepare(
|
|
65
|
+
"INSERT INTO delivery_graph_runs (run_key, digest, status, created_at, updated_at) VALUES ('post-done', 'dig', 'done', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
|
|
66
|
+
).run();
|
|
67
|
+
return db;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function prBucket(db: DatabaseSync, pr_key: string) {
|
|
71
|
+
return db.prepare("SELECT list_bucket, ack_open, acknowledged_at FROM pull_requests_read_model WHERE pr_key = ?").get(pr_key) as {
|
|
72
|
+
list_bucket: string;
|
|
73
|
+
ack_open: number;
|
|
74
|
+
acknowledged_at: string | null;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function dgBucket(db: DatabaseSync, run_key: string) {
|
|
78
|
+
return db.prepare("SELECT list_bucket, ack_open, acknowledged_at FROM delivery_graph_read_model WHERE run_key = ?").get(run_key) as {
|
|
79
|
+
list_bucket: string;
|
|
80
|
+
ack_open: number;
|
|
81
|
+
acknowledged_at: string | null;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("migration 093 backfill: every pre-existing terminal PR loads in History (acknowledged_at stamped); a live PR stays Active; a post-migration terminal PR stays Active until dismissed", () => {
|
|
86
|
+
const db = backfillDb();
|
|
87
|
+
for (const s of ["merged", "converged", "abandoned", "closed", "failed"]) {
|
|
88
|
+
const b = prBucket(db, `pre-${s}`);
|
|
89
|
+
assert(b.acknowledged_at !== null, `pre-existing terminal PR (${s}) must be backfilled with acknowledged_at`);
|
|
90
|
+
assertEquals(b.list_bucket, "history", `pre-existing terminal PR (${s}) must load in History`);
|
|
91
|
+
assertEquals(b.ack_open, 0);
|
|
92
|
+
}
|
|
93
|
+
// A live PR was never terminal → not backfilled → Active, no Dismiss.
|
|
94
|
+
const live = prBucket(db, "pre-live");
|
|
95
|
+
assertEquals(live.acknowledged_at, null);
|
|
96
|
+
assertEquals(live.list_bucket, "active");
|
|
97
|
+
// A PR that settled AFTER the migration is NOT auto-dismissed — stays Active with the Dismiss flag.
|
|
98
|
+
const post = prBucket(db, "post-merged");
|
|
99
|
+
assertEquals(post.acknowledged_at, null);
|
|
100
|
+
assertEquals(post.list_bucket, "active");
|
|
101
|
+
assertEquals(post.ack_open, 1);
|
|
102
|
+
db.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("migration 095 backfill: every pre-existing terminal delivery-graph run loads in History; a live run stays Active; a post-migration terminal run stays Active until dismissed", () => {
|
|
106
|
+
const db = backfillDb();
|
|
107
|
+
for (const s of ["done", "failed", "abandoned"]) {
|
|
108
|
+
const b = dgBucket(db, `pre-${s}`);
|
|
109
|
+
assert(b.acknowledged_at !== null, `pre-existing terminal run (${s}) must be backfilled with acknowledged_at`);
|
|
110
|
+
assertEquals(b.list_bucket, "history", `pre-existing terminal run (${s}) must load in History`);
|
|
111
|
+
assertEquals(b.ack_open, 0);
|
|
112
|
+
}
|
|
113
|
+
const live = dgBucket(db, "pre-live");
|
|
114
|
+
assertEquals(live.acknowledged_at, null);
|
|
115
|
+
assertEquals(live.list_bucket, "active");
|
|
116
|
+
const post = dgBucket(db, "post-done");
|
|
117
|
+
assertEquals(post.acknowledged_at, null);
|
|
118
|
+
assertEquals(post.list_bucket, "active");
|
|
119
|
+
assertEquals(post.ack_open, 1);
|
|
120
|
+
db.close();
|
|
121
|
+
});
|
package/app/delivery.ts
CHANGED
|
@@ -121,10 +121,11 @@ export const EPIC_LIVE_STATUSES = ["planning", "dispatched"] as const;
|
|
|
121
121
|
* Dismiss affordance stays closed (see {@link epicIsAcknowledgeable}) so it is never ticked off
|
|
122
122
|
* mid-flight.
|
|
123
123
|
*
|
|
124
|
-
* It falls to `history` only once truly resolved:
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
124
|
+
* It falls to `history` only once truly resolved AND acknowledged: any TERMINAL epic — `done`,
|
|
125
|
+
* `failed`, or `abandoned` — that the operator has dismissed (issue #641 made this uniform; before it,
|
|
126
|
+
* a `failed`/`abandoned` epic dropped straight to History with no tick-off). An unacknowledged terminal
|
|
127
|
+
* epic of ANY terminal status stays Active until dismissed. Pure and read-only; projected at write time
|
|
128
|
+
* by the `plans` gateway (app/plan.ts) onto `plans.list_bucket`. */
|
|
128
129
|
export function deriveEpicBucket(
|
|
129
130
|
status: string,
|
|
130
131
|
delivery: string | null | undefined,
|
|
@@ -134,15 +135,16 @@ export function deriveEpicBucket(
|
|
|
134
135
|
return raw === "active" ? "active" : "history";
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
/** True iff an epic carries the operator "Dismiss" (acknowledge) affordance — a
|
|
138
|
-
* fan-out has RESOLVED (it is no longer `converging`): every slice PR
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* but-
|
|
138
|
+
/** True iff an epic carries the operator "Dismiss" (acknowledge) affordance — a TERMINAL epic whose
|
|
139
|
+
* fan-out has RESOLVED (it is no longer `converging`): a `done` epic whose every slice PR reached a
|
|
140
|
+
* terminal state (all merged, `delivery = landed` — promote to main, then dismiss; or resolved-not-
|
|
141
|
+
* landed, `delivery = null` — some abandoned/converged), OR a `failed`/`abandoned` epic (whose
|
|
142
|
+
* `delivery` is inherently non-`converging`, so it is dismissable outright — issue #641). This is the
|
|
143
|
+
* set of Active epics a tick-off may move to History. A live (`planning`/`dispatched`) or still-
|
|
144
|
+
* `converging` epic is genuinely working — nothing to tick off — so its Dismiss stays closed. The
|
|
145
|
+
* `acknowledgeEpic` operation guards on this (409 otherwise) and the gateway projects it to
|
|
146
|
+
* `plans.ack_open` (1/0) so the page's `showWhenField` Dismiss button renders only for a resolved-but-
|
|
147
|
+
* unacknowledged epic. */
|
|
146
148
|
export function epicIsAcknowledgeable(
|
|
147
149
|
status: string,
|
|
148
150
|
delivery: string | null | undefined,
|
|
@@ -39,7 +39,7 @@ import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.
|
|
|
39
39
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
40
40
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
41
41
|
|
|
42
|
-
const READ_MODEL_MIGRATION = "
|
|
42
|
+
const READ_MODEL_MIGRATION = "096_delivery_graph_read_model_list_bucket.sql";
|
|
43
43
|
|
|
44
44
|
// A minimal in-memory DB carrying the base `delivery_graph_runs` / `pull_requests` shapes the VIEW
|
|
45
45
|
// reads, plus stand-ins for the managed `<table>__tracking` derived VIEWs urban provisions at mount
|
|
@@ -53,7 +53,7 @@ function viewDb(): DatabaseSync {
|
|
|
53
53
|
run_key TEXT PRIMARY KEY, process_key TEXT, process_definition_id TEXT, digest TEXT,
|
|
54
54
|
status TEXT, side_effecting INTEGER, node_count INTEGER, human_node_count INTEGER,
|
|
55
55
|
side_effect_count INTEGER, title TEXT, phase TEXT, phase_node_id TEXT, human_labels TEXT,
|
|
56
|
-
created_at TEXT, updated_at TEXT, derived_status_override TEXT);
|
|
56
|
+
created_at TEXT, updated_at TEXT, acknowledged_at TEXT, derived_status_override TEXT);
|
|
57
57
|
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, root_request_key TEXT, status TEXT,
|
|
58
58
|
derived_status_override TEXT);`,
|
|
59
59
|
);
|
|
@@ -72,6 +72,7 @@ interface SampleRun {
|
|
|
72
72
|
phase?: string | null;
|
|
73
73
|
phase_node_id?: string | null;
|
|
74
74
|
derived_status_override?: string | null;
|
|
75
|
+
acknowledged_at?: string | null;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function addRun(db: DatabaseSync, run_key: string, run: SampleRun): void {
|
|
@@ -79,8 +80,8 @@ function addRun(db: DatabaseSync, run_key: string, run: SampleRun): void {
|
|
|
79
80
|
`INSERT INTO delivery_graph_runs
|
|
80
81
|
(run_key, process_key, process_definition_id, digest, status, side_effecting, node_count,
|
|
81
82
|
human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, created_at,
|
|
82
|
-
updated_at, derived_status_override)
|
|
83
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
83
|
+
updated_at, acknowledged_at, derived_status_override)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
84
85
|
).run(
|
|
85
86
|
run_key,
|
|
86
87
|
`pk-${run_key}`,
|
|
@@ -97,6 +98,7 @@ function addRun(db: DatabaseSync, run_key: string, run: SampleRun): void {
|
|
|
97
98
|
null,
|
|
98
99
|
"2026-01-01T00:00:00Z",
|
|
99
100
|
"2026-01-01T00:00:00Z",
|
|
101
|
+
run.acknowledged_at ?? null,
|
|
100
102
|
run.derived_status_override ?? null,
|
|
101
103
|
);
|
|
102
104
|
}
|
|
@@ -210,10 +212,12 @@ test("FRAMEWORK PARITY GUARD: deliveryGraphReadModel's SQL and TS lowerings agre
|
|
|
210
212
|
for (const status of ["awaiting-approval", "running", "done", "failed", "abandoned"]) {
|
|
211
213
|
for (const derived_status of [status, "failed"]) {
|
|
212
214
|
for (const prs_in_flight of [0, 1, 3]) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
for (const acknowledged_at of [null, "2026-02-02T00:00:00Z"]) {
|
|
216
|
+
samples.push({
|
|
217
|
+
baseRow: { run_key: "self", status, derived_status, acknowledged_at },
|
|
218
|
+
lookups: { [PR_COUNTS_LOOKUP]: [{ root_request_key: "self", prs_in_flight }] },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
217
221
|
}
|
|
218
222
|
}
|
|
219
223
|
}
|
|
@@ -292,6 +296,38 @@ test("park_label carries the actionable 'Parked on human node: <label>' text (on
|
|
|
292
296
|
db.close();
|
|
293
297
|
});
|
|
294
298
|
|
|
299
|
+
// ── 3b. ACKNOWLEDGE-TO-DISMISS: list_bucket / ack_open (issue #641) ────────────────────────────────
|
|
300
|
+
|
|
301
|
+
function bucket(db: DatabaseSync, run_key: string): { list_bucket: string; ack_open: number } {
|
|
302
|
+
const r = db.prepare("SELECT list_bucket, ack_open FROM delivery_graph_read_model WHERE run_key = ?").get(run_key) as {
|
|
303
|
+
list_bucket: string;
|
|
304
|
+
ack_open: number;
|
|
305
|
+
};
|
|
306
|
+
return { list_bucket: r.list_bucket, ack_open: r.ack_open };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
test("a live run is active with no Dismiss; a terminal-but-unacknowledged run STAYS active and offers Dismiss; once acknowledged it drops to history", () => {
|
|
310
|
+
const db = viewDb();
|
|
311
|
+
// Live (running) — active, no dismiss.
|
|
312
|
+
addRun(db, "live", { status: "running", phase: "Running" });
|
|
313
|
+
// Terminal, not yet dismissed — the uniform rule keeps it ACTIVE (not History) with the Dismiss flag.
|
|
314
|
+
addRun(db, "done-open", { status: "done", phase: "Completed" });
|
|
315
|
+
addRun(db, "failed-open", { status: "failed", phase: "Failed" });
|
|
316
|
+
addRun(db, "aband-open", { status: "abandoned", phase: "Failed" });
|
|
317
|
+
// Terminal AND acknowledged — dropped to History, Dismiss retracted.
|
|
318
|
+
addRun(db, "done-ack", { status: "done", phase: "Completed", acknowledged_at: "2026-03-03T00:00:00Z" });
|
|
319
|
+
// Derive-only terminated (base frozen 'running', derived 'failed'), unacknowledged — still active/dismissable.
|
|
320
|
+
addRun(db, "derive-term", { status: "running", phase: "Running", derived_status_override: "failed" });
|
|
321
|
+
|
|
322
|
+
assertEquals(bucket(db, "live"), { list_bucket: "active", ack_open: 0 });
|
|
323
|
+
assertEquals(bucket(db, "done-open"), { list_bucket: "active", ack_open: 1 });
|
|
324
|
+
assertEquals(bucket(db, "failed-open"), { list_bucket: "active", ack_open: 1 });
|
|
325
|
+
assertEquals(bucket(db, "aband-open"), { list_bucket: "active", ack_open: 1 });
|
|
326
|
+
assertEquals(bucket(db, "done-ack"), { list_bucket: "history", ack_open: 0 });
|
|
327
|
+
assertEquals(bucket(db, "derive-term"), { list_bucket: "active", ack_open: 1 });
|
|
328
|
+
db.close();
|
|
329
|
+
});
|
|
330
|
+
|
|
295
331
|
// ── 4. PARITY vs TODAY'S PHASE (the acceptance parity) ────────────────────────────────────────────
|
|
296
332
|
|
|
297
333
|
test("the derived stepper matches TODAY's plain `phase` text (deriveDeliveryPhase) for representative runs, retaining the actionable park label", () => {
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
// single-step graph both reduce trivially to their one branch.
|
|
41
41
|
|
|
42
42
|
import { and, caseWhen, col, countWhere, defineReadModel, defineRollup, type Expr, eq, fromTable, gt, isNotNull, lit, not, or, type ReadModel, type Rollup, rcol, when } from "@nanobpm/urban";
|
|
43
|
+
import { DELIVERY_GRAPH_TERMINAL_STATUSES } from "./deliveryGraphRun.ts";
|
|
43
44
|
import { TERMINAL_STATUSES } from "./deliveryStatuses.ts";
|
|
45
|
+
import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
|
|
44
46
|
import { PR_TRACKING_RELATION } from "./planRollups.ts";
|
|
45
47
|
|
|
46
48
|
/** The slice-PR relation the member-PR rollup folds over: the auto-provisioned
|
|
@@ -136,10 +138,23 @@ const stageState: Expr = caseWhen(
|
|
|
136
138
|
|
|
137
139
|
/** The keys of {@link deliveryGraphReadModel}'s DERIVED columns, in the order the migration emits them.
|
|
138
140
|
* Base columns are identity pass-throughs (listed in the migration directly); `park_label` is a
|
|
139
|
-
* hand-authored display column over the base `phase`/`phase_node_id` (no TS twin).
|
|
140
|
-
|
|
141
|
+
* hand-authored display column over the base `phase`/`phase_node_id` (no TS twin). `list_bucket`/
|
|
142
|
+
* `ack_open` are the acknowledge-to-dismiss partition + Dismiss-affordance flag (issue #641). */
|
|
143
|
+
export const DELIVERY_GRAPH_READ_MODEL_DERIVED = ["stage", "stage_state", "list_bucket", "ack_open"] as const;
|
|
141
144
|
export type DeliveryGraphReadModelDerivedColumn = (typeof DELIVERY_GRAPH_READ_MODEL_DERIVED)[number];
|
|
142
145
|
|
|
146
|
+
/** The Active/History partition — `history` IFF the run is terminal AND acknowledged, else `active`
|
|
147
|
+
* (live runs + terminal-but-UNACKNOWLEDGED runs that stay actionable until dismissed). The ONE shared
|
|
148
|
+
* oracle (app/listBucket.ts, issue #641) parameterised by {@link DELIVERY_GRAPH_TERMINAL_STATUSES}, so
|
|
149
|
+
* this grid's activeness predicate is byte-for-byte the same rule Features/Epics/PRs use — retiring the
|
|
150
|
+
* `status IN ('awaiting-approval','running')` allowlist the pages filtered before. */
|
|
151
|
+
const listBucket: Expr = deriveListBucketExpr(EFFECTIVE_STATUS_COLUMN, DELIVERY_GRAPH_TERMINAL_STATUSES);
|
|
152
|
+
|
|
153
|
+
/** The operator "Dismiss" affordance flag — `1` IFF the run is terminal AND not yet acknowledged (so
|
|
154
|
+
* the page's `showWhenField` Dismiss button renders only for a terminal-but-unacknowledged run), else
|
|
155
|
+
* `0`. */
|
|
156
|
+
const ackOpen: Expr = deriveAckOpenExpr(EFFECTIVE_STATUS_COLUMN, DELIVERY_GRAPH_TERMINAL_STATUSES);
|
|
157
|
+
|
|
143
158
|
/**
|
|
144
159
|
* The declare-once `delivery_graph_read_model` derived columns. `selectBaseColumns: false` because the
|
|
145
160
|
* base columns are plain identity pass-throughs enumerated in the migration (so the static pages↔schema
|
|
@@ -162,5 +177,7 @@ export const deliveryGraphReadModel: ReadModel = defineReadModel({
|
|
|
162
177
|
derive: {
|
|
163
178
|
stage,
|
|
164
179
|
stage_state: stageState,
|
|
180
|
+
list_bucket: listBucket,
|
|
181
|
+
ack_open: ackOpen,
|
|
165
182
|
},
|
|
166
183
|
});
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -47,6 +47,10 @@ export interface DeliveryGraphRun {
|
|
|
47
47
|
human_labels: string | null;
|
|
48
48
|
created_at: string;
|
|
49
49
|
updated_at: string;
|
|
50
|
+
/** The operator-dismissal stamp (issue #641). Set by `acknowledgeDeliveryGraph` on a TERMINAL run so
|
|
51
|
+
* the `delivery_graph_read_model` VIEW folds its `list_bucket` to 'history'; NULL while the run is
|
|
52
|
+
* live or terminal-but-undismissed (it stays in Active until an operator ticks it off). */
|
|
53
|
+
acknowledged_at: string | null;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
/** The run lifecycle. `awaiting-approval` is RESERVED but no longer produced (issue #460 moved dispatch
|
|
@@ -264,5 +268,6 @@ export function buildDeliveryGraphRunRow(input: {
|
|
|
264
268
|
human_labels: input.humanLabels ? JSON.stringify(input.humanLabels) : null,
|
|
265
269
|
created_at: input.createdAt ?? at,
|
|
266
270
|
updated_at: at,
|
|
271
|
+
acknowledged_at: null,
|
|
267
272
|
};
|
|
268
273
|
}
|
package/app/epicBucket.test.ts
CHANGED
|
@@ -43,10 +43,16 @@ test("done + delivery=null (poller-pending / resolved-not-landed) -> Active, ack
|
|
|
43
43
|
assertEquals(deriveEpicBucket("done", null, "2024-01-01T00:00:00Z"), "history");
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Issue #641 (uniform acknowledge-to-dismiss): a terminal-non-`done` epic (`failed`/`abandoned` —
|
|
47
|
+
// cancelled) now ALSO stays Active until the operator dismisses it, instead of dropping straight to
|
|
48
|
+
// History. Its `delivery` is always non-`converging`, so it is immediately acknowledgeable, and a
|
|
49
|
+
// dismiss stamp settles it to History — uniform with `done` epics and the PR / delivery-graph grids.
|
|
50
|
+
test("terminal non-done statuses (failed/abandoned) + unacknowledged -> Active, acknowledgeable (issue #641)", () => {
|
|
47
51
|
for (const status of ["failed", "abandoned"]) {
|
|
48
|
-
assertEquals(deriveEpicBucket(status, null, null), "
|
|
49
|
-
assert(
|
|
52
|
+
assertEquals(deriveEpicBucket(status, null, null), "active", `status=${status}`);
|
|
53
|
+
assert(epicIsAcknowledgeable(status, null), `status=${status}`);
|
|
54
|
+
// A dismiss stamp settles it to History, like every other terminal surface.
|
|
55
|
+
assertEquals(deriveEpicBucket(status, null, "2024-01-01T00:00:00Z"), "history", `status=${status} acknowledged`);
|
|
50
56
|
}
|
|
51
57
|
});
|
|
52
58
|
|
package/app/featureReadModel.ts
CHANGED
|
@@ -31,6 +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
35
|
|
|
35
36
|
/** The 6 TRULY-terminal statuses that map to the `Done` stage — the single source of truth for the
|
|
36
37
|
* terminal tier of BOTH the pipeline `stage`/`stage_state` derivations and the `list_bucket` history
|
|
@@ -128,18 +129,13 @@ const attention: Expr = caseWhen(
|
|
|
128
129
|
lit(null),
|
|
129
130
|
);
|
|
130
131
|
|
|
131
|
-
/** `<col> IS NOT NULL` in the closed DSL, which has no dedicated null-test operator: a SELF-equality.
|
|
132
|
-
* `eq` collapses a nullish operand to false in BOTH backends (`COALESCE(x = x, 0)` in SQL, the nullish
|
|
133
|
-
* guard in `compareValues` for TS), and any NON-null value equals itself, so this is true IFF the column
|
|
134
|
-
* is non-NULL — faithful to 073/075's `acknowledged_at IS NOT NULL` and free of the SQLite string→number
|
|
135
|
-
* truthiness coercion a bare `col(...)` boolean predicate would otherwise rely on (e.g. `''`/`'abc'`). */
|
|
136
|
-
const isNotNull = (name: string): Expr => eq(col(name), col(name));
|
|
137
|
-
|
|
138
132
|
/** The Active/History partition: `history` IFF the row is in a truly-terminal status AND has been
|
|
139
|
-
* acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs).
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
|
|
133
|
+
* acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). Delegates to the ONE
|
|
134
|
+
* shared `deriveListBucketExpr` oracle (app/listBucket.ts, issue #641) parameterised by the feature
|
|
135
|
+
* terminal set ({@link STAGE_DONE_STATUSES}) so all four "Active …" grids share the identical AST — the
|
|
136
|
+
* emitted SQL stays byte-equivalent to migration 081's already-merged VIEW body (the shared oracle
|
|
137
|
+
* reproduces this model's `isDone`/`isNotNull` forms exactly). */
|
|
138
|
+
const listBucket: Expr = deriveListBucketExpr(EFFECTIVE_STATUS_COLUMN, STAGE_DONE_STATUSES);
|
|
143
139
|
|
|
144
140
|
/** The keys of {@link featureReadModel}'s DERIVED columns, in the order migration 076 emits them.
|
|
145
141
|
* Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The ONE Active/History `list_bucket` derivation, shared byte-for-byte by every "Active …" grid's
|
|
2
|
+
// read model (issue #641). All four dispatch surfaces — Features, Epics, Convergence (PRs) and
|
|
3
|
+
// Delivery Graphs — partition their rows into Active/History with the SAME acknowledge-to-dismiss rule:
|
|
4
|
+
// a row STAYS in Active until an operator dismisses it (stamps `acknowledged_at`), then drops to
|
|
5
|
+
// History. This module is that rule expressed ONCE in Urban's closed expression DSL, parameterised by
|
|
6
|
+
// each model's own terminal-status set, so the four read models cannot drift from one another (the
|
|
7
|
+
// last two base-`status` allowlists — Convergence + Delivery Graphs — are retired by consuming it).
|
|
8
|
+
//
|
|
9
|
+
// THE RULE (uniform across the four surfaces):
|
|
10
|
+
// terminal & acknowledged NULL -> active (stay until dismissed)
|
|
11
|
+
// terminal -> history (dismissed)
|
|
12
|
+
// else (still live) -> active
|
|
13
|
+
//
|
|
14
|
+
// which is exactly `history` IFF the row is in a truly-terminal status AND has been acknowledged; else
|
|
15
|
+
// `active`. Feature runs (app/featureReadModel.ts, migration 081) already encode this shape; this is
|
|
16
|
+
// its extraction so PRs / Delivery Graphs / Epics share the identical AST rather than re-authoring it.
|
|
17
|
+
//
|
|
18
|
+
// `acknowledged_at IS NOT NULL` is expressed via {@link isAckStamped} as a SELF-equality (`eq(col,
|
|
19
|
+
// col)`), NOT a bare `col(...)` boolean or a dedicated null-test: `eq` collapses a nullish operand to
|
|
20
|
+
// false in BOTH lowerings (`COALESCE(x = x, 0)` in SQL, the nullish guard in `compareValues` for TS),
|
|
21
|
+
// and any non-null value equals itself, so it is true IFF the column is non-NULL — free of the SQLite
|
|
22
|
+
// string→number truthiness coercion a bare column predicate would rely on, and byte-equivalent to the
|
|
23
|
+
// feature model's own `isNotNull` (app/featureReadModel.ts) so the shared oracle stays identical to
|
|
24
|
+
// migration 081's already-merged VIEW body.
|
|
25
|
+
|
|
26
|
+
import { and, caseWhen, col, type Expr, eq, lit, not, or, when } from "@nanobpm/urban";
|
|
27
|
+
|
|
28
|
+
/** `<effectiveStatusCol> IN (…terminal)` as a closed-DSL predicate: an OR of equalities over the
|
|
29
|
+
* tracking VIEW's terminal-folded effective status. The single "is this row terminal?" test the
|
|
30
|
+
* bucket/ack derivations share. */
|
|
31
|
+
export const terminalStatusIn = (effectiveStatusCol: string, terminalStatuses: readonly string[]): Expr =>
|
|
32
|
+
or(...terminalStatuses.map((s) => eq(col(effectiveStatusCol), lit(s))));
|
|
33
|
+
|
|
34
|
+
/** `<ackCol> IS NOT NULL` in the closed DSL (which has no dedicated null-test operator): a SELF-equality
|
|
35
|
+
* that collapses a nullish operand to false in both lowerings, so it is true IFF the column is
|
|
36
|
+
* non-NULL. See the module header for why this exact form (not a bare `col`) is used. */
|
|
37
|
+
export const isAckStamped = (ackCol: string): Expr => eq(col(ackCol), col(ackCol));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Active/History partition over an arbitrary "dismissable-terminal" PREDICATE: `history` IFF the
|
|
41
|
+
* predicate holds AND the row has been acknowledged; otherwise `active`. The predicate captures each
|
|
42
|
+
* model's notion of a row that is BOTH terminal AND actually tick-off-able — for PRs/Delivery-Graphs/
|
|
43
|
+
* features that is simply "terminal" ({@link deriveListBucketExpr}); for EPICS it additionally excludes
|
|
44
|
+
* a still-`converging` done epic (which is terminal by `status` but must NOT be dismissable mid-flight),
|
|
45
|
+
* so a stray/premature ack never drags it to History. Shared by all four surfaces so they cannot drift.
|
|
46
|
+
*/
|
|
47
|
+
export const deriveListBucketFromTerminal = (terminalPredicate: Expr, ackCol = "acknowledged_at"): Expr =>
|
|
48
|
+
caseWhen([when(and(terminalPredicate, isAckStamped(ackCol)), lit("history"))], lit("active"));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The operator "Dismiss" affordance flag over an arbitrary "dismissable-terminal" PREDICATE: `1` IFF
|
|
52
|
+
* the predicate holds AND the row is not yet acknowledged, else `0`. The twin of {@link
|
|
53
|
+
* deriveListBucketFromTerminal} (same predicate) — a row is dismissable exactly while it would still be
|
|
54
|
+
* `active` on the terminal branch, i.e. terminal-and-unacknowledged (and, for epics, non-`converging`).
|
|
55
|
+
*/
|
|
56
|
+
export const deriveAckOpenFromTerminal = (terminalPredicate: Expr, ackCol = "acknowledged_at"): Expr =>
|
|
57
|
+
caseWhen([when(and(terminalPredicate, not(isAckStamped(ackCol))), lit(1))], lit(0));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The Active/History partition, parameterised by the model's terminal-status set: `history` IFF the
|
|
61
|
+
* row's terminal-folded effective status is terminal AND it has been acknowledged; otherwise `active`
|
|
62
|
+
* (live rows + terminal-but-UNACKNOWLEDGED rows that stay actionable until dismissed).
|
|
63
|
+
*
|
|
64
|
+
* @param effectiveStatusCol the terminal-folded status column the model classifies on (`derived_status`).
|
|
65
|
+
* @param terminalStatuses the model's terminal set (features' Done statuses, the PR terminal set, …).
|
|
66
|
+
* @param ackCol the acknowledgement column (defaults to `acknowledged_at`).
|
|
67
|
+
*/
|
|
68
|
+
export const deriveListBucketExpr = (
|
|
69
|
+
effectiveStatusCol: string,
|
|
70
|
+
terminalStatuses: readonly string[],
|
|
71
|
+
ackCol = "acknowledged_at",
|
|
72
|
+
): Expr => deriveListBucketFromTerminal(terminalStatusIn(effectiveStatusCol, terminalStatuses), ackCol);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The operator "Dismiss" (acknowledge) affordance flag: `1` IFF the row is terminal AND not yet
|
|
76
|
+
* acknowledged (so the page's `showWhenField` Dismiss button renders only for a terminal-but-
|
|
77
|
+
* unacknowledged row — never a still-live one, and never a re-dismiss of an already-filed row), else
|
|
78
|
+
* `0`. The PR / Delivery-Graph twin of the epic's `ack_open` (app/planReadModel.ts), which additionally
|
|
79
|
+
* gates on its `converging` sub-state; PRs and Delivery Graphs have no such mid-flight terminal, so
|
|
80
|
+
* "terminal ∧ unacknowledged" is the whole predicate.
|
|
81
|
+
*/
|
|
82
|
+
export const deriveAckOpenExpr = (
|
|
83
|
+
effectiveStatusCol: string,
|
|
84
|
+
terminalStatuses: readonly string[],
|
|
85
|
+
ackCol = "acknowledged_at",
|
|
86
|
+
): Expr => deriveAckOpenFromTerminal(terminalStatusIn(effectiveStatusCol, terminalStatuses), ackCol);
|
|
@@ -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", () => {
|