@nanobpm/nano-workforce 0.169.0 → 0.170.1
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/agentGuide.ts +1 -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/docs/agent-guide.md +198 -42
- package/docs/mcp-runbook.md +21 -4
- package/e2e/convergence-escalation.e2e.ts +19 -7
- package/e2e/retire-escalation-subsystem.e2e.ts +18 -5
- package/openapi.yaml +134 -2
- 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/pages/overview.page.json +2 -2
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
|
|
@@ -3496,7 +3628,7 @@ paths:
|
|
|
3496
3628
|
example: delivery-graphs
|
|
3497
3629
|
description: OPTIONAL stable section id (from the table of contents `getAgentGuide` returns
|
|
3498
3630
|
with no argument), e.g. `orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`,
|
|
3499
|
-
`debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`. Omit it to get the
|
|
3631
|
+
`debug`, `debug-models`, `unstick`, `raise-issue`, `delivery-graphs`, `tool-crosswalk`. Omit it to get the
|
|
3500
3632
|
table of contents. An unknown id yields a 400 listing the valid ids.
|
|
3501
3633
|
responses:
|
|
3502
3634
|
"200":
|
|
@@ -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.1",
|
|
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",
|
package/pages/overview.page.json
CHANGED
|
@@ -82,9 +82,9 @@
|
|
|
82
82
|
"variant": "ghost",
|
|
83
83
|
"modal": {
|
|
84
84
|
"title": "Point your agent at Nano Workforce",
|
|
85
|
-
"description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It connects the agent to this instance's MCP server \u2014 the workforce's operations become native tools (including the live operator guide as `getAgentInstructions`), so it can drive and debug your workforce; agents with no MCP client fall back to fetching the operator skill. Note: `/app/mcp` is served on the same HTTP surface as the rest of the app \u2014 reachable on loopback by default, and from a remote instance (merlin, an ngrok tunnel) only when the app is bound wide (`network.bind`) or fronted by a reverse proxy, per the MCP runbook.",
|
|
85
|
+
"description": "Copy this prompt and paste it into your coding agent (Copilot, Claude, etc.). It connects the agent to this instance's MCP server \u2014 the workforce's operations become native tools (including the live operator guide as `getAgentInstructions`, or its addressable companion `getAgentGuide(section?)` \u2014 preferred over the ~43KB blob to avoid tool-result overrun), so it can drive and debug your workforce; agents with no MCP client fall back to fetching the operator skill. Note: `/app/mcp` is served on the same HTTP surface as the rest of the app \u2014 reachable on loopback by default, and from a remote instance (merlin, an ngrok tunnel) only when the app is bound wide (`network.bind`) or fronted by a reverse proxy, per the MCP runbook.",
|
|
86
86
|
"copyLabel": "Copy prompt",
|
|
87
|
-
"copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool
|
|
87
|
+
"copyText": "Add this running Nano Workforce instance as an MCP server, then help me drive and debug my workforce \u2014 a durable orchestration app that drives pull requests to review convergence against an automated reviewer, merges them, and can take a whole issue and plan \u2192 implement \u2192 converge it across a fleet of coding agents.\n\nRegister ONE MCP server entry per instance (streamable-HTTP transport, URL {{appBase}}app/mcp). Naming the instance makes targeting the wrong one structurally impossible \u2014 its tools are namespaced under that name:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp\n\nGive each instance its own entry (e.g. `workforce-merlin` for a LAN/remote node). In config form (~/.copilot/mcp-config.json or repo-scoped .mcp.json):\n\n {\n \"mcpServers\": {\n \"workforce-local\": {\n \"type\": \"http\",\n \"url\": \"{{appBase}}app/mcp\",\n \"tools\": [\"*\"]\n }\n }\n }\n\nIf this instance is secured with a shared secret (NANO_PR_WEBHOOK_SECRET), pass it on the MCP connection \u2014 reads AND mutations both require it:\n\n copilot mcp add --transport http workforce-local {{appBase}}app/mcp \\\n --header \"x-hook-secret: $NANO_PR_WEBHOOK_SECRET\"\n\nIn config form, add a `headers` entry alongside `url` on that server (per the MCP runbook) \u2014 the config path has no header flag, so omitting this yields 401s:\n\n \"headers\": { \"x-hook-secret\": \"$NANO_PR_WEBHOOK_SECRET\" }\n\nA Basic-Auth-fronted instance (behind a reverse proxy) additionally needs `Authorization: Basic \u2026` on the connection.\n\nMCP servers register at host startup \u2014 add the entry, THEN start a new session so its tools load. The `workforce-*` tools then appear (the full set of operations projected from this instance's OpenAPI contract, including the live operator guide itself as the `getAgentInstructions` read tool \u2014 or its addressable companion `getAgentGuide(section?)`, which the MCP runbook recommends over the ~43KB blob to avoid tool-result overrun \u2014 plus the `urban_debug_*` family for inspecting a wedged instance's process instances, wait states, and incidents).\n\nThe instance's operations are now native tools. Ask, naming the instance: \"Using workforce-local, show what's in flight and any open escalations.\" It should call the status tool, not curl. Operator-only doors (the delivery-graph stage/dispatch/dismiss lifecycle \u2014 the human click IS the approval) are deliberately not tools.\n\nNo MCP client? The curl path is unchanged \u2014 fetch and follow the live guide (the response is JSON with a `skill` markdown field), adding `-H \"x-hook-secret: <secret>\"` if this instance is secured:\n\n curl -sS {{appBase}}app/api/agent/skill\n\nThat skill bootstraps you to this instance's live operator guide at {{appBase}}app/api/agent (the same guide MCP exposes as `getAgentInstructions`, or section-addressably as `getAgentGuide`). If you find a bug or a stuck process, the guide explains how to raise an issue or a PR against nanobpm/nano-workforce."
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
},
|