@nanobpm/nano-workforce 0.168.2 → 0.169.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.169.0](https://github.com/nanobpm/nano-workforce/compare/v0.168.2...v0.169.0) (2026-08-31)
2
+
3
+ ### Features
4
+
5
+ * add getPrHistory read tool for PR escalation/round history ([#670](https://github.com/nanobpm/nano-workforce/issues/670)) ([22bd6f9](https://github.com/nanobpm/nano-workforce/commit/22bd6f9d28b891a2b4c157d21bad94cb11af1eb4)), closes [#668](https://github.com/nanobpm/nano-workforce/issues/668)
6
+
1
7
  ## [0.168.2](https://github.com/nanobpm/nano-workforce/compare/v0.168.1...v0.168.2) (2026-08-31)
2
8
 
3
9
  ### Bug Fixes
@@ -0,0 +1,117 @@
1
+ // PR round + escalation history read model (issue #668, N4 of epic #664).
2
+ //
3
+ // "Why did this PR escalate?" / "what happened in prior rounds?" lives in the `rounds` and
4
+ // `escalations` tables — the SAME two tables the Convergence page (`pages/home.page.json`, the PR
5
+ // detail's "Rounds" and "Escalations" child grids) reads directly. Until now the only way to answer
6
+ // those questions off the UI was to ssh into the instance and query the DB by hand. This module is
7
+ // the ONE canonical reader over those tables so an MCP read tool (`getPrHistory`) can surface the
8
+ // same history without DB access.
9
+ //
10
+ // Derivation over duplication (AGENTS.md): this does NOT introduce a new projection table or a
11
+ // second source of truth. The Convergence page's "query" is declarative datasource JSON over the
12
+ // `rounds`/`escalations` tables (ordered rounds-by-round_no, escalations-by-id); this function reads
13
+ // those exact tables with the exact same orderings, so the tool and the page cannot drift onto
14
+ // different data.
15
+ import type { DataLayer } from "@nanobpm/urban";
16
+
17
+ /** A `rounds` row — the per-round convergence record the Convergence page's "Rounds" grid reads. */
18
+ interface RoundRow {
19
+ id: number;
20
+ pr_key: string;
21
+ round_no: number;
22
+ status: string | null;
23
+ summary: string | null;
24
+ worker: string | null;
25
+ started_at: string;
26
+ ended_at: string | null;
27
+ }
28
+
29
+ /** An `escalations` row — the escalation record the Convergence page's "Escalations" grid reads. */
30
+ interface EscalationRow {
31
+ id: number;
32
+ pr_key: string;
33
+ round_no: number;
34
+ kind: string;
35
+ question: string;
36
+ answer: string | null;
37
+ status: string;
38
+ worker: string | null;
39
+ asked_at: string;
40
+ answered_at: string | null;
41
+ }
42
+
43
+ /** The subset of a `pull_requests` row this module needs to resolve a processKey → prKey. */
44
+ interface PrKeyRow {
45
+ pr_key: string;
46
+ process_key: string | null;
47
+ }
48
+
49
+ /** One round in a PR's timeline: its status transition/outcome, owning worker, and timestamps. */
50
+ export interface PrHistoryRound {
51
+ roundNo: number;
52
+ status: string | null;
53
+ worker: string | null;
54
+ summary: string | null;
55
+ startedAt: string;
56
+ endedAt: string | null;
57
+ }
58
+
59
+ /** One escalation in a PR's history: its kind, question/answer, status, and timestamps. */
60
+ export interface PrHistoryEscalation {
61
+ roundNo: number;
62
+ kind: string;
63
+ worker: string | null;
64
+ question: string;
65
+ answer: string | null;
66
+ status: string;
67
+ askedAt: string;
68
+ answeredAt: string | null;
69
+ }
70
+
71
+ /** A PR's full escalation + round history, as surfaced by the Convergence page's PR detail. */
72
+ export interface PrHistory {
73
+ prKey: string;
74
+ rounds: PrHistoryRound[];
75
+ escalations: PrHistoryEscalation[];
76
+ }
77
+
78
+ const rounds = (data: DataLayer) => data.table<RoundRow>("rounds", "id");
79
+ const escs = (data: DataLayer) => data.table<EscalationRow>("escalations", "id");
80
+ const prs = (data: DataLayer) => data.table<PrKeyRow>("pull_requests", "pr_key");
81
+
82
+ /** Resolve an engine process-instance key to the PR it drives (unique per instance), or null. */
83
+ export async function prKeyForProcess(data: DataLayer, processKey: string): Promise<string | null> {
84
+ const matches = await prs(data).find({ process_key: processKey });
85
+ return matches[0]?.pr_key ?? null;
86
+ }
87
+
88
+ /** The canonical PR history read: rounds (round_no asc) + escalations (id asc, i.e. asked order) for
89
+ * one PR, projected to the wire shape. An unknown `prKey` yields an empty history (no throw), so a
90
+ * caller can distinguish "no history yet" from an error without a 404 round-trip. */
91
+ export async function prHistory(data: DataLayer, prKey: string): Promise<PrHistory> {
92
+ const roundRows = (await rounds(data).find({ pr_key: prKey })).sort(
93
+ (a, b) => a.round_no - b.round_no || a.id - b.id,
94
+ );
95
+ const escRows = (await escs(data).find({ pr_key: prKey })).sort((a, b) => a.id - b.id);
96
+ return {
97
+ prKey,
98
+ rounds: roundRows.map((r) => ({
99
+ roundNo: r.round_no,
100
+ status: r.status ?? null,
101
+ worker: r.worker ?? null,
102
+ summary: r.summary ?? null,
103
+ startedAt: r.started_at,
104
+ endedAt: r.ended_at ?? null,
105
+ })),
106
+ escalations: escRows.map((e) => ({
107
+ roundNo: e.round_no,
108
+ kind: e.kind,
109
+ worker: e.worker ?? null,
110
+ question: e.question,
111
+ answer: e.answer ?? null,
112
+ status: e.status,
113
+ askedAt: e.asked_at,
114
+ answeredAt: e.answered_at ?? null,
115
+ })),
116
+ };
117
+ }
package/openapi.yaml CHANGED
@@ -223,6 +223,93 @@ components:
223
223
  type: array
224
224
  items:
225
225
  $ref: "#/components/schemas/LineageThreadView"
226
+ PrHistoryRound:
227
+ type: object
228
+ description: One convergence round in a PR's timeline (issue #668) — its status transition/outcome, owning worker, and timestamps. Sourced from the `rounds` table the Convergence page reads.
229
+ additionalProperties: false
230
+ required:
231
+ - roundNo
232
+ - status
233
+ - worker
234
+ - summary
235
+ - startedAt
236
+ - endedAt
237
+ properties:
238
+ roundNo:
239
+ type: integer
240
+ status:
241
+ type: string
242
+ nullable: true
243
+ description: The round's result/status transition (converged|addressed|waiting|needs_input|blocked).
244
+ worker:
245
+ type: string
246
+ nullable: true
247
+ description: The worker that ran this round.
248
+ summary:
249
+ type: string
250
+ nullable: true
251
+ description: The round outcome summary.
252
+ startedAt:
253
+ type: string
254
+ endedAt:
255
+ type: string
256
+ nullable: true
257
+ PrHistoryEscalation:
258
+ type: object
259
+ description: One escalation in a PR's history (issue #668) — its kind, question/answer, status, and timestamps. Sourced from the `escalations` table the Convergence page reads.
260
+ additionalProperties: false
261
+ required:
262
+ - roundNo
263
+ - kind
264
+ - worker
265
+ - question
266
+ - answer
267
+ - status
268
+ - askedAt
269
+ - answeredAt
270
+ properties:
271
+ roundNo:
272
+ type: integer
273
+ kind:
274
+ type: string
275
+ description: The escalation kind (question|blocker).
276
+ worker:
277
+ type: string
278
+ nullable: true
279
+ description: The worker that raised this escalation.
280
+ question:
281
+ type: string
282
+ answer:
283
+ type: string
284
+ nullable: true
285
+ description: The human's answer; null while the escalation is still open.
286
+ status:
287
+ type: string
288
+ description: open | answered | stale.
289
+ askedAt:
290
+ type: string
291
+ answeredAt:
292
+ type: string
293
+ nullable: true
294
+ PrHistory:
295
+ type: object
296
+ description: A PR's full escalation + round history (issue #668), as surfaced read-only on the Convergence page's PR detail. Empty arrays when the PR has no history (or is unknown).
297
+ additionalProperties: false
298
+ required:
299
+ - prKey
300
+ - rounds
301
+ - escalations
302
+ properties:
303
+ prKey:
304
+ type: string
305
+ rounds:
306
+ type: array
307
+ items:
308
+ $ref: "#/components/schemas/PrHistoryRound"
309
+ escalations:
310
+ type: array
311
+ items:
312
+ $ref: "#/components/schemas/PrHistoryEscalation"
226
313
  AgenticSupplyWorker:
227
314
  type: object
228
315
  description: One connected worker in the supply mirror (H5 cockpit; sourced from the H1 presence registry).
@@ -2997,6 +3084,45 @@ paths:
2997
3084
  application/json:
2998
3085
  schema:
2999
3086
  $ref: "#/components/schemas/ErrorBody"
3087
+ /prs/history:
3088
+ get:
3089
+ operationId: getPrHistory
3090
+ summary: "A PR's escalation + round history (issue #668): per-round status transitions and outcome, plus each escalation's kind, question/answer, and timestamps — the read that retires the DB-over-ssh fallback. Identify the PR by prKey, or by processKey. Reads the same rounds/escalations tables the Convergence page surfaces."
3091
+ security:
3092
+ - hookSecret: []
3093
+ - {}
3094
+ parameters:
3095
+ - name: prKey
3096
+ in: query
3097
+ required: false
3098
+ schema:
3099
+ type: string
3100
+ description: The PR key ("<owner>/<repo>#<number>"). Provide this or processKey.
3101
+ - name: processKey
3102
+ in: query
3103
+ required: false
3104
+ schema:
3105
+ type: string
3106
+ description: The engine process-instance key driving the PR; resolved to its prKey. Provide this or prKey.
3107
+ responses:
3108
+ "200":
3109
+ description: The PR's round + escalation history (empty arrays when there is none).
3110
+ content:
3111
+ application/json:
3112
+ schema:
3113
+ $ref: "#/components/schemas/PrHistory"
3114
+ "400":
3115
+ description: Neither prKey nor processKey supplied.
3116
+ content:
3117
+ application/json:
3118
+ schema:
3119
+ $ref: "#/components/schemas/ErrorBody"
3120
+ "401":
3121
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
3122
+ content:
3123
+ application/json:
3124
+ schema:
3125
+ $ref: "#/components/schemas/ErrorBody"
3000
3126
  /agentic/supply:
3001
3127
  get:
3002
3128
  operationId: getAgenticSupply
@@ -0,0 +1,137 @@
1
+ // Tests for GET /app/api/prs/history → operation `getPrHistory` (issue #668, N4 of epic #664).
2
+ // The headline scenario: a PR that escalated and then RESUMED (its escalation answered, a fresh round
3
+ // recorded) exposes its full history — every round's status/outcome plus the answered escalation's
4
+ // question/answer — through the tool, with NO DB access (the handler reads the same `rounds`/
5
+ // `escalations` tables the Convergence page surfaces, via a minimal in-memory DataLayer). Also covers
6
+ // the processKey → prKey resolution, the prKey/processKey-required 400, and the shared-secret guard.
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { noopLog } from "../test/log.ts";
11
+ import handler from "./getPrHistory.ts";
12
+
13
+ // biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row shapes.
14
+ type Row = any;
15
+
16
+ function memApp(tables: { rounds?: Row[]; escalations?: Row[]; pull_requests?: Row[] }): AppApi {
17
+ const stores: Record<string, Row[]> = {
18
+ rounds: tables.rounds ?? [],
19
+ escalations: tables.escalations ?? [],
20
+ pull_requests: tables.pull_requests ?? [],
21
+ };
22
+ const table = (name: string) => ({
23
+ async find(where: Record<string, unknown>) {
24
+ return (stores[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
25
+ },
26
+ });
27
+ return { data: { table }, log: noopLog() } as unknown as AppApi;
28
+ }
29
+
30
+ function input(query: Record<string, string>, headers: Record<string, string> = {}) {
31
+ return {
32
+ req: {
33
+ method: "GET",
34
+ path: "/app/api/prs/history",
35
+ query: new URLSearchParams(query),
36
+ headers: new Headers(headers),
37
+ text: async () => "",
38
+ } as unknown,
39
+ params: {},
40
+ query,
41
+ body: undefined,
42
+ };
43
+ }
44
+
45
+ // A PR that escalated in round 1 (blocker), had that escalation answered, and RESUMED into round 2.
46
+ function escalatedThenResumed() {
47
+ return memApp({
48
+ pull_requests: [{ pr_key: "o/r#7", process_key: "proc-7" }],
49
+ rounds: [
50
+ { id: 10, pr_key: "o/r#7", round_no: 1, status: "needs_input", summary: "hit an ambiguity", worker: "senior-a", started_at: "2026-03-01T00:00:00Z", ended_at: "2026-03-01T00:05:00Z" },
51
+ { id: 11, pr_key: "o/r#7", round_no: 2, status: "converged", summary: "resolved after the answer", worker: "senior-a", started_at: "2026-03-01T01:00:00Z", ended_at: "2026-03-01T01:05:00Z" },
52
+ ],
53
+ escalations: [
54
+ { id: 20, pr_key: "o/r#7", round_no: 1, kind: "blocker", question: "Which base branch?", answer: "main", status: "answered", worker: "senior-a", asked_at: "2026-03-01T00:03:00Z", answered_at: "2026-03-01T00:50:00Z" },
55
+ ],
56
+ });
57
+ }
58
+
59
+ test("an escalated-then-resumed PR exposes its round + escalation history by prKey", async () => {
60
+ const res = (await handler(input({ prKey: "o/r#7" }), escalatedThenResumed())) as Row;
61
+ assertEquals(res.status, 200);
62
+ assertEquals(res.body.prKey, "o/r#7");
63
+
64
+ // Both rounds surface, in round order, with their status transition + outcome summary.
65
+ assertEquals(res.body.rounds.length, 2);
66
+ assertEquals(res.body.rounds[0].roundNo, 1);
67
+ assertEquals(res.body.rounds[0].status, "needs_input");
68
+ assertEquals(res.body.rounds[1].roundNo, 2);
69
+ assertEquals(res.body.rounds[1].status, "converged");
70
+ assertEquals(res.body.rounds[1].summary, "resolved after the answer");
71
+
72
+ // The escalation surfaces with its kind, question, answer, and timestamps — the "why did it
73
+ // escalate / what was answered" the tool exists to expose.
74
+ assertEquals(res.body.escalations.length, 1);
75
+ const e = res.body.escalations[0];
76
+ assertEquals(e.kind, "blocker");
77
+ assertEquals(e.question, "Which base branch?");
78
+ assertEquals(e.answer, "main");
79
+ assertEquals(e.status, "answered");
80
+ assertEquals(e.roundNo, 1);
81
+ assertEquals(e.askedAt, "2026-03-01T00:03:00Z");
82
+ assertEquals(e.answeredAt, "2026-03-01T00:50:00Z");
83
+ });
84
+
85
+ test("resolves the PR by processKey when no prKey is given", async () => {
86
+ const res = (await handler(input({ processKey: "proc-7" }), escalatedThenResumed())) as Row;
87
+ assertEquals(res.status, 200);
88
+ assertEquals(res.body.prKey, "o/r#7");
89
+ assertEquals(res.body.rounds.length, 2);
90
+ assertEquals(res.body.escalations.length, 1);
91
+ });
92
+
93
+ test("orders rounds by round_no and escalations in asked order", async () => {
94
+ const app = memApp({
95
+ rounds: [
96
+ { id: 2, pr_key: "o/r#9", round_no: 2, status: "converged", summary: null, worker: null, started_at: "b", ended_at: null },
97
+ { id: 1, pr_key: "o/r#9", round_no: 1, status: "addressed", summary: null, worker: null, started_at: "a", ended_at: null },
98
+ ],
99
+ escalations: [
100
+ { id: 5, pr_key: "o/r#9", round_no: 2, kind: "question", question: "second", answer: null, status: "open", worker: null, asked_at: "b", answered_at: null },
101
+ { id: 4, pr_key: "o/r#9", round_no: 1, kind: "question", question: "first", answer: null, status: "answered", worker: null, asked_at: "a", answered_at: "a2" },
102
+ ],
103
+ });
104
+ const res = (await handler(input({ prKey: "o/r#9" }), app)) as Row;
105
+ assertEquals(res.body.rounds.map((r: Row) => r.roundNo), [1, 2]);
106
+ assertEquals(res.body.escalations.map((e: Row) => e.question), ["first", "second"]);
107
+ });
108
+
109
+ test("an unknown PR returns an empty history (not a 404)", async () => {
110
+ const res = (await handler(input({ prKey: "o/r#404" }), memApp({}))) as Row;
111
+ assertEquals(res.status, 200);
112
+ assertEquals(res.body.prKey, "o/r#404");
113
+ assertEquals(res.body.rounds, []);
114
+ assertEquals(res.body.escalations, []);
115
+ });
116
+
117
+ test("neither prKey nor processKey → 400", async () => {
118
+ const res = (await handler(input({}), memApp({}))) as Row;
119
+ assertEquals(res.status, 400);
120
+ });
121
+
122
+ test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
123
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
124
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
125
+ try {
126
+ const mod = await import(`./getPrHistory.ts?guard=${Date.now()}`);
127
+ const guarded = mod.default as typeof handler;
128
+ const bad = (await guarded(input({ prKey: "o/r#7" }), escalatedThenResumed())) as Row;
129
+ assertEquals(bad.status, 401);
130
+ const ok = (await guarded(input({ prKey: "o/r#7" }, { "x-hook-secret": "s3cr3t" }), escalatedThenResumed())) as Row;
131
+ assertEquals(ok.status, 200);
132
+ assert(Array.isArray(ok.body.rounds));
133
+ } finally {
134
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
135
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
136
+ }
137
+ });
@@ -0,0 +1,41 @@
1
+ // GET /app/api/prs/history → operationId `getPrHistory` (issue #668, N4 of epic #664). Surface a PR's
2
+ // escalation + round history — "why did this escalate?" / "what happened in prior rounds?" — as an
3
+ // MCP read tool, so an operator or external harness no longer has to ssh into the instance and query
4
+ // the `escalations`/`rounds` tables by hand.
5
+ //
6
+ // Read-only projection over the SAME `rounds`/`escalations` tables the Convergence page's PR detail
7
+ // reads (see app/prHistory.ts). Identify the PR by `prKey` directly, or by `processKey` (resolved to
8
+ // its pr_key). At least one is required; neither → 400.
9
+ //
10
+ // The optional shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`):
11
+ // when NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header.
12
+ import { prHistory, prKeyForProcess } from "../app/prHistory.ts";
13
+ import { envVar } from "../app/version.ts";
14
+ import { defineOperation } from "../nano-generated/operations.ts";
15
+
16
+ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
17
+
18
+ export default defineOperation("getPrHistory", async ({ query, req }, app) => {
19
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
20
+ app.log.warn("getPrHistory rejected: missing/invalid shared secret");
21
+ return { status: 401, body: { error: "unauthorized" } };
22
+ }
23
+ const rawPrKey = query.prKey;
24
+ const rawProcessKey = query.processKey;
25
+ const prKeyArg = typeof rawPrKey === "string" ? rawPrKey.trim() : "";
26
+ const processKeyArg = typeof rawProcessKey === "string" ? rawProcessKey.trim() : "";
27
+
28
+ if (!prKeyArg && !processKeyArg) {
29
+ return { status: 400, body: { error: "prKey or processKey is required" } };
30
+ }
31
+
32
+ const prKey = prKeyArg || (await prKeyForProcess(app.data, processKeyArg));
33
+ if (!prKey) {
34
+ // A processKey with no tracked PR: report an empty history for a stable, echoable identity
35
+ // rather than a 404, mirroring getLineage's "empty if unknown" read semantics.
36
+ return { status: 200, body: { prKey: processKeyArg, rounds: [], escalations: [] } };
37
+ }
38
+
39
+ const history = await prHistory(app.data, prKey);
40
+ return { status: 200, body: history };
41
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.168.2",
3
+ "version": "0.169.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",