@nanobpm/nano-workforce 0.169.0 → 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,9 @@
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
+
1
7
  ## [0.169.0](https://github.com/nanobpm/nano-workforce/compare/v0.168.2...v0.169.0) (2026-08-31)
2
8
 
3
9
  ### Features
@@ -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
  ];
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) {
package/openapi.yaml CHANGED
@@ -108,8 +108,31 @@ components:
108
108
  type: string
109
109
  nullable: true
110
110
  openEscalation:
111
- type: string
111
+ type: object
112
112
  nullable: true
113
+ additionalProperties: false
114
+ description: >-
115
+ The structured pointer to this PR's OPEN escalation when it is parked awaiting a human/agent
116
+ answer (issue #666), else null. Derived from the same `user_tasks` read model as
117
+ `listEscalations`, so `userTaskKey` is the completable key an agent answers via
118
+ `completeUserTask` / `agentCompleteEscalation` (no `/tasks/api/tasks` curl needed). `kind`
119
+ is the BPMN escalation elementId (e.g. `wait-answer`, `wait-merge-answer`); `summary` is the
120
+ raised question/findings (null when none was recorded).
121
+ required:
122
+ - userTaskKey
123
+ - kind
124
+ - summary
125
+ properties:
126
+ userTaskKey:
127
+ type: string
128
+ description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
129
+ kind:
130
+ type: string
131
+ description: The BPMN escalation elementId (the escalation kind).
132
+ summary:
133
+ type: string
134
+ nullable: true
135
+ description: The raised question / findings, denormalised for display; null when none.
113
136
  updatedAt:
114
137
  type: string
115
138
  activeWorker:
@@ -130,6 +153,85 @@ components:
130
153
  type: array
131
154
  items:
132
155
  $ref: "#/components/schemas/ActivePr"
156
+ Escalation:
157
+ type: object
158
+ description: >-
159
+ One OPEN native user-task escalation awaiting a human/agent decision (issue #666), projected
160
+ from the `user_tasks` read model. `userTaskKey` is the completable key an agent answers via
161
+ `completeUserTask` / `agentCompleteEscalation`; `kind` is the BPMN escalation elementId.
162
+ additionalProperties: false
163
+ required:
164
+ - userTaskKey
165
+ - kind
166
+ - kindLabel
167
+ - prKey
168
+ - subjectType
169
+ - subjectKey
170
+ - subjectTitle
171
+ - subjectUrl
172
+ - question
173
+ - formKey
174
+ - processKey
175
+ - formVariables
176
+ properties:
177
+ userTaskKey:
178
+ type: string
179
+ description: The completable engine user-task key (answer via completeUserTask / agentCompleteEscalation).
180
+ kind:
181
+ type: string
182
+ description: The BPMN escalation elementId (e.g. wait-answer, wait-merge-answer, plan-review-decision, trial-merge-decision, feature-escalation).
183
+ kindLabel:
184
+ type: string
185
+ description: Human-readable kind label (e.g. "PR review", "Plan review", "Trial merge").
186
+ prKey:
187
+ type: string
188
+ nullable: true
189
+ description: The PR key when this escalation belongs to a PR (review/merge loop); null for feature / plan / delivery / agent subjects.
190
+ subjectType:
191
+ type: string
192
+ description: The domain subject kind — feature | plan | pr | delivery | agent.
193
+ subjectKey:
194
+ type: string
195
+ description: The subject aggregate key (feature_key / plan_key / pr_key).
196
+ subjectTitle:
197
+ type: string
198
+ description: The subject's human-readable title (coalesced to subjectKey when unknown).
199
+ subjectUrl:
200
+ type: string
201
+ nullable: true
202
+ description: An optional external link (the issue/PR URL); null when none.
203
+ question:
204
+ type: string
205
+ nullable: true
206
+ description: The raised question / findings / task text the loop or agent recorded; null when none.
207
+ formKey:
208
+ type: string
209
+ nullable: true
210
+ description: The deployed `.form` key of the parked user task, for rendering/answering; null when unresolved.
211
+ processKey:
212
+ type: string
213
+ nullable: true
214
+ description: The owning engine process-instance key; null when unknown.
215
+ formVariables:
216
+ type: object
217
+ additionalProperties: true
218
+ description: >-
219
+ The denormalised decision/form context the Tasks inbox renders for this task (the same
220
+ question + subject the deployed form is seeded with). The typed answer fields an agent
221
+ submits depend on `kind` (e.g. a PR `{ answer }`, a plan-review `{ directive, notes }`).
222
+ EscalationList:
223
+ type: object
224
+ additionalProperties: false
225
+ required:
226
+ - count
227
+ - escalations
228
+ properties:
229
+ count:
230
+ type: integer
231
+ escalations:
232
+ type: array
233
+ items:
234
+ $ref: "#/components/schemas/Escalation"
133
235
  LineagePrView:
134
236
  type: object
135
237
  description: A member PR of a lineage thread (issue #245).
@@ -3057,6 +3159,36 @@ paths:
3057
3159
  application/json:
3058
3160
  schema:
3059
3161
  $ref: "#/components/schemas/ErrorBody"
3162
+ /escalations:
3163
+ get:
3164
+ operationId: listEscalations
3165
+ summary: List every OPEN escalation awaiting a human/agent decision, with the completable userTaskKey.
3166
+ description: >-
3167
+ Discovery for the escalation-answer path (epic #664, issue #666): across every surfaced
3168
+ escalation kind (PR review/merge loop, plan-review, empty-plan, trial-merge,
3169
+ conformance-review, delivery human-step, feature/blocked, agent-permission and the shared
3170
+ human-escalation cell), list each currently-open native user-task escalation with the
3171
+ completable `userTaskKey` an agent then answers via `completeUserTask` /
3172
+ `agentCompleteEscalation` — so a tool-aware agent never has to curl the un-projected
3173
+ `/tasks/api/tasks` inbox to find keys. Read-only projection over the ONE `user_tasks` read
3174
+ model the Tasks inbox and Convergence page consume (no second source of truth); a row is
3175
+ present iff its task is open, so the list reflects live pending work.
3176
+ security:
3177
+ - hookSecret: []
3178
+ - {}
3179
+ responses:
3180
+ "200":
3181
+ description: The open escalations.
3182
+ content:
3183
+ application/json:
3184
+ schema:
3185
+ $ref: "#/components/schemas/EscalationList"
3186
+ "401":
3187
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3188
+ content:
3189
+ application/json:
3190
+ schema:
3191
+ $ref: "#/components/schemas/ErrorBody"
3060
3192
  /lineage:
3061
3193
  get:
3062
3194
  operationId: getLineage
@@ -8,14 +8,15 @@ import { noopLog } from "../test/log.ts";
8
8
  import { withTrackingViews } from "../test/trackingViews.ts";
9
9
  import handler from "./listActivePrs.ts";
10
10
 
11
- function memApp(rows: any[], escalations: any[] = []): AppApi {
11
+ function memApp(rows: any[], userTaskRows: any[] = []): AppApi {
12
12
  const table = (name: string) => {
13
- if (name === "escalations") {
13
+ if (name === "user_tasks") {
14
14
  return {
15
+ async all() {
16
+ return userTaskRows;
17
+ },
15
18
  async find(where: Record<string, unknown>) {
16
- return escalations.filter((e) =>
17
- Object.entries(where).every(([k, v]) => e[k] === v)
18
- );
19
+ return userTaskRows.filter((t) => Object.entries(where).every(([k, v]) => t[k] === v));
19
20
  },
20
21
  };
21
22
  }
@@ -58,25 +59,42 @@ test("returns 200 with a count + projected active PRs", async () => {
58
59
  assertEquals(r.body.prs[0].processKey, "9");
59
60
  });
60
61
 
61
- test("surfaces openEscalation for an escalated PR from its open escalations row (both loops)", async () => {
62
- // Regression: a merge-loop escalation parks on a message catch (no user task), so deriving
63
- // openEscalation from a user-task probe hid it. Deriving from the canonical `escalations` row
64
- // surfaces it. Two escalated PRs one with an open row (visible), one already answered (null).
62
+ test("surfaces the structured openEscalation from the user_tasks read model (both loops)", async () => {
63
+ // Issue #666: openEscalation is derived from the ONE `user_tasks` read model (the same surface
64
+ // `listEscalations` and the Convergence page consume), so `/status` carries the completable
65
+ // userTaskKey. A `user_tasks` row for a PR subject exists iff its review/merge-loop escalation task
66
+ // is currently open; a PR with no such row derives null. Cover a review-loop and a merge-loop PR.
65
67
  const app = memApp(
66
68
  [
67
69
  { pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
68
- { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "answered", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
70
+ { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "no open task", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
69
71
  ],
70
72
  [
71
- { id: 1, pr_key: "o/r#10", status: "open", question: "Resolve the conflict on the branch, then retry?" },
72
- { id: 2, pr_key: "o/r#11", status: "answered", question: "old question" },
73
+ {
74
+ user_task_key: "ut-10",
75
+ element_id: "wait-merge-answer",
76
+ kind_label: "PR merge",
77
+ subject_type: "pr",
78
+ subject_key: "o/r#10",
79
+ subject_title: "merge blocked",
80
+ subject_url: null,
81
+ question: "Resolve the conflict on the branch, then retry?",
82
+ process_key: "m1",
83
+ form_key: null,
84
+ created_at: "2026-02-02",
85
+ updated_at: "2026-02-02",
86
+ },
73
87
  ],
74
88
  );
75
89
  const res = (await handler(input(), app)) as any;
76
90
  assertEquals(res.status, 200);
77
91
  const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
78
92
  const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
79
- assertEquals(p10.openEscalation, "Resolve the conflict on the branch, then retry?");
93
+ assertEquals(p10.openEscalation, {
94
+ userTaskKey: "ut-10",
95
+ kind: "wait-merge-answer",
96
+ summary: "Resolve the conflict on the branch, then retry?",
97
+ });
80
98
  assertEquals(p11.openEscalation, null);
81
99
  });
82
100
 
@@ -0,0 +1,215 @@
1
+ // Tests for GET /app/api/escalations operation `listEscalations` (epic #664, issue #666).
2
+ //
3
+ // The read tool that lists EVERY open native user-task escalation with its completable `userTaskKey`,
4
+ // so a tool-aware agent discovers keys on-tool instead of curling the un-projected `/tasks/api/tasks`
5
+ // inbox. It projects the ONE `user_tasks` read model (the same surface the Tasks inbox / Convergence
6
+ // page consume) via the pure `toEscalationView` derivation — no second source of truth.
7
+ //
8
+ // The headline round-trip test proves the acceptance criterion: an open escalation is listed by
9
+ // `listEscalations` with the EXACT `userTaskKey` that `completeUserTask` then resolves.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import type { AppApi } from "@nanobpm/urban";
13
+ import { noopLog } from "../test/log.ts";
14
+ import completeHandler from "./completeUserTask.ts";
15
+ import listHandler from "./listEscalations.ts";
16
+
17
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors sibling op tests
18
+ function memApp(
19
+ seedUserTasks: Record<string, unknown>[],
20
+ openTasks: { userTaskKey: string; elementId?: string }[],
21
+ ): {
22
+ app: AppApi;
23
+ // biome-ignore lint/suspicious/noExplicitAny: see above
24
+ stores: Record<string, any[]>;
25
+ completed: { userTaskKey: string; variables: Record<string, unknown> }[];
26
+ } {
27
+ // biome-ignore lint/suspicious/noExplicitAny: see above
28
+ const stores: Record<string, any[]> = { user_tasks: [...seedUserTasks] };
29
+ const completed: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
30
+ function tbl(name: string, pk: string) {
31
+ // biome-ignore lint/suspicious/noExplicitAny: see above
32
+ const rows = (stores[name] ??= [] as any[]);
33
+ return {
34
+ // biome-ignore lint/suspicious/noExplicitAny: see above
35
+ async insert(row: any) {
36
+ rows.push({ ...row });
37
+ return rows.length;
38
+ },
39
+ // biome-ignore lint/suspicious/noExplicitAny: see above
40
+ async get(id: any) {
41
+ return rows.find((r) => r[pk] === id);
42
+ },
43
+ async all() {
44
+ return [...rows];
45
+ },
46
+ // biome-ignore lint/suspicious/noExplicitAny: see above
47
+ async find(where: any = {}) {
48
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
49
+ },
50
+ // biome-ignore lint/suspicious/noExplicitAny: see above
51
+ async delete(id: any) {
52
+ const i = rows.findIndex((r) => r[pk] === id);
53
+ if (i >= 0) rows.splice(i, 1);
54
+ },
55
+ // biome-ignore lint/suspicious/noExplicitAny: see above
56
+ async update(id: any, patch: any) {
57
+ const r = rows.find((row) => row[pk] === id);
58
+ if (r) Object.assign(r, patch);
59
+ },
60
+ };
61
+ }
62
+ const engine = {
63
+ openUserTasks: async () => openTasks,
64
+ searchUserTasks: async () => openTasks,
65
+ completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
66
+ completed.push({ userTaskKey, variables });
67
+ },
68
+ };
69
+ const app = {
70
+ data: { table: (n: string, pk: string) => tbl(n, pk) },
71
+ engine,
72
+ log: noopLog(),
73
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
74
+ } as any as AppApi;
75
+ return { app, stores, completed };
76
+ }
77
+
78
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
79
+ async function callList(app: AppApi): Promise<any> {
80
+ // biome-ignore lint/suspicious/noExplicitAny: see above
81
+ return (await listHandler({ req: { headers: new Headers() } as any, params: {}, query: {}, body: undefined } as any, app)) as any;
82
+ }
83
+
84
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
85
+ async function callComplete(app: AppApi, body: unknown): Promise<any> {
86
+ // biome-ignore lint/suspicious/noExplicitAny: see above
87
+ return (await completeHandler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
88
+ }
89
+
90
+ function utRow(over: Record<string, unknown>): Record<string, unknown> {
91
+ return {
92
+ user_task_key: "ut-x",
93
+ element_id: "wait-answer",
94
+ kind_label: "PR review",
95
+ subject_type: "pr",
96
+ subject_key: "acme/repo#7",
97
+ subject_title: "Add widget",
98
+ subject_url: "https://github.com/acme/repo/pull/7",
99
+ question: "Which API version?",
100
+ process_key: "pi-1",
101
+ form_key: "form-pr",
102
+ created_at: "2026-01-01T00:00:00.000Z",
103
+ updated_at: "2026-01-01T00:00:00.000Z",
104
+ ...over,
105
+ };
106
+ }
107
+
108
+ test("listEscalations: round-trip — the listed userTaskKey is exactly what completeUserTask resolves", async () => {
109
+ const { app, stores, completed } = memApp(
110
+ [utRow({ user_task_key: "ut-answer", element_id: "wait-answer" })],
111
+ [{ userTaskKey: "ut-answer", elementId: "wait-answer" }],
112
+ );
113
+
114
+ const listed = await callList(app);
115
+ assertEquals(listed.status, 200);
116
+ assertEquals(listed.body.count, 1);
117
+ const esc = listed.body.escalations[0];
118
+ assertEquals(esc.userTaskKey, "ut-answer");
119
+ assertEquals(esc.kind, "wait-answer");
120
+ assertEquals(esc.prKey, "acme/repo#7");
121
+ assertEquals(esc.question, "Which API version?");
122
+ assertEquals(esc.formKey, "form-pr");
123
+
124
+ // Answer the exact key the list handed back — it resolves via the canonical completer.
125
+ const done = await callComplete(app, { userTaskKey: esc.userTaskKey, variables: { answer: "v2" } });
126
+ assertEquals(done.status, 200);
127
+ assertEquals(done.body.ok, true);
128
+ assertEquals(done.body.elementId, "wait-answer");
129
+ assertEquals(completed, [{ userTaskKey: "ut-answer", variables: { answer: "v2" } }]);
130
+ // The answered task's read-model row is dropped, so a re-list no longer shows it.
131
+ assertEquals(stores.user_tasks, []);
132
+ const reListed = await callList(app);
133
+ assertEquals(reListed.body.count, 0);
134
+ });
135
+
136
+ test("listEscalations: lists across all four escalation kinds, newest-updated first", async () => {
137
+ const { app } = memApp(
138
+ [
139
+ utRow({ user_task_key: "ut-pr", element_id: "wait-answer", updated_at: "2026-01-04T00:00:00.000Z" }),
140
+ utRow({
141
+ user_task_key: "ut-plan",
142
+ element_id: "plan-review-decision",
143
+ kind_label: "Plan review",
144
+ subject_type: "plan",
145
+ subject_key: "acme/repo#99",
146
+ updated_at: "2026-01-03T00:00:00.000Z",
147
+ }),
148
+ utRow({
149
+ user_task_key: "ut-trial",
150
+ element_id: "trial-merge-decision",
151
+ kind_label: "Trial merge",
152
+ subject_type: "plan",
153
+ subject_key: "acme/repo#99",
154
+ updated_at: "2026-01-02T00:00:00.000Z",
155
+ }),
156
+ utRow({
157
+ user_task_key: "ut-feat",
158
+ element_id: "feature-escalation",
159
+ kind_label: "Feature escalation",
160
+ subject_type: "feature",
161
+ subject_key: "acme/repo#42",
162
+ updated_at: "2026-01-01T00:00:00.000Z",
163
+ }),
164
+ ],
165
+ [],
166
+ );
167
+
168
+ const res = await callList(app);
169
+ assertEquals(res.status, 200);
170
+ assertEquals(res.body.count, 4);
171
+ assertEquals(
172
+ res.body.escalations.map((e: { userTaskKey: string }) => e.userTaskKey),
173
+ ["ut-pr", "ut-plan", "ut-trial", "ut-feat"],
174
+ );
175
+ // Non-PR subjects carry a null prKey; the PR subject carries the pr key.
176
+ const byKey = Object.fromEntries(res.body.escalations.map((e: { userTaskKey: string }) => [e.userTaskKey, e]));
177
+ assertEquals(byKey["ut-pr"].prKey, "acme/repo#7");
178
+ assertEquals(byKey["ut-plan"].prKey, null);
179
+ assertEquals(byKey["ut-feat"].subjectType, "feature");
180
+ });
181
+
182
+ test("listEscalations: empty when no open escalations", async () => {
183
+ const { app } = memApp([], []);
184
+ const res = await callList(app);
185
+ assertEquals(res.status, 200);
186
+ assertEquals(res.body, { count: 0, escalations: [] });
187
+ });
188
+
189
+ // The optional shared-secret guard is captured at module load from NANO_PR_WEBHOOK_SECRET, so
190
+ // cache-bust re-import the handler with the env set to exercise both the rejected (401, missing
191
+ // header) and authorized (200, correct header) paths deterministically — mirrors the read-door
192
+ // guard tests on sibling ops (listActivePrs, listLibrary).
193
+ test("listEscalations: shared-secret guard — 401 without x-hook-secret, 200 with it", async () => {
194
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
195
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
196
+ try {
197
+ const mod = await import(`./listEscalations.ts?guard=${Date.now()}`);
198
+ const guarded = mod.default as typeof listHandler;
199
+ const { app } = memApp([], []);
200
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
201
+ const bad = (await guarded({ req: { headers: new Headers() } as any, params: {}, query: {}, body: undefined } as any, app)) as any;
202
+ assertEquals(bad.status, 401);
203
+ const ok = (await guarded(
204
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
205
+ { req: { headers: new Headers({ "x-hook-secret": "s3cr3t" }) } as any, params: {}, query: {}, body: undefined } as any,
206
+ app,
207
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
208
+ )) as any;
209
+ assertEquals(ok.status, 200);
210
+ assert("count" in ok.body);
211
+ } finally {
212
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
213
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
214
+ }
215
+ });
@@ -0,0 +1,32 @@
1
+ // GET /app/api/escalations → operationId `listEscalations` (epic #664, issue #666). Discovery for the
2
+ // escalation-answer path: list EVERY currently-open native user-task escalation — across every
3
+ // surfaced kind (PR review/merge loop, plan-review, empty-plan, trial-merge, conformance-review,
4
+ // delivery human-step, feature/blocked, agent-permission and the shared human-escalation cell) —
5
+ // with the completable `userTaskKey` an agent then answers via
6
+ // `completeUserTask` / `agentCompleteEscalation`. This closes the fallback where an agent had to curl
7
+ // the un-projected `/tasks/api/tasks` inbox to find keys before answering.
8
+ //
9
+ // Read-only projection over the ONE `user_tasks` read model the Tasks inbox and Convergence page
10
+ // consume (`userTasks` + the pure `toEscalationView` derivation in app/userTasks.ts) — NOT a second
11
+ // source of truth. A row exists iff its task is open, so the list reflects live pending work.
12
+ //
13
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`):
14
+ // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset →
15
+ // open (unchanged default), mirroring `listActivePrs`.
16
+ import { toEscalationView, userTasks } from "../app/userTasks.ts";
17
+ import { envVar } from "../app/version.ts";
18
+ import { defineOperation } from "../nano-generated/operations.ts";
19
+
20
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
21
+
22
+ export default defineOperation("listEscalations", async ({ req }, app) => {
23
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
24
+ app.log.warn("listEscalations rejected: missing/invalid shared secret");
25
+ return { status: 401, body: { error: "unauthorized" } };
26
+ }
27
+ const rows = await userTasks(app.data).all();
28
+ const escalations = rows
29
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))
30
+ .map(toEscalationView);
31
+ return { status: 200, body: { count: escalations.length, escalations } };
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.169.0",
3
+ "version": "0.170.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",