@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 +6 -0
- package/app/prHistory.ts +117 -0
- package/openapi.yaml +126 -0
- package/operations/getPrHistory.test.ts +137 -0
- package/operations/getPrHistory.ts +41 -0
- package/package.json +1 -1
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
|
package/app/prHistory.ts
ADDED
|
@@ -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.
|
|
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",
|