@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.
Files changed (44) hide show
  1. package/.github/workflows/mirror-install-dispatch.yml +54 -0
  2. package/CHANGELOG.md +13 -0
  3. package/app/backfillAcknowledgedAt.test.ts +121 -0
  4. package/app/contracts.ts +17 -1
  5. package/app/delivery.ts +15 -13
  6. package/app/deliveryGraphReadModel.test.ts +44 -8
  7. package/app/deliveryGraphReadModel.ts +19 -2
  8. package/app/deliveryGraphRun.ts +5 -0
  9. package/app/epicBucket.test.ts +9 -3
  10. package/app/featureReadModel.ts +7 -11
  11. package/app/listBucket.ts +86 -0
  12. package/app/planReadModel.test.ts +29 -19
  13. package/app/planReadModel.ts +32 -26
  14. package/app/pollUserTasks.test.ts +165 -0
  15. package/app/pullRequestReadModel.test.ts +208 -0
  16. package/app/pullRequestReadModel.ts +76 -0
  17. package/app/service.ts +52 -0
  18. package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
  19. package/db/migrations/094_pull_requests_read_model.sql +65 -0
  20. package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
  21. package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
  22. package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
  23. package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
  24. package/e2e/feature-preflight.e2e.ts +3 -2
  25. package/e2e/feature-run.e2e.ts +60 -5
  26. package/nano.app.json +4 -0
  27. package/openapi.yaml +98 -0
  28. package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
  29. package/operations/acknowledgeDeliveryGraph.ts +58 -0
  30. package/operations/acknowledgePr.test.ts +94 -0
  31. package/operations/acknowledgePr.ts +62 -0
  32. package/package.json +2 -2
  33. package/pages/delivery-graphs/library.mount.js +59 -2
  34. package/pages/delivery-graphs/mount.js +88 -2
  35. package/pages/delivery-graphs.page.json +12 -3
  36. package/pages/home.page.json +18 -27
  37. package/pages/overview.page.json +26 -17
  38. package/resources/processes/feature.bpmn +90 -69
  39. package/scripts/pages-contract.test.ts +80 -18
  40. package/test/delivery-graphs-ack-or-timeout.test.ts +280 -0
  41. package/test/delivery-graphs-library-embed.test.ts +1 -1
  42. package/workers/record-feature-implementing/worker.test.ts +57 -0
  43. package/workers/record-feature-implementing/worker.ts +31 -0
  44. 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 = "083_plan_read_model_declare_once.sql";
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) then the declare-once supersessions (082/083). Mirrors the runtime migrator.
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
- READ_MODEL_MIGRATION,
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 083 embeds each derived column VERBATIM from planReadModel.sqlSelectFor (the VIEW cannot drift from the declaration)", () => {
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 080 and folds in (drops) the now-redundant intermediate VIEWs, keeping
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), "083 must DROP the superseded plan_read_model first");
189
- assert(/DROP VIEW IF EXISTS plan_delivery;/.test(sql), "083 must fold in (drop) the retired plan_delivery");
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}`), `083 must pass base column "${base}" through the VIEW`);
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"), "083 must carry the hand-authored delivery_label display column");
197
- assert(sql.includes("AS wave_label"), "083 must carry the hand-authored wave_label display column");
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 083 pointing at a
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}`), `083's FROM must be the declaration's baseTable "${planReadModel.decl.baseTable}" (aliased ${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), `083 must LEFT JOIN the declaration's "${rollupName}" lookup exactly as "${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') drops out of Active with no worker write", () => {
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. 083 classifies the bucket
358
- // off `derived_status`, so a terminated epic renders History (not wedged Active) with no poller pass.
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, "history", "a derive-only terminated epic is History (the #503 phantom fix)");
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", () => {
@@ -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, isNull, lit, not, or, type ReadModel, rcol, when } from "@nanobpm/urban";
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 Active/History partition (`deriveEpicBucket`, app/delivery.ts) the byte-for-byte twin of the
86
- * `plan_read_model` VIEW's bucket CASE (074/080). `active` while the epic is LIVE (planning/dispatched)
87
- * OR `done`-but-still-`converging` (genuinely working) OR `done`-but-unacknowledged (stay actionable
88
- * until dismissed); `history` once truly resolved (a `done` epic the operator acknowledged, or a
89
- * terminal non-`done` status). Classifies the status arms on the terminal-folded `derived_status` so a
90
- * cancelled epic falls to History; the `converging` arm reuses the {@link delivery} sub-expression
91
- * (base-status-derived) so the two columns can't disagree. */
92
- const listBucket: Expr = caseWhen(
93
- [
94
- when(or(eq(ds, lit("planning")), eq(ds, lit("dispatched"))), lit("active")),
95
- when(and(eq(ds, lit("done")), eq(delivery, lit("converging"))), lit("active")),
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" predicateterminal 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 operator "Dismiss" (acknowledge) affordance flag (`epicIsAcknowledgeable` unacknowledged) —
103
- * the byte-for-byte twin of the `plan_read_model` VIEW's `ack_open` CASE (074/080): `1` iff the epic is
104
- * `done`, its fan-out has RESOLVED (`delivery` is not `converging`), and it is not yet acknowledged;
105
- * else `0`. `not(eq(delivery, 'converging'))` matches the VIEW's null-safe `d.delivery IS NOT
106
- * 'converging'` (a NULL delivery resolved acknowledgeable) under the shared "NULL false" rule. */
107
- const ackOpen: Expr = caseWhen(
108
- [when(and(eq(ds, lit("done")), not(eq(delivery, lit("converging"))), isNull(col("acknowledged_at"))), lit(1))],
109
- lit(0),
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
+ }