@nanobpm/nano-workforce 0.166.0 → 0.167.1
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/.github/workflows/mirror-install-dispatch.yml +54 -0
- package/CHANGELOG.md +13 -0
- package/app/backfillAcknowledgedAt.test.ts +121 -0
- package/app/contracts.ts +17 -1
- 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/pollUserTasks.test.ts +165 -0
- package/app/pullRequestReadModel.test.ts +208 -0
- package/app/pullRequestReadModel.ts +76 -0
- package/app/service.ts +52 -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/e2e/feature-preflight.e2e.ts +3 -2
- package/e2e/feature-run.e2e.ts +60 -5
- package/nano.app.json +4 -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 +2 -2
- package/pages/delivery-graphs/library.mount.js +59 -2
- package/pages/delivery-graphs/mount.js +88 -2
- 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 +90 -69
- package/scripts/pages-contract.test.ts +80 -18
- package/test/delivery-graphs-ack-or-timeout.test.ts +280 -0
- package/test/delivery-graphs-library-embed.test.ts +1 -1
- package/workers/record-feature-implementing/worker.test.ts +57 -0
- package/workers/record-feature-implementing/worker.ts +31 -0
- 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).
|
|
@@ -675,3 +675,168 @@ test("pollUserTasks (typed-seam fallback): denormalises the engine form_key from
|
|
|
675
675
|
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
676
676
|
assertEquals(byKey["ut-plan"].form_key, "form-77");
|
|
677
677
|
});
|
|
678
|
+
|
|
679
|
+
test("pollUserTasks (engine-first): self-heals an escalated run stranded off its parked task, sparing a genuinely parked one (issue #642)", async () => {
|
|
680
|
+
// `status="escalated"` must hold ONLY while a `feature-escalation` task is open (parity with the PR
|
|
681
|
+
// contract). A run whose instance the engine no longer reports parked (its escalation was answered,
|
|
682
|
+
// or it predates the write-side reset — the #632 tear) is reconciled to `running`; a run whose task
|
|
683
|
+
// IS still open is left escalated. The engine's open set is the authority, not the raw status column.
|
|
684
|
+
const { data, stores } = memData({
|
|
685
|
+
feature_runs: [
|
|
686
|
+
{ feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
|
|
687
|
+
{ feature_key: "o/r#77", status: "escalated", process_key: "fp-77", issue_url: null, title: "still parked", delivery_label: null },
|
|
688
|
+
],
|
|
689
|
+
});
|
|
690
|
+
const restore = stubUserTaskSearch([
|
|
691
|
+
// Only fp-77 is genuinely parked; the engine reports NO open task on fp-632.
|
|
692
|
+
{ userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-77", state: "CREATED" },
|
|
693
|
+
]);
|
|
694
|
+
try {
|
|
695
|
+
await pollUserTasks(data, fakeEngine({ "fp-77": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] }), REST);
|
|
696
|
+
} finally {
|
|
697
|
+
restore();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
701
|
+
assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
|
|
702
|
+
assertEquals(byKey["o/r#77"].status, "escalated", "the genuinely parked run stays escalated");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("pollUserTasks (engine-first): skips the per-instance open-task RPC for an escalated run already seen parked in this pass's sweep (issue #642)", async () => {
|
|
706
|
+
// Presence in THIS pass's swept `desired` set is POSITIVE evidence the run is genuinely parked — the
|
|
707
|
+
// best-effort sweep may truncate (drop tasks) but never invents one. Re-confirming such a run with a
|
|
708
|
+
// per-instance `openUserTasks` RPC is a redundant N+1 query on every poll tick; the self-heal must skip
|
|
709
|
+
// it. Only a run NOT confirmed parked by the sweep still needs the per-instance check (unchanged).
|
|
710
|
+
const { data, stores } = memData({
|
|
711
|
+
feature_runs: [
|
|
712
|
+
{ feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "parked", delivery_label: null },
|
|
713
|
+
{ feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
|
|
714
|
+
],
|
|
715
|
+
});
|
|
716
|
+
const restore = stubUserTaskSearch([
|
|
717
|
+
{ userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-parked", state: "CREATED" },
|
|
718
|
+
]);
|
|
719
|
+
const openUserTasksCalls: string[] = [];
|
|
720
|
+
const engine = {
|
|
721
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
722
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) => {
|
|
723
|
+
if (filter?.processInstanceKey) openUserTasksCalls.push(filter.processInstanceKey);
|
|
724
|
+
return Promise.resolve([]); // no instance reports an open escalation via the per-instance seam
|
|
725
|
+
},
|
|
726
|
+
} as unknown as EngineClient;
|
|
727
|
+
try {
|
|
728
|
+
await pollUserTasks(data, engine, REST);
|
|
729
|
+
} finally {
|
|
730
|
+
restore();
|
|
731
|
+
}
|
|
732
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
733
|
+
assertEquals(byKey["o/r#parked"].status, "escalated", "the swept-parked run stays escalated without a per-instance query");
|
|
734
|
+
assertEquals(byKey["o/r#stranded"].status, "running", "the run absent from the sweep is still confirmed per-instance and healed");
|
|
735
|
+
assertEquals(openUserTasksCalls.includes("fp-parked"), false, "no redundant per-instance RPC for the already-parked run");
|
|
736
|
+
assertEquals(openUserTasksCalls.includes("fp-stranded"), true, "the unconfirmed run still needs the per-instance RPC");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("pollUserTasks (engine-first): a TRUNCATED best-effort sweep never heals a genuinely-parked escalated run (issue #642)", async () => {
|
|
740
|
+
// `sweepOpenEscalationTasks` is explicitly best-effort — it BREAKS early on a paging/transport error
|
|
741
|
+
// and projects only what it had gathered. Healing `escalated -> running` on ABSENCE from that partial
|
|
742
|
+
// set is mutating durable state on negative evidence: a genuinely-parked human escalation whose task
|
|
743
|
+
// lived on an unreached page would be silently stolen. The self-heal must confirm per-instance against
|
|
744
|
+
// the engine's authoritative open set, NOT the (possibly truncated) global sweep.
|
|
745
|
+
const { data, stores } = memData({
|
|
746
|
+
feature_runs: [
|
|
747
|
+
{ feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "genuinely parked", delivery_label: null },
|
|
748
|
+
{ feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
|
|
749
|
+
],
|
|
750
|
+
});
|
|
751
|
+
// Page 1 fills the limit (forcing a second page) with unrelated open escalations; page 2 — which WOULD
|
|
752
|
+
// carry fp-parked's escalation — errors, so the sweep truncates and `desired` never sees fp-parked.
|
|
753
|
+
const page1: RawTask[] = Array.from({ length: 100 }, (_, i) => ({
|
|
754
|
+
userTaskKey: `other-${i}`,
|
|
755
|
+
elementId: "feature-escalation",
|
|
756
|
+
processInstanceKey: `other-${i}`,
|
|
757
|
+
state: "CREATED",
|
|
758
|
+
}));
|
|
759
|
+
const orig = globalThis.fetch;
|
|
760
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
|
|
761
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
762
|
+
if (!String(url).endsWith("/user-tasks/search")) return new Response("not found", { status: 404 });
|
|
763
|
+
const from: number = JSON.parse(init?.body ?? "{}")?.page?.from ?? 0;
|
|
764
|
+
if (from === 0) {
|
|
765
|
+
return new Response(JSON.stringify({ items: page1 }), { status: 200, headers: { "content-type": "application/json" } });
|
|
766
|
+
}
|
|
767
|
+
return new Response("boom", { status: 500 }); // page 2 transport error -> sweep truncates here
|
|
768
|
+
}) as typeof fetch;
|
|
769
|
+
// The engine's authoritative per-instance open set: fp-parked IS parked; fp-stranded is not.
|
|
770
|
+
const engine = fakeEngine({ "fp-parked": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] });
|
|
771
|
+
try {
|
|
772
|
+
await pollUserTasks(data, engine, REST);
|
|
773
|
+
} finally {
|
|
774
|
+
globalThis.fetch = orig;
|
|
775
|
+
}
|
|
776
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
777
|
+
assertEquals(byKey["o/r#parked"].status, "escalated", "a genuinely-parked run survives a truncated sweep");
|
|
778
|
+
assertEquals(byKey["o/r#stranded"].status, "running", "a truly stranded run is still healed");
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated run when the per-instance open-task query errors (issue #642)", async () => {
|
|
782
|
+
// A per-instance query error is not proof the run is unparked — mutating on that negative evidence would
|
|
783
|
+
// again steal a parked escalation. On query error the run must be left `escalated` for a later pass.
|
|
784
|
+
const { data, stores } = memData({
|
|
785
|
+
feature_runs: [{ feature_key: "o/r#err", status: "escalated", process_key: "fp-err", issue_url: null, title: "query errors", delivery_label: null }],
|
|
786
|
+
});
|
|
787
|
+
const restore = stubUserTaskSearch([]); // empty sweep -> old code would heal on absence
|
|
788
|
+
const engine = {
|
|
789
|
+
searchUserTasks: () => Promise.resolve([]),
|
|
790
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
791
|
+
filter?.processInstanceKey === "fp-err" ? Promise.reject(new Error("engine down")) : Promise.resolve([]),
|
|
792
|
+
} as unknown as EngineClient;
|
|
793
|
+
try {
|
|
794
|
+
await pollUserTasks(data, engine, REST);
|
|
795
|
+
} finally {
|
|
796
|
+
restore();
|
|
797
|
+
}
|
|
798
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
799
|
+
assertEquals(byKey["o/r#err"].status, "escalated", "a failed per-instance query leaves the run escalated");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("pollUserTasks (engine-first): does NOT heal a JUST-escalated run inside the grace window before its user task exists (issue #642)", async () => {
|
|
803
|
+
// `record-feature-escalation` writes `status="escalated"` (stamping `updated_at`) on the `escalated`
|
|
804
|
+
// arm IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing in that
|
|
805
|
+
// window sees `openUserTasks` return none and would wrongly flip the fresh escalation back to `running`,
|
|
806
|
+
// making the just-raised escalation invisible. A short grace window on `updated_at` spares a just-written
|
|
807
|
+
// escalation while still healing genuinely-stranded (old) rows.
|
|
808
|
+
const fresh = new Date().toISOString();
|
|
809
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString(); // an hour ago — comfortably past grace
|
|
810
|
+
const { data, stores } = memData({
|
|
811
|
+
feature_runs: [
|
|
812
|
+
{ feature_key: "o/r#fresh", status: "escalated", process_key: "fp-fresh", updated_at: fresh, issue_url: null, title: "just escalated", delivery_label: null },
|
|
813
|
+
{ feature_key: "o/r#old", status: "escalated", process_key: "fp-old", updated_at: stale, issue_url: null, title: "genuinely stranded", delivery_label: null },
|
|
814
|
+
],
|
|
815
|
+
});
|
|
816
|
+
const restore = stubUserTaskSearch([]); // engine reports no open escalation task for either instance
|
|
817
|
+
const engine = fakeEngine({}); // openUserTasks returns [] for every instance (task not yet created / gone)
|
|
818
|
+
try {
|
|
819
|
+
await pollUserTasks(data, engine, REST);
|
|
820
|
+
} finally {
|
|
821
|
+
restore();
|
|
822
|
+
}
|
|
823
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
824
|
+
assertEquals(byKey["o/r#fresh"].status, "escalated", "a just-escalated run inside the grace window is spared the heal");
|
|
825
|
+
assertEquals(byKey["o/r#old"].status, "running", "a genuinely-stranded (old) run is still healed");
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no open feature-escalation task (issue #642)", async () => {
|
|
829
|
+
// The reduced-capability path scans FEATURE_ACTIVE_STATUSES instances (incl. `escalated`) directly,
|
|
830
|
+
// so the per-instance open-task read is just as authoritative for the self-heal.
|
|
831
|
+
const { data, stores } = memData({
|
|
832
|
+
feature_runs: [
|
|
833
|
+
{ feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
const engine = fakeEngine({ "fp-632": [] }); // instance active at implement-task, no open user task
|
|
837
|
+
|
|
838
|
+
await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
|
|
839
|
+
|
|
840
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
841
|
+
assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
|
|
842
|
+
});
|
|
@@ -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
|
+
}
|