@nanobpm/nano-workforce 0.168.2 → 0.170.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.168.2",
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",