@nanobpm/nano-workforce 0.168.2 → 0.170.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/mcpExclusions.test.ts +1 -0
- package/app/prHistory.ts +117 -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 +259 -1
- package/operations/getPrHistory.test.ts +137 -0
- package/operations/getPrHistory.ts +41 -0
- 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
|
@@ -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",
|