@nanobpm/nano-workforce 0.168.2 → 0.170.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.170.0](https://github.com/nanobpm/nano-workforce/compare/v0.169.0...v0.170.0) (2026-08-31)
2
+
3
+ ### Features
4
+
5
+ * **api:** add listEscalations read tool + structured openEscalation ([#672](https://github.com/nanobpm/nano-workforce/issues/672)) ([893d39a](https://github.com/nanobpm/nano-workforce/commit/893d39a644cd18102422186b4baddc6779b3510d)), closes [#666](https://github.com/nanobpm/nano-workforce/issues/666) [#666](https://github.com/nanobpm/nano-workforce/issues/666) [#358](https://github.com/nanobpm/nano-workforce/issues/358) [owner/repo#N](https://github.com/owner/repo/issues/N)
6
+
7
+ ## [0.169.0](https://github.com/nanobpm/nano-workforce/compare/v0.168.2...v0.169.0) (2026-08-31)
8
+
9
+ ### Features
10
+
11
+ * add getPrHistory read tool for PR escalation/round history ([#670](https://github.com/nanobpm/nano-workforce/issues/670)) ([22bd6f9](https://github.com/nanobpm/nano-workforce/commit/22bd6f9d28b891a2b4c157d21bad94cb11af1eb4)), closes [#668](https://github.com/nanobpm/nano-workforce/issues/668)
12
+
1
13
  ## [0.168.2](https://github.com/nanobpm/nano-workforce/compare/v0.168.1...v0.168.2) (2026-08-31)
2
14
 
3
15
  ### Bug Fixes
@@ -46,6 +46,7 @@ const EXPECTED_EXPOSED = [
46
46
  "previewDeliveryGraph",
47
47
  "listStagedProposals",
48
48
  "listActivePrs",
49
+ "listEscalations",
49
50
  "getAgentInstructions",
50
51
  "getVersion",
51
52
  ];
@@ -0,0 +1,117 @@
1
+ // PR round + escalation history read model (issue #668, N4 of epic #664).
2
+ //
3
+ // "Why did this PR escalate?" / "what happened in prior rounds?" lives in the `rounds` and
4
+ // `escalations` tables — the SAME two tables the Convergence page (`pages/home.page.json`, the PR
5
+ // detail's "Rounds" and "Escalations" child grids) reads directly. Until now the only way to answer
6
+ // those questions off the UI was to ssh into the instance and query the DB by hand. This module is
7
+ // the ONE canonical reader over those tables so an MCP read tool (`getPrHistory`) can surface the
8
+ // same history without DB access.
9
+ //
10
+ // Derivation over duplication (AGENTS.md): this does NOT introduce a new projection table or a
11
+ // second source of truth. The Convergence page's "query" is declarative datasource JSON over the
12
+ // `rounds`/`escalations` tables (ordered rounds-by-round_no, escalations-by-id); this function reads
13
+ // those exact tables with the exact same orderings, so the tool and the page cannot drift onto
14
+ // different data.
15
+ import type { DataLayer } from "@nanobpm/urban";
16
+
17
+ /** A `rounds` row — the per-round convergence record the Convergence page's "Rounds" grid reads. */
18
+ interface RoundRow {
19
+ id: number;
20
+ pr_key: string;
21
+ round_no: number;
22
+ status: string | null;
23
+ summary: string | null;
24
+ worker: string | null;
25
+ started_at: string;
26
+ ended_at: string | null;
27
+ }
28
+
29
+ /** An `escalations` row — the escalation record the Convergence page's "Escalations" grid reads. */
30
+ interface EscalationRow {
31
+ id: number;
32
+ pr_key: string;
33
+ round_no: number;
34
+ kind: string;
35
+ question: string;
36
+ answer: string | null;
37
+ status: string;
38
+ worker: string | null;
39
+ asked_at: string;
40
+ answered_at: string | null;
41
+ }
42
+
43
+ /** The subset of a `pull_requests` row this module needs to resolve a processKey → prKey. */
44
+ interface PrKeyRow {
45
+ pr_key: string;
46
+ process_key: string | null;
47
+ }
48
+
49
+ /** One round in a PR's timeline: its status transition/outcome, owning worker, and timestamps. */
50
+ export interface PrHistoryRound {
51
+ roundNo: number;
52
+ status: string | null;
53
+ worker: string | null;
54
+ summary: string | null;
55
+ startedAt: string;
56
+ endedAt: string | null;
57
+ }
58
+
59
+ /** One escalation in a PR's history: its kind, question/answer, status, and timestamps. */
60
+ export interface PrHistoryEscalation {
61
+ roundNo: number;
62
+ kind: string;
63
+ worker: string | null;
64
+ question: string;
65
+ answer: string | null;
66
+ status: string;
67
+ askedAt: string;
68
+ answeredAt: string | null;
69
+ }
70
+
71
+ /** A PR's full escalation + round history, as surfaced by the Convergence page's PR detail. */
72
+ export interface PrHistory {
73
+ prKey: string;
74
+ rounds: PrHistoryRound[];
75
+ escalations: PrHistoryEscalation[];
76
+ }
77
+
78
+ const rounds = (data: DataLayer) => data.table<RoundRow>("rounds", "id");
79
+ const escs = (data: DataLayer) => data.table<EscalationRow>("escalations", "id");
80
+ const prs = (data: DataLayer) => data.table<PrKeyRow>("pull_requests", "pr_key");
81
+
82
+ /** Resolve an engine process-instance key to the PR it drives (unique per instance), or null. */
83
+ export async function prKeyForProcess(data: DataLayer, processKey: string): Promise<string | null> {
84
+ const matches = await prs(data).find({ process_key: processKey });
85
+ return matches[0]?.pr_key ?? null;
86
+ }
87
+
88
+ /** The canonical PR history read: rounds (round_no asc) + escalations (id asc, i.e. asked order) for
89
+ * one PR, projected to the wire shape. An unknown `prKey` yields an empty history (no throw), so a
90
+ * caller can distinguish "no history yet" from an error without a 404 round-trip. */
91
+ export async function prHistory(data: DataLayer, prKey: string): Promise<PrHistory> {
92
+ const roundRows = (await rounds(data).find({ pr_key: prKey })).sort(
93
+ (a, b) => a.round_no - b.round_no || a.id - b.id,
94
+ );
95
+ const escRows = (await escs(data).find({ pr_key: prKey })).sort((a, b) => a.id - b.id);
96
+ return {
97
+ prKey,
98
+ rounds: roundRows.map((r) => ({
99
+ roundNo: r.round_no,
100
+ status: r.status ?? null,
101
+ worker: r.worker ?? null,
102
+ summary: r.summary ?? null,
103
+ startedAt: r.started_at,
104
+ endedAt: r.ended_at ?? null,
105
+ })),
106
+ escalations: escRows.map((e) => ({
107
+ roundNo: e.round_no,
108
+ kind: e.kind,
109
+ worker: e.worker ?? null,
110
+ question: e.question,
111
+ answer: e.answer ?? null,
112
+ status: e.status,
113
+ askedAt: e.asked_at,
114
+ answeredAt: e.answered_at ?? null,
115
+ })),
116
+ };
117
+ }
package/app/prParse.ts ADDED
@@ -0,0 +1,34 @@
1
+ // Canonical PR-key parser (extracted from app/service.ts so leaf modules can reuse the ONE
2
+ // implementation of the `owner/repo#N` shape without importing the heavy service module — which
3
+ // imports them, so a back-import would cycle). `app/service.ts` re-exports `parsePr`/`ParsedPr`
4
+ // from here, so every existing `import { parsePr } from "./service.ts"` keeps resolving. This is
5
+ // the single source of truth for "is this string a PR key?" — do not add a second shape regex.
6
+ export interface ParsedPr {
7
+ repo: string;
8
+ number: number;
9
+ url: string;
10
+ prKey: string;
11
+ }
12
+
13
+ /** Parse "owner/repo#123" or a canonical PR URL into its parts, or `null` when the input is not a
14
+ * PR key/URL. */
15
+ export function parsePr(input: unknown): ParsedPr | null {
16
+ // Total on any input: a process-variable regression (or an older in-flight instance) can carry a
17
+ // non-string prKey, and `.trim()` on a non-string throws — turning a should-fail-open caller into
18
+ // a retrying job. Fail closed to `null` here so every caller resolves safely instead of throwing.
19
+ if (typeof input !== "string") return null;
20
+ const s = input.trim();
21
+ let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
22
+ if (m) {
23
+ const repo = `${m[1]}/${m[2]}`;
24
+ const number = Number(m[3]);
25
+ return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
26
+ }
27
+ m = s.match(/^([^/]+\/[^#]+)#(\d+)$/);
28
+ if (m) {
29
+ const repo = m[1];
30
+ const number = Number(m[2]);
31
+ return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
32
+ }
33
+ return null;
34
+ }
package/app/service.ts CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  planTasks,
76
76
  } from "./plan.ts";
77
77
  import { derivePromotionState, isEpicIntegrationBranch, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
78
+ import { type ParsedPr, parsePr } from "./prParse.ts";
78
79
  import {
79
80
  defaultProbeExec,
80
81
  type ProbeExec,
@@ -93,12 +94,14 @@ import {
93
94
  latestOpenEscalationQuestion,
94
95
  latestPlanReviewFindings,
95
96
  latestTrialMergeQuestion,
97
+ type OpenEscalation,
96
98
  PLAN_REVIEW_ELEMENT,
97
99
  PR_WAIT_ANSWER_ELEMENT,
98
100
  PR_WAIT_MERGE_ANSWER_ELEMENT,
99
101
  prEscalations,
100
102
  reconcileUserTasks,
101
103
  TRIAL_MERGE_ELEMENT,
104
+ toOpenEscalation,
102
105
  type UserTaskContext,
103
106
  type UserTaskRow,
104
107
  userTaskKindLabel,
@@ -314,13 +317,6 @@ const prsTracking = (data: DataLayer) =>
314
317
  const escs = (data: DataLayer) => data.table<Escalation>("escalations", "id");
315
318
  const deps = (data: DataLayer) => data.table<PrDependency>("pr_dependencies", "pr_key");
316
319
 
317
- export interface ParsedPr {
318
- repo: string;
319
- number: number;
320
- url: string;
321
- prKey: string;
322
- }
323
-
324
320
  /** Canonical GitHub PR URL for a repo + number. Matches the `url` `parsePr` derives, so a
325
321
  * reconstructed row is indistinguishable from one registered at submit time. */
326
322
  export function canonicalPrUrl(repo: string, number: number): string {
@@ -379,27 +375,10 @@ export async function ensurePr(
379
375
  }
380
376
  }
381
377
 
382
- /** Parse "owner/repo#123" or a canonical PR URL into its parts. */
383
- export function parsePr(input: unknown): ParsedPr | null {
384
- // Total on any input: a process-variable regression (or an older in-flight instance) can carry a
385
- // non-string prKey, and `.trim()` on a non-string throws — turning a should-fail-open caller into
386
- // a retrying job. Fail closed to `null` here so every caller resolves safely instead of throwing.
387
- if (typeof input !== "string") return null;
388
- const s = input.trim();
389
- let m = s.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i);
390
- if (m) {
391
- const repo = `${m[1]}/${m[2]}`;
392
- const number = Number(m[3]);
393
- return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
394
- }
395
- m = s.match(/^([^/]+\/[^#]+)#(\d+)$/);
396
- if (m) {
397
- const repo = m[1];
398
- const number = Number(m[2]);
399
- return { repo, number, url: `https://github.com/${repo}/pull/${number}`, prKey: `${repo}#${number}` };
400
- }
401
- return null;
402
- }
378
+ /** Parse "owner/repo#123" or a canonical PR URL into its parts. Canonical implementation lives in
379
+ * `./prParse.ts` (a leaf module other leaf modules can reuse without cycling through this one); re-exported
380
+ * here so existing `import { parsePr } from "./service.ts"` call sites keep resolving. */
381
+ export { type ParsedPr, parsePr };
403
382
 
404
383
  /** Extract `Depends-on: owner/repo#N[, owner/repo#N …]` (or PR URLs) from a PR body. Multiple
405
384
  * `Depends-on:` lines accumulate; each line may list several comma/space-separated refs. Returns
@@ -785,7 +764,7 @@ export interface ActivePr {
785
764
  round: number;
786
765
  processKey: string | null;
787
766
  waitingSince: string | null;
788
- openEscalation: string | null;
767
+ openEscalation: OpenEscalation | null;
789
768
  updatedAt: string;
790
769
  /** Leasing worker while an agent is actively working the review round; null when queued
791
770
  * (job created, not yet activated) or not at the review-round task. */
@@ -796,15 +775,15 @@ export interface ActivePr {
796
775
 
797
776
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
798
777
  * the GET status endpoint so an operator or an external harness can see what is in flight
799
- * without reading the datasource directly. The open-escalation question is derived from the
800
- * canonical `escalations` audit row the single source of truth (no denormalised PR-row
801
- * pointer). A PR reads `status="escalated"` only while a token is parked awaiting a human answer,
802
- * and the row it raised carries `status="open"` until that answer is recorded by the
803
- * `pr.answer-escalation` step on the `wait-answer` (review loop) or `wait-merge-answer` (merge loop)
804
- * user-task completion. Both loops now park on a native user task answered through the one canonical
805
- * `completeUserTask` door (#256), so deriving from the row (not a per-loop wait mechanism) surfaces
806
- * BOTH loops' escalations uniformly. Once answered the row leaves `open`, so `openEscalation`
807
- * derives back to null. */
778
+ * without reading the datasource directly. The structured `openEscalation` pointer (issue #666:
779
+ * `{ userTaskKey, kind, summary }`) is derived from the canonical `user_tasks` read model the SAME
780
+ * user-task surface `listEscalations` and the Convergence/Tasks page consume, so `/status` carries the
781
+ * completable `userTaskKey` without a denormalised PR-row pointer or a second source of truth. A
782
+ * `user_tasks` row for a PR subject exists iff its review/merge-loop escalation task is currently OPEN
783
+ * (parked awaiting a human answer) the same live-escalation signal a `status="escalated"` PR carries.
784
+ * Both loops park on a native user task answered through the one canonical `completeUserTask` door
785
+ * (#256), so deriving from the read model surfaces BOTH loops' escalations uniformly. Once the task is
786
+ * completed its row is removed, so `openEscalation` derives back to null. */
808
787
  export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
809
788
  const all = await prsTracking(data).all();
810
789
  const active = all
@@ -818,15 +797,20 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
818
797
  // `derived_status === status`.
819
798
  .filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
820
799
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
821
- // Only an `escalated` PR is parked awaiting a human answer (either loop). Surface the question
822
- // from its latest still-open `escalations` row; a resubmit retires stale rows and finalize/merge
823
- // move the PR off `escalated`, so an open row on an escalated PR is a genuinely live escalation.
824
- // Fetch every open row in one query (avoids an N+1 over escalated PRs), then keep the newest per PR.
825
- const openEscByPr = new Map<string, string>();
826
- const escalatedPrs = new Set(active.filter((p) => p.status === "escalated").map((p) => p.pr_key));
827
- for (const e of (await escs(data).find({ status: "open" })).sort((a, b) => b.id - a.id)) {
828
- if (!escalatedPrs.has(e.pr_key) || openEscByPr.has(e.pr_key)) continue;
829
- if (e.question) openEscByPr.set(e.pr_key, e.question);
800
+ // Derive the STRUCTURED open-escalation pointer from the ONE `user_tasks` read model (issue #666)
801
+ // the same user-task surface `listEscalations` and the Convergence/Tasks page consume so `/status`
802
+ // carries the completable `userTaskKey` (plus the escalation `kind` and a one-line `summary`) without
803
+ // a second source of truth. A `user_tasks` row for a PR subject exists iff its review/merge-loop
804
+ // escalation task is currently OPEN (parked awaiting a human answer), so its presence is exactly the
805
+ // live-escalation signal the old `escalations`-table derivation computed now unified across BOTH
806
+ // loops. Keep the newest per PR (a PR parks on at most one such task at a time; order by recency for
807
+ // determinism).
808
+ const openEscByPr = new Map<string, OpenEscalation>();
809
+ for (const t of (await userTasks(data).find({ subject_type: "pr" })).sort((a, b) =>
810
+ a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0,
811
+ )) {
812
+ if (openEscByPr.has(t.subject_key)) continue;
813
+ openEscByPr.set(t.subject_key, toOpenEscalation(t));
830
814
  }
831
815
  return active.map((p) => ({
832
816
  prKey: p.pr_key,
@@ -19,6 +19,7 @@ import {
19
19
  latestTrialMergeQuestion,
20
20
  type PrEscalationRow,
21
21
  reconcileUserTasks,
22
+ toEscalationView,
22
23
  TRIAL_MERGE_ELEMENT,
23
24
  userTaskKindLabel,
24
25
  type UserTaskRow,
@@ -337,3 +338,38 @@ test("latestFeatureEscalationQuestion: picks the newest audit row (highest id),
337
338
  test("latestFeatureEscalationQuestion: null when the feature has no recorded escalation", () => {
338
339
  assertEquals(latestFeatureEscalationQuestion([]), null);
339
340
  });
341
+
342
+ // toEscalationView.prKey must be a genuine PR key (owner/repo#N), never a subject key that fell back
343
+ // to a non-PR value. `buildUserTaskRow` coalesces a blank subjectKey to `processKey`/`userTaskKey`
344
+ // for an orphaned/untracked instance (#358); for a PR-subject task that yields `subject_key` = a
345
+ // numeric engine key, which must NOT be emitted as `prKey` (OpenAPI: prKey is the PR key only when
346
+ // known). Copilot review suppressed advisory app/userTasks.ts:136.
347
+ const escRow = (over: Partial<UserTaskRow>): UserTaskRow => ({
348
+ user_task_key: "ut-1",
349
+ element_id: PR_WAIT_ANSWER_ELEMENT,
350
+ kind_label: "PR review",
351
+ subject_type: "pr",
352
+ subject_key: "o/r#7",
353
+ subject_title: "o/r#7",
354
+ subject_url: null,
355
+ question: null,
356
+ process_key: null,
357
+ form_key: null,
358
+ created_at: AT,
359
+ updated_at: AT,
360
+ ...over,
361
+ });
362
+
363
+ test("toEscalationView: PR-subject row with a PR-shaped subject key emits it as prKey", () => {
364
+ assertEquals(toEscalationView(escRow({ subject_key: "o/r#7" })).prKey, "o/r#7");
365
+ });
366
+
367
+ test("toEscalationView: PR-subject row whose subject key fell back to a non-PR value yields prKey null (still correlatable via subjectKey)", () => {
368
+ const view = toEscalationView(escRow({ subject_key: "19153" }));
369
+ assertEquals(view.prKey, null);
370
+ assertEquals(view.subjectKey, "19153");
371
+ });
372
+
373
+ test("toEscalationView: non-PR subject always yields prKey null", () => {
374
+ assertEquals(toEscalationView(escRow({ subject_type: "plan", subject_key: "o/r#7" })).prKey, null);
375
+ });
package/app/userTasks.ts CHANGED
@@ -22,6 +22,7 @@ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
22
22
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
23
23
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
24
24
  import type { PlanReview } from "./plan.ts";
25
+ import { parsePr } from "./prParse.ts";
25
26
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
26
27
 
27
28
  const now = () => new Date().toISOString();
@@ -98,6 +99,80 @@ export interface UserTaskRow {
98
99
 
99
100
  export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_tasks", "user_task_key");
100
101
 
102
+ /** The read-tool projection of ONE open escalation user task (issue #666, epic #664 — retire the
103
+ * `/tasks/api/tasks` inbox curl). Sourced from the SAME `user_tasks` read model the Tasks inbox and
104
+ * Convergence page consume — NOT a second source of truth. It carries the completable `userTaskKey`
105
+ * an agent answers via `completeUserTask` / `agentCompleteEscalation`, the escalation `kind` (the
106
+ * BPMN `elementId`), the denormalised decision text (`question` — the question / findings / task the
107
+ * loop or agent raised, uniform across all four kinds), and the deployed-form context so a tool-aware
108
+ * agent can discover and answer an escalation without curling the un-projected task inbox. */
109
+ export interface EscalationView {
110
+ userTaskKey: string;
111
+ kind: string;
112
+ kindLabel: string;
113
+ /** The PR key when this escalation belongs to a PR (review/merge loop): the subject key, but ONLY
114
+ * when it is actually PR-key-shaped (`owner/repo#N`). Null for feature / plan / delivery / agent
115
+ * subjects — and also null for a PR-subject task whose subject key fell back to a non-PR value
116
+ * (`processKey`/`userTaskKey`) for an orphaned/untracked instance (see `buildUserTaskRow`), so
117
+ * `prKey` never emits a non-PR key. Use `subjectKey` for raw correlation in that case. */
118
+ prKey: string | null;
119
+ subjectType: string;
120
+ subjectKey: string;
121
+ subjectTitle: string;
122
+ subjectUrl: string | null;
123
+ question: string | null;
124
+ formKey: string | null;
125
+ processKey: string | null;
126
+ /** The denormalised decision/form context the Tasks inbox renders for this task (the same
127
+ * `question` + subject the deployed `.form` is seeded with). The typed answer fields an agent
128
+ * submits depend on `kind` (e.g. a PR `{ answer }`, a plan-review `{ directive, notes }`). */
129
+ formVariables: Record<string, unknown>;
130
+ }
131
+
132
+ /** Pure: project one open `user_tasks` row into its `listEscalations` read-tool entry. Reuses the
133
+ * read model verbatim (no new query / source of truth). `prKey` is the subject key only when the
134
+ * subject is a PR AND the subject key is genuinely PR-key-shaped (`owner/repo#N`, validated by the
135
+ * canonical `parsePr`) — an orphaned PR-loop instance whose subject key fell back to a numeric
136
+ * `processKey`/`userTaskKey` (see `buildUserTaskRow`) yields `prKey: null`, not a non-PR key that
137
+ * would contradict the OpenAPI contract. `subjectKey` still carries the raw value for correlation. */
138
+ export function toEscalationView(row: UserTaskRow): EscalationView {
139
+ return {
140
+ userTaskKey: row.user_task_key,
141
+ kind: row.element_id,
142
+ kindLabel: row.kind_label,
143
+ prKey: row.subject_type === "pr" && parsePr(row.subject_key) ? row.subject_key : null,
144
+ subjectType: row.subject_type,
145
+ subjectKey: row.subject_key,
146
+ subjectTitle: row.subject_title,
147
+ subjectUrl: row.subject_url,
148
+ question: row.question,
149
+ formKey: row.form_key,
150
+ processKey: row.process_key,
151
+ formVariables: {
152
+ question: row.question,
153
+ subjectTitle: row.subject_title,
154
+ subjectUrl: row.subject_url,
155
+ formKey: row.form_key,
156
+ },
157
+ };
158
+ }
159
+
160
+ /** The structured open-escalation pointer surfaced on each active PR by `/status` (issue #666): the
161
+ * completable `userTaskKey`, the escalation `kind` (BPMN `elementId`), and a one-line `summary` (the
162
+ * raised question / findings). Derived from the SAME `user_tasks` read model as `listEscalations`
163
+ * (no denormalised PR-row pointer, no second source of truth); the field is null when the PR is not
164
+ * parked on an open escalation. */
165
+ export interface OpenEscalation {
166
+ userTaskKey: string;
167
+ kind: string;
168
+ summary: string | null;
169
+ }
170
+
171
+ /** Pure: the structured `/status` open-escalation pointer for one open PR user-task row. */
172
+ export function toOpenEscalation(row: UserTaskRow): OpenEscalation {
173
+ return { userTaskKey: row.user_task_key, kind: row.element_id, summary: row.question };
174
+ }
175
+
101
176
  /** Human-readable label per escalation element. The set of keys is the closed set of user-task
102
177
  * elements the Tasks inbox surfaces — an element absent from here is not an escalation and is
103
178
  * ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */
@@ -28,6 +28,8 @@ import { dirname, join, resolve } from "node:path";
28
28
  import { after, before, describe, test } from "node:test";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
31
+ import { pollUserTasks } from "../app/service.ts";
32
+ import { asEngineClient } from "./support/engine-client.ts";
31
33
 
32
34
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
35
  const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u4-"));
@@ -49,7 +51,11 @@ interface InboxTask {
49
51
  }
50
52
 
51
53
  interface StatusBody {
52
- prs: Array<{ prKey: string; status: string; openEscalation: string | null }>;
54
+ prs: Array<{
55
+ prKey: string;
56
+ status: string;
57
+ openEscalation: { userTaskKey: string; kind: string; summary: string | null } | null;
58
+ }>;
53
59
  }
54
60
 
55
61
  interface TakenFlow {
@@ -149,17 +155,20 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
149
155
  assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
150
156
  assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
151
157
 
152
- // The open escalation is DERIVED from the durable `escalations` audit row on the status
153
- // endpoint — no denormalised `open_escalation_*` pointer is written or read.
158
+ // The open escalation is DERIVED from the `user_tasks` read model on the status endpoint — no
159
+ // denormalised `open_escalation_*` pointer is written or read. That read model is projected by
160
+ // `pollUserTasks` (part of the imperative main.ts poll loop the testkit does not run), so drive it
161
+ // explicitly (mirrors feature-run.e2e.ts) to project the parked `wait-answer` task first.
162
+ await pollUserTasks(app.db, asEngineClient(app.engine));
154
163
  const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
155
164
  assert.equal(status.status, 200, "the status endpoint responds");
156
165
  const statusRow = status.body.prs.find((p) => p.prKey === prKey);
157
166
  assert.ok(statusRow, "the escalated PR is listed as active");
158
167
  assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
159
168
  assert.equal(
160
- statusRow?.openEscalation,
169
+ statusRow?.openEscalation?.summary,
161
170
  "Which retry cap?",
162
- "the open escalation question is derived from the open escalations row",
171
+ "the open escalation question is derived from the user_tasks read model",
163
172
  );
164
173
 
165
174
  // Complete the escalation through the taskInbox completion route with the typed `answer`.
@@ -185,8 +194,11 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
185
194
  assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
186
195
  assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
187
196
 
188
- // The escalations row was retired to `answered` (the single source of truth the status endpoint
189
- // derives from), so no open escalation lingers on the status endpoint once answered + resumed.
197
+ // The escalations row was retired to `answered` and the completed task's `user_tasks` row
198
+ // reconciles away, so no open escalation lingers on the status endpoint once answered + resumed.
199
+ // Re-run the projection (as the durable poller would) so the closed task's read-model row is
200
+ // deleted before re-reading /status.
201
+ await pollUserTasks(app.db, asEngineClient(app.engine));
190
202
  const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
191
203
  const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
192
204
  if (afterRow) {
@@ -25,6 +25,8 @@ import { dirname, join, resolve } from "node:path";
25
25
  import { after, before, describe, test } from "node:test";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
28
+ import { pollUserTasks } from "../app/service.ts";
29
+ import { asEngineClient } from "./support/engine-client.ts";
28
30
 
29
31
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
30
32
  const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u7-"));
@@ -46,7 +48,11 @@ interface InboxTask {
46
48
  }
47
49
 
48
50
  interface StatusBody {
49
- prs: Array<{ prKey: string; status: string; openEscalation: string | null }>;
51
+ prs: Array<{
52
+ prKey: string;
53
+ status: string;
54
+ openEscalation: { userTaskKey: string; kind: string; summary: string | null } | null;
55
+ }>;
50
56
  }
51
57
 
52
58
  interface TableInfoRow {
@@ -227,14 +233,18 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
227
233
  assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
228
234
  assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
229
235
 
230
- // The open escalation is DERIVED from the durable audit row — no denormalised pointer is written.
236
+ // The open escalation is DERIVED from the `user_tasks` read model — no denormalised pointer is
237
+ // written. That read model is projected by `pollUserTasks` (part of the imperative main.ts poll
238
+ // loop the testkit does not run), so drive it explicitly (mirrors feature-run.e2e.ts) to project
239
+ // the parked `wait-answer` task before reading /status.
240
+ await pollUserTasks(app.db, asEngineClient(app.engine));
231
241
  const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
232
242
  const statusRow = status.body.prs.find((p) => p.prKey === prKey);
233
243
  assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
234
244
  assert.equal(
235
- statusRow?.openEscalation,
245
+ statusRow?.openEscalation?.summary,
236
246
  "Which retry cap?",
237
- "the open escalation question is derived from the escalations audit row",
247
+ "the open escalation question is derived from the user_tasks read model",
238
248
  );
239
249
 
240
250
  // Answer through the inbox completion route with the typed `answer` (the re-issued-new path).
@@ -252,7 +262,10 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
252
262
  assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
253
263
  assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
254
264
 
255
- // No addressed escalation lingers — the audit row is the single source of truth.
265
+ // No addressed escalation lingers — the completed task's `user_tasks` row reconciles away. Re-run
266
+ // the projection (as the durable poller would) so the closed task's read-model row is deleted
267
+ // before re-reading /status.
268
+ await pollUserTasks(app.db, asEngineClient(app.engine));
256
269
  const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
257
270
  const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
258
271
  if (afterRow) {