@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 +6 -0
- package/app/mcpExclusions.test.ts +1 -0
- package/app/prParse.ts +34 -0
- package/app/service.ts +31 -47
- package/app/userTasks.test.ts +36 -0
- package/app/userTasks.ts +75 -0
- package/e2e/convergence-escalation.e2e.ts +19 -7
- package/e2e/retire-escalation-subsystem.e2e.ts +18 -5
- package/openapi.yaml +133 -1
- package/operations/listActivePrs.test.ts +31 -13
- package/operations/listEscalations.test.ts +215 -0
- package/operations/listEscalations.ts +32 -0
- package/package.json +1 -1
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
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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:
|
|
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
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
* `
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
* BOTH loops' escalations uniformly. Once
|
|
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
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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,
|
package/app/userTasks.test.ts
CHANGED
|
@@ -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<{
|
|
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
|
|
153
|
-
//
|
|
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
|
|
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`
|
|
189
|
-
//
|
|
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<{
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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[],
|
|
11
|
+
function memApp(rows: any[], userTaskRows: any[] = []): AppApi {
|
|
12
12
|
const table = (name: string) => {
|
|
13
|
-
if (name === "
|
|
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
|
|
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
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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: "
|
|
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
|
-
{
|
|
72
|
-
|
|
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,
|
|
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.
|
|
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",
|