@nanobpm/nano-workforce 0.34.0 → 0.35.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/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +14 -0
- package/SPEC.md +12 -4
- package/app/plan.ts +4 -3
- package/app/service.test.ts +42 -0
- package/app/service.ts +4 -3
- package/app/version.ts +2 -2
- package/deno.json +1 -1
- package/deno.lock +2 -3
- package/nano.app.json +6 -22
- package/openapi.json +248 -0
- package/operations/getVersion.test.ts +60 -0
- package/operations/getVersion.ts +25 -0
- package/operations/listActivePrs.test.ts +70 -0
- package/operations/listActivePrs.ts +29 -0
- package/{actions/message.ts → operations/postMessage.ts} +26 -19
- package/operations/startAndMessage.test.ts +55 -0
- package/operations/startConvergenceLoop.ts +28 -0
- package/operations/startPlanFanout.ts +27 -0
- package/package.json +2 -2
- package/actions/plan-start.ts +0 -17
- package/actions/start.ts +0 -19
- package/actions/status.ts +0 -22
- package/actions/version.test.ts +0 -85
- package/actions/version.ts +0 -25
package/.github/workflows/ci.yml
CHANGED
|
@@ -44,7 +44,7 @@ jobs:
|
|
|
44
44
|
deno-version: v2.x
|
|
45
45
|
|
|
46
46
|
- name: Typecheck (Deno)
|
|
47
|
-
run: deno check main.ts 'workers/**/*.ts' 'actions/**/*.ts'
|
|
47
|
+
run: deno check main.ts 'workers/**/*.ts' 'actions/**/*.ts' 'operations/**/*.ts'
|
|
48
48
|
|
|
49
49
|
# Deploy-safety gate: every model-authored `{{template}}` agent-prompt header must resolve
|
|
50
50
|
# to a declared, non-blank prompt (and no agent prompt may ship blank). A broken/blank token
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.35.1](https://github.com/nanobpm/nano-workforce/compare/v0.35.0...v0.35.1) (2026-08-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **api:** move OpenAPI base off the reserved /app page-runtime namespace ([#103](https://github.com/nanobpm/nano-workforce/issues/103)) ([6dfb77f](https://github.com/nanobpm/nano-workforce/commit/6dfb77ff82b532965c4a9823d771d46d4ffa920e)), closes [#102](https://github.com/nanobpm/nano-workforce/issues/102)
|
|
7
|
+
|
|
8
|
+
# [0.35.0](https://github.com/nanobpm/nano-workforce/compare/v0.34.0...v0.35.0) (2026-08-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* migrate externally-facing endpoints to the OpenAPI api surface ([#102](https://github.com/nanobpm/nano-workforce/issues/102)) ([5598e2c](https://github.com/nanobpm/nano-workforce/commit/5598e2ce5a8ec3dc241401714284ce8c94420f7a))
|
|
14
|
+
|
|
1
15
|
# [0.34.0](https://github.com/nanobpm/nano-workforce/compare/v0.33.1...v0.34.0) (2026-08-09)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -261,13 +261,21 @@ child grids, a lazily-loaded transcript, and a conditional **answer** form shown
|
|
|
261
261
|
when the PR has an open escalation (`open_escalation_id`, denormalised onto the
|
|
262
262
|
row by migration `003`).
|
|
263
263
|
|
|
264
|
-
|
|
265
|
-
|
|
264
|
+
The app-specific business-logic endpoints are **OpenAPI operations** mounted
|
|
265
|
+
under `api.base` (`/app/api`), each implemented by a delegate module in
|
|
266
|
+
`operations/`, plus a `/hooks/*` webhook. The runtime serves them all; `main.ts`
|
|
267
|
+
only starts the runtime and the review-ready poller. The full, authoritative
|
|
268
|
+
contract is `openapi.json` (Swagger UI at `/app/api-docs`); the OpenAPI rows
|
|
269
|
+
below are the complete set of operations, `/hooks/submit` is one of the `/hooks/*`
|
|
270
|
+
webhooks:
|
|
266
271
|
|
|
267
272
|
| method | route | purpose |
|
|
268
273
|
|---|---|---|
|
|
269
|
-
| `
|
|
270
|
-
| `
|
|
274
|
+
| `GET` | `/app/api/status` | list tracked PRs + count |
|
|
275
|
+
| `GET` | `/app/api/version` | app + engine version |
|
|
276
|
+
| `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process |
|
|
277
|
+
| `POST` | `/app/api/actions/start/plan-fanout` | start a plan fan-out run |
|
|
278
|
+
| `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open escalation → publish `escalation-answered` |
|
|
271
279
|
| `POST` | `/hooks/submit` | webhook submit (shared-secret auth) → start the process |
|
|
272
280
|
|
|
273
281
|
Everything else (`GET /`, `GET /app/pages/*`, `GET /app/data/*`, the renderer) is
|
package/app/plan.ts
CHANGED
|
@@ -267,10 +267,11 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
267
267
|
blackboardBrief: renderCoordinationBrief(bbUrl),
|
|
268
268
|
},
|
|
269
269
|
});
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
271
|
+
if (processKey != null) {
|
|
272
|
+
await table.update(parsed.planKey, { process_key: processKey, updated_at: now() });
|
|
272
273
|
}
|
|
273
|
-
return { planKey: parsed.planKey, processKey
|
|
274
|
+
return { planKey: parsed.planKey, processKey };
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
/** Re-point a plan's denormalised "open task escalation" fields at its OLDEST
|
package/app/service.test.ts
CHANGED
|
@@ -252,3 +252,45 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
|
|
|
252
252
|
assertEquals(row.incident_message, "the first fault");
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
+
|
|
256
|
+
// Red/green regression (nano-workforce#102 review): the engine can hand back a numeric
|
|
257
|
+
// `processInstanceKey`, but the OpenAPI `SubmitResult.processKey` contract is `string | null`, so
|
|
258
|
+
// under api `validateResponses:"dev"` a raw number fails response validation. Stringifying at the
|
|
259
|
+
// source also keeps the returned key aligned with the DB-persisted `String(...)` value and dodges
|
|
260
|
+
// JS 53-bit precision limits for large 64-bit keys (which is why keys travel as strings in
|
|
261
|
+
// practice). `submitPr` must stringify it both in the returned body and the persisted row.
|
|
262
|
+
Deno.test("submitPr stringifies a numeric processInstanceKey (contract: string | null)", async () => {
|
|
263
|
+
await withGithubOff(async () => {
|
|
264
|
+
const PR_KEY = "owner/repo#7";
|
|
265
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
266
|
+
pull_requests: { rows: [], key: "pr_key" },
|
|
267
|
+
escalations: { rows: [], key: "id" },
|
|
268
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
269
|
+
};
|
|
270
|
+
const data = {
|
|
271
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
272
|
+
// deno-lint-ignore no-explicit-any
|
|
273
|
+
} as any;
|
|
274
|
+
const engine = {
|
|
275
|
+
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
276
|
+
// (number vs the `string | null` contract). Kept within MAX_SAFE_INTEGER so the fixture
|
|
277
|
+
// itself is exact; true 64-bit keys travel as strings for the same precision reason.
|
|
278
|
+
createInstance: () => Promise.resolve({ processInstanceKey: 2251799813685249 }),
|
|
279
|
+
// deno-lint-ignore no-explicit-any
|
|
280
|
+
} as any;
|
|
281
|
+
|
|
282
|
+
const res = await submitPr(data, engine, {
|
|
283
|
+
repo: "owner/repo",
|
|
284
|
+
number: 7,
|
|
285
|
+
url: "https://github.com/owner/repo/pull/7",
|
|
286
|
+
prKey: PR_KEY,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// deno-lint-ignore no-explicit-any
|
|
290
|
+
const processKey = (res as any).processKey;
|
|
291
|
+
assertEquals(typeof processKey, "string");
|
|
292
|
+
assertEquals(processKey, "2251799813685249");
|
|
293
|
+
const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
|
|
294
|
+
assertEquals(pr.process_key, "2251799813685249");
|
|
295
|
+
});
|
|
296
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -369,10 +369,11 @@ export async function submitPr(
|
|
|
369
369
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
370
370
|
},
|
|
371
371
|
});
|
|
372
|
-
|
|
373
|
-
|
|
372
|
+
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
373
|
+
if (processKey != null) {
|
|
374
|
+
await table.update(parsed.prKey, { process_key: processKey });
|
|
374
375
|
}
|
|
375
|
-
return { prKey: parsed.prKey, processKey
|
|
376
|
+
return { prKey: parsed.prKey, processKey };
|
|
376
377
|
}
|
|
377
378
|
|
|
378
379
|
/** Start the merge-loop for a converged PR (called by the `pr.finalize` worker when AUTO_MERGE
|
package/app/version.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// debugging a stuck instance can confirm the process is on the code they think it is.
|
|
10
10
|
//
|
|
11
11
|
// Every probe is best-effort: a missing file or unavailable `.git` yields `null` for that field
|
|
12
|
-
// rather than throwing, so `/app/version` never fails just because one source is absent.
|
|
12
|
+
// rather than throwing, so `/app/api/version` never fails just because one source is absent.
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { dirname, join, resolve, isAbsolute } from "node:path";
|
|
@@ -41,7 +41,7 @@ function readJson(path: string): Record<string, unknown> | null {
|
|
|
41
41
|
* Read an env var across runtimes: Node exposes `process.env`; Deno may not populate it, so fall
|
|
42
42
|
* back to `Deno.env.get` (guarded — reading env can throw without `--allow-env`).
|
|
43
43
|
*/
|
|
44
|
-
function envVar(name: string): string | null {
|
|
44
|
+
export function envVar(name: string): string | null {
|
|
45
45
|
const fromProcess = globalThis.process?.env?.[name];
|
|
46
46
|
if (typeof fromProcess === "string" && fromProcess.trim()) return fromProcess.trim();
|
|
47
47
|
const deno = (globalThis as { Deno?: { env?: { get?(k: string): string | undefined } } }).Deno;
|
package/deno.json
CHANGED
package/deno.lock
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
"specifiers": {
|
|
4
4
|
"jsr:@std/assert@1": "1.0.19",
|
|
5
5
|
"jsr:@std/internal@^1.0.12": "1.0.14",
|
|
6
|
-
"npm:@nanobpm/urban@0.31": "0.31.0",
|
|
7
6
|
"npm:@semantic-release/changelog@^6.0.3": "6.0.3_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
|
|
8
7
|
"npm:@semantic-release/git@^10.0.1": "10.0.1_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
|
|
9
8
|
"npm:@semantic-release/npm@^13.1.5": "13.1.5_semantic-release@24.2.9__typescript@5.9.3",
|
|
@@ -1757,11 +1756,11 @@
|
|
|
1757
1756
|
},
|
|
1758
1757
|
"workspace": {
|
|
1759
1758
|
"dependencies": [
|
|
1760
|
-
"npm:@nanobpm/urban@0.
|
|
1759
|
+
"npm:@nanobpm/urban@0.32"
|
|
1761
1760
|
],
|
|
1762
1761
|
"packageJson": {
|
|
1763
1762
|
"dependencies": [
|
|
1764
|
-
"npm:@nanobpm/urban@0.
|
|
1763
|
+
"npm:@nanobpm/urban@0.32",
|
|
1765
1764
|
"npm:@semantic-release/changelog@^6.0.3",
|
|
1766
1765
|
"npm:@semantic-release/git@^10.0.1",
|
|
1767
1766
|
"npm:@semantic-release/npm@^13.1.5",
|
package/nano.app.json
CHANGED
|
@@ -139,29 +139,13 @@
|
|
|
139
139
|
"label": "Nano Workforce",
|
|
140
140
|
"icon": "assets/icon.svg"
|
|
141
141
|
},
|
|
142
|
+
"api": {
|
|
143
|
+
"spec": "openapi.json",
|
|
144
|
+
"base": "/app/api",
|
|
145
|
+
"dir": "operations",
|
|
146
|
+
"validateResponses": "dev"
|
|
147
|
+
},
|
|
142
148
|
"actions": [
|
|
143
|
-
{
|
|
144
|
-
"path": "/app/actions/start/convergence-loop",
|
|
145
|
-
"module": "actions/start.ts"
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
"path": "/app/actions/start/plan-fanout",
|
|
149
|
-
"module": "actions/plan-start.ts"
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
"path": "/app/status",
|
|
153
|
-
"module": "actions/status.ts",
|
|
154
|
-
"method": "GET"
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
"path": "/app/version",
|
|
158
|
-
"module": "actions/version.ts",
|
|
159
|
-
"method": "GET"
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
"path": "/app/actions/message",
|
|
163
|
-
"module": "actions/message.ts"
|
|
164
|
-
},
|
|
165
149
|
{
|
|
166
150
|
"path": "/hooks/submit",
|
|
167
151
|
"module": "actions/webhook-submit.ts"
|
package/openapi.json
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.0.3",
|
|
3
|
+
"info": {
|
|
4
|
+
"title": "Nano Workforce control API",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "The externally-facing control surface an operator, an automation harness, or an LLM uses to observe and steer PR-convergence and planning runs. Contract-first (ADR 0058): the toolkit derives typed request/response contracts + runtime validators from this document and each `operationId` is implemented by a delegate module in `operations/`. Mounted under base `/app/api`, kept off the framework-reserved `/app` page-runtime namespace (which owns `/app/runtime.js`, `/app/pages/*`, `/app/data/*`). The `/hooks/*` webhook endpoints stay on `actions[]` because they live outside the `/app` namespace."
|
|
7
|
+
},
|
|
8
|
+
"components": {
|
|
9
|
+
"securitySchemes": {
|
|
10
|
+
"hookSecret": {
|
|
11
|
+
"type": "apiKey",
|
|
12
|
+
"in": "header",
|
|
13
|
+
"name": "x-hook-secret",
|
|
14
|
+
"description": "Optional shared secret. Enforced by the delegate (NOT the runtime) only when NANO_PR_WEBHOOK_SECRET is set; unset means the endpoint is open. Declared here for documentation."
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"schemas": {
|
|
18
|
+
"ErrorBody": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["error"],
|
|
21
|
+
"properties": { "error": { "type": "string" } }
|
|
22
|
+
},
|
|
23
|
+
"ActivePr": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"description": "A tracked PR that is not in a terminal (converged/abandoned) state.",
|
|
26
|
+
"required": [
|
|
27
|
+
"prKey", "repo", "number", "url", "title", "status", "round",
|
|
28
|
+
"processKey", "waitingSince", "openEscalation", "updatedAt",
|
|
29
|
+
"activeWorker", "leaseUntil"
|
|
30
|
+
],
|
|
31
|
+
"properties": {
|
|
32
|
+
"prKey": { "type": "string" },
|
|
33
|
+
"repo": { "type": "string" },
|
|
34
|
+
"number": { "type": "integer" },
|
|
35
|
+
"url": { "type": "string" },
|
|
36
|
+
"title": { "type": ["string", "null"] },
|
|
37
|
+
"status": { "type": "string" },
|
|
38
|
+
"round": { "type": "integer" },
|
|
39
|
+
"processKey": { "type": ["string", "null"], "description": "The engine process instance key; also the keyField the pages processExplorer link uses." },
|
|
40
|
+
"waitingSince": { "type": ["string", "null"] },
|
|
41
|
+
"openEscalation": { "type": ["string", "null"] },
|
|
42
|
+
"updatedAt": { "type": "string" },
|
|
43
|
+
"activeWorker": { "type": ["string", "null"] },
|
|
44
|
+
"leaseUntil": { "type": ["string", "null"] }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"ActivePrList": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": ["count", "prs"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"count": { "type": "integer" },
|
|
52
|
+
"prs": { "type": "array", "items": { "$ref": "#/components/schemas/ActivePr" } }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"VersionInfo": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"description": "The running app's identity (which code is actually live).",
|
|
58
|
+
"required": [
|
|
59
|
+
"name", "version", "urbanVersion", "gitSha", "gitBranch",
|
|
60
|
+
"runtime", "pid", "startedAt", "uptimeSeconds"
|
|
61
|
+
],
|
|
62
|
+
"properties": {
|
|
63
|
+
"name": { "type": "string" },
|
|
64
|
+
"version": { "type": ["string", "null"] },
|
|
65
|
+
"urbanVersion": { "type": ["string", "null"] },
|
|
66
|
+
"gitSha": { "type": ["string", "null"] },
|
|
67
|
+
"gitBranch": { "type": ["string", "null"] },
|
|
68
|
+
"runtime": { "type": "string" },
|
|
69
|
+
"pid": { "type": ["integer", "null"] },
|
|
70
|
+
"startedAt": { "type": "string" },
|
|
71
|
+
"uptimeSeconds": { "type": "integer" }
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"SubmitResult": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"required": ["prKey"],
|
|
77
|
+
"properties": {
|
|
78
|
+
"prKey": { "type": "string" },
|
|
79
|
+
"processKey": { "type": ["string", "null"], "description": "The started convergence-loop instance key (null if the engine did not return one)." },
|
|
80
|
+
"alreadyRunning": { "type": "boolean", "description": "True when a non-terminal convergence loop for this PR already exists; the aggregate was refreshed and no new instance was started." }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"StartPlanResult": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"required": ["planKey"],
|
|
86
|
+
"properties": {
|
|
87
|
+
"planKey": { "type": "string" },
|
|
88
|
+
"processKey": { "type": ["string", "null"] },
|
|
89
|
+
"alreadyRunning": { "type": "boolean", "description": "True when a non-terminal plan for this issue already exists; no new instance was started." }
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"StartVariables": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"description": "Process-start variables. `pr`/`url` (convergence) or `issue`/`url` (planning) name the target; extra keys are forwarded to the engine.",
|
|
95
|
+
"additionalProperties": true,
|
|
96
|
+
"properties": {
|
|
97
|
+
"pr": { "type": "string", "description": "PR reference: owner/repo#123 or a PR URL." },
|
|
98
|
+
"issue": { "type": "string", "description": "Issue reference: owner/repo#123 or an issue URL." },
|
|
99
|
+
"url": { "type": "string", "description": "Alias for pr/issue when a bare URL is supplied." },
|
|
100
|
+
"dependsOn": { "type": "array", "items": { "type": "string" } },
|
|
101
|
+
"maxRounds": { "type": "integer", "minimum": 1 }
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"MessageResult": {
|
|
105
|
+
"type": "object",
|
|
106
|
+
"description": "The result of publishing a message / answering an escalation. Shape varies by message name; `ok` is always present.",
|
|
107
|
+
"additionalProperties": true,
|
|
108
|
+
"required": ["ok"],
|
|
109
|
+
"properties": { "ok": { "type": "boolean" } }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"paths": {
|
|
114
|
+
"/status": {
|
|
115
|
+
"get": {
|
|
116
|
+
"operationId": "listActivePrs",
|
|
117
|
+
"summary": "List every tracked PR currently in flight (not converged/abandoned), newest-updated first.",
|
|
118
|
+
"security": [{ "hookSecret": [] }, {}],
|
|
119
|
+
"responses": {
|
|
120
|
+
"200": {
|
|
121
|
+
"description": "The active PRs.",
|
|
122
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivePrList" } } }
|
|
123
|
+
},
|
|
124
|
+
"401": {
|
|
125
|
+
"description": "Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).",
|
|
126
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"/version": {
|
|
132
|
+
"get": {
|
|
133
|
+
"operationId": "getVersion",
|
|
134
|
+
"summary": "The running app's identity (app/urban versions, git sha/branch, runtime, pid, uptime).",
|
|
135
|
+
"security": [{ "hookSecret": [] }, {}],
|
|
136
|
+
"responses": {
|
|
137
|
+
"200": {
|
|
138
|
+
"description": "The version/identity payload.",
|
|
139
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/VersionInfo" } } }
|
|
140
|
+
},
|
|
141
|
+
"401": {
|
|
142
|
+
"description": "Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).",
|
|
143
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"/actions/start/convergence-loop": {
|
|
149
|
+
"post": {
|
|
150
|
+
"operationId": "startConvergenceLoop",
|
|
151
|
+
"summary": "Register/refresh a PR aggregate (idempotent on prKey) and start its convergence loop.",
|
|
152
|
+
"requestBody": {
|
|
153
|
+
"required": true,
|
|
154
|
+
"content": {
|
|
155
|
+
"application/json": {
|
|
156
|
+
"schema": {
|
|
157
|
+
"type": "object",
|
|
158
|
+
"additionalProperties": true,
|
|
159
|
+
"required": ["variables"],
|
|
160
|
+
"properties": { "variables": { "$ref": "#/components/schemas/StartVariables" } }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"responses": {
|
|
166
|
+
"202": {
|
|
167
|
+
"description": "The loop was started (or refreshed).",
|
|
168
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitResult" } } }
|
|
169
|
+
},
|
|
170
|
+
"400": {
|
|
171
|
+
"description": "The PR reference could not be parsed.",
|
|
172
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"/actions/start/plan-fanout": {
|
|
178
|
+
"post": {
|
|
179
|
+
"operationId": "startPlanFanout",
|
|
180
|
+
"summary": "Register/refresh a plan aggregate (idempotent on planKey) and start the planning fan-out.",
|
|
181
|
+
"requestBody": {
|
|
182
|
+
"required": true,
|
|
183
|
+
"content": {
|
|
184
|
+
"application/json": {
|
|
185
|
+
"schema": {
|
|
186
|
+
"type": "object",
|
|
187
|
+
"additionalProperties": true,
|
|
188
|
+
"required": ["variables"],
|
|
189
|
+
"properties": { "variables": { "$ref": "#/components/schemas/StartVariables" } }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
"responses": {
|
|
195
|
+
"202": {
|
|
196
|
+
"description": "The plan fan-out was started (or was already running).",
|
|
197
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/StartPlanResult" } } }
|
|
198
|
+
},
|
|
199
|
+
"400": {
|
|
200
|
+
"description": "The issue reference could not be parsed.",
|
|
201
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
"/actions/message": {
|
|
207
|
+
"post": {
|
|
208
|
+
"operationId": "postMessage",
|
|
209
|
+
"summary": "Publish a message / answer an escalation. For escalation-answered and feature-escalation-answered names, runs the corresponding answer flow; otherwise a plain publishMessage.",
|
|
210
|
+
"requestBody": {
|
|
211
|
+
"required": true,
|
|
212
|
+
"content": {
|
|
213
|
+
"application/json": {
|
|
214
|
+
"schema": {
|
|
215
|
+
"type": "object",
|
|
216
|
+
"additionalProperties": true,
|
|
217
|
+
"required": ["name"],
|
|
218
|
+
"properties": {
|
|
219
|
+
"name": { "type": "string", "description": "The message name (correlates a waiting event)." },
|
|
220
|
+
"correlationKey": { "type": "string" },
|
|
221
|
+
"variables": {
|
|
222
|
+
"type": "object",
|
|
223
|
+
"additionalProperties": true,
|
|
224
|
+
"properties": { "answer": { "type": "string" } }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
"responses": {
|
|
232
|
+
"200": {
|
|
233
|
+
"description": "The message was published (or the escalation answered).",
|
|
234
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/MessageResult" } } }
|
|
235
|
+
},
|
|
236
|
+
"400": {
|
|
237
|
+
"description": "A required field was missing/blank.",
|
|
238
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorBody" } } }
|
|
239
|
+
},
|
|
240
|
+
"404": {
|
|
241
|
+
"description": "No matching open escalation / parked token to answer.",
|
|
242
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/MessageResult" } } }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Tests for GET /app/api/version → operation `getVersion` (ADR 0058 OpenAPI surface).
|
|
2
|
+
// Ported from the previous actions/version.test.ts. Method handling now belongs to the router
|
|
3
|
+
// (only GET is routed here), so there is no 405 case to test at the delegate level.
|
|
4
|
+
import { assert, assertEquals } from "jsr:@std/assert@1";
|
|
5
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
6
|
+
import handler from "./getVersion.ts";
|
|
7
|
+
|
|
8
|
+
// deno-lint-ignore no-explicit-any
|
|
9
|
+
const app = {} as any as AppApi;
|
|
10
|
+
|
|
11
|
+
function input(headers: Record<string, string> = {}) {
|
|
12
|
+
return {
|
|
13
|
+
req: {
|
|
14
|
+
method: "GET",
|
|
15
|
+
path: "/app/api/version",
|
|
16
|
+
query: new URLSearchParams(),
|
|
17
|
+
headers: new Headers(headers),
|
|
18
|
+
text: async () => "",
|
|
19
|
+
// deno-lint-ignore no-explicit-any
|
|
20
|
+
} as any,
|
|
21
|
+
params: {},
|
|
22
|
+
query: {},
|
|
23
|
+
body: undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Deno.test("returns 200 with the app identity", async () => {
|
|
28
|
+
const res = await handler(input(), app);
|
|
29
|
+
// deno-lint-ignore no-explicit-any
|
|
30
|
+
const r = res as any;
|
|
31
|
+
assertEquals(r.status, 200);
|
|
32
|
+
assertEquals(r.body.name, "nano-workforce");
|
|
33
|
+
// These are always present; their values are environment-dependent so we only assert shape.
|
|
34
|
+
assert("version" in r.body);
|
|
35
|
+
assert("urbanVersion" in r.body);
|
|
36
|
+
assert("gitSha" in r.body);
|
|
37
|
+
assert("gitBranch" in r.body);
|
|
38
|
+
assert(typeof r.body.runtime === "string" && r.body.runtime.length > 0);
|
|
39
|
+
assert(typeof r.body.startedAt === "string");
|
|
40
|
+
assert(typeof r.body.uptimeSeconds === "number");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
Deno.test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
44
|
+
const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
|
|
45
|
+
Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
|
|
46
|
+
try {
|
|
47
|
+
// SECRET is bound at import time, so import a cache-busted copy to observe the guard.
|
|
48
|
+
const mod = await import(`./getVersion.ts?guard=${Date.now()}`);
|
|
49
|
+
const guarded = mod.default as typeof handler;
|
|
50
|
+
// deno-lint-ignore no-explicit-any
|
|
51
|
+
const bad = (await guarded(input(), app)) as any;
|
|
52
|
+
assertEquals(bad.status, 401);
|
|
53
|
+
// deno-lint-ignore no-explicit-any
|
|
54
|
+
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
55
|
+
assertEquals(ok.status, 200);
|
|
56
|
+
} finally {
|
|
57
|
+
if (prev === undefined) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
|
|
58
|
+
else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// GET /app/api/version → operationId `getVersion` (ADR 0058 OpenAPI surface, mounted under base /app/api).
|
|
2
|
+
// The running app's identity (app + resolved @nanobpm/urban versions, git commit/branch, JS runtime,
|
|
3
|
+
// pid, uptime). Because the app runs its `.ts` sources directly from a checkout with no build step,
|
|
4
|
+
// restarts alone don't tell you whether a fix is live; this endpoint does.
|
|
5
|
+
//
|
|
6
|
+
// Read-only and unauthenticated by design (no secrets in the payload). The optional shared-secret
|
|
7
|
+
// guard stays HERE (the runtime does not enforce OpenAPI `security`); the OpenAPI document only
|
|
8
|
+
// routes GET to this operation, so a wrong method is a 404 from the router (no explicit 405 needed).
|
|
9
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
10
|
+
import { buildVersionInfo, envVar, type VersionInfo } from "../app/version.ts";
|
|
11
|
+
|
|
12
|
+
// Cross-runtime env read (Node `process.env` OR Deno `Deno.env`): reading via `process.env` alone
|
|
13
|
+
// would silently disable the guard under Deno/compiled runtimes where `process` is absent, leaving
|
|
14
|
+
// the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
|
|
15
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
16
|
+
|
|
17
|
+
export default defineOperation<
|
|
18
|
+
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: undefined },
|
|
19
|
+
VersionInfo | { error: string }
|
|
20
|
+
>("getVersion", ({ req }) => {
|
|
21
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
22
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
23
|
+
}
|
|
24
|
+
return { status: 200, body: buildVersionInfo() };
|
|
25
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Tests for GET /app/api/status → operation `listActivePrs` (ADR 0058 OpenAPI surface).
|
|
2
|
+
// Covers the happy path (count/prs projection) and the optional shared-secret guard. A minimal
|
|
3
|
+
// in-memory DataLayer backs `activePrs` (it reads the `pull_requests` table via `.all()`).
|
|
4
|
+
import { assert, assertEquals } from "jsr:@std/assert@1";
|
|
5
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
6
|
+
import handler from "./listActivePrs.ts";
|
|
7
|
+
|
|
8
|
+
// deno-lint-ignore no-explicit-any
|
|
9
|
+
function memApp(rows: any[]): AppApi {
|
|
10
|
+
const tbl = {
|
|
11
|
+
// deno-lint-ignore require-await
|
|
12
|
+
async all() {
|
|
13
|
+
return rows;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
// deno-lint-ignore no-explicit-any
|
|
17
|
+
return { data: { table: () => tbl } } as any as AppApi;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function input(headers: Record<string, string> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
req: {
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/app/api/status",
|
|
25
|
+
query: new URLSearchParams(),
|
|
26
|
+
headers: new Headers(headers),
|
|
27
|
+
text: async () => "",
|
|
28
|
+
// deno-lint-ignore no-explicit-any
|
|
29
|
+
} as any,
|
|
30
|
+
params: {},
|
|
31
|
+
query: {},
|
|
32
|
+
body: undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Deno.test("returns 200 with a count + projected active PRs", async () => {
|
|
37
|
+
const app = memApp([
|
|
38
|
+
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", title: "t", status: "converging", current_round: 2, process_key: "9", updated_at: "2026-01-02" },
|
|
39
|
+
{ pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", title: null, status: "converged", current_round: 1, process_key: null, updated_at: "2026-01-01" },
|
|
40
|
+
]);
|
|
41
|
+
const res = await handler(input(), app);
|
|
42
|
+
// deno-lint-ignore no-explicit-any
|
|
43
|
+
const r = res as any;
|
|
44
|
+
assertEquals(r.status, 200);
|
|
45
|
+
// `converged` is terminal → filtered out, leaving one active PR.
|
|
46
|
+
assertEquals(r.body.count, 1);
|
|
47
|
+
assertEquals(r.body.prs.length, 1);
|
|
48
|
+
assertEquals(r.body.prs[0].prKey, "o/r#1");
|
|
49
|
+
assertEquals(r.body.prs[0].processKey, "9");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
Deno.test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
53
|
+
const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
|
|
54
|
+
Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
|
|
55
|
+
try {
|
|
56
|
+
const mod = await import(`./listActivePrs.ts?guard=${Date.now()}`);
|
|
57
|
+
const guarded = mod.default as typeof handler;
|
|
58
|
+
const app = memApp([]);
|
|
59
|
+
// deno-lint-ignore no-explicit-any
|
|
60
|
+
const bad = (await guarded(input(), app)) as any;
|
|
61
|
+
assertEquals(bad.status, 401);
|
|
62
|
+
// deno-lint-ignore no-explicit-any
|
|
63
|
+
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
64
|
+
assertEquals(ok.status, 200);
|
|
65
|
+
assert("count" in ok.body);
|
|
66
|
+
} finally {
|
|
67
|
+
if (prev === undefined) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
|
|
68
|
+
else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// GET /app/api/status → operationId `listActivePrs` (ADR 0058 OpenAPI surface, mounted under base /app/api).
|
|
2
|
+
// List the PRs currently in flight (every tracked PR not converged/abandoned) so an operator or an
|
|
3
|
+
// external automation harness can see active work — and grab a `processKey` to cancel — without
|
|
4
|
+
// opening the DB or the UI. Read-only projection over the datasource.
|
|
5
|
+
//
|
|
6
|
+
// The runtime validates the (empty) request against openapi.json; the optional shared-secret guard
|
|
7
|
+
// stays HERE (the runtime does not enforce OpenAPI `security`): when NANO_PR_WEBHOOK_SECRET is set,
|
|
8
|
+
// callers must present it via the x-hook-secret header. Unset → open (unchanged default).
|
|
9
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
10
|
+
import { type ActivePr, activePrs } from "../app/service.ts";
|
|
11
|
+
import { envVar } from "../app/version.ts";
|
|
12
|
+
|
|
13
|
+
// Cross-runtime env read (Node `process.env` OR Deno `Deno.env`): reading via `process.env` alone
|
|
14
|
+
// would silently disable the guard under Deno/compiled runtimes where `process` is absent, leaving
|
|
15
|
+
// the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
|
|
16
|
+
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
17
|
+
|
|
18
|
+
type Res = { count: number; prs: ActivePr[] } | { error: string };
|
|
19
|
+
|
|
20
|
+
export default defineOperation<
|
|
21
|
+
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: undefined },
|
|
22
|
+
Res
|
|
23
|
+
>("listActivePrs", async ({ req }, app) => {
|
|
24
|
+
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
25
|
+
return { status: 401, body: { error: "unauthorized" } };
|
|
26
|
+
}
|
|
27
|
+
const prs = await activePrs(app.data);
|
|
28
|
+
return { status: 200, body: { count: prs.length, prs } };
|
|
29
|
+
});
|
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
// POST /app/actions/message
|
|
2
|
-
//
|
|
3
|
-
// `
|
|
4
|
-
// (issue #25): record the answer, resume the parked
|
|
5
|
-
// next open escalation. Any other message falls back to a plain
|
|
6
|
-
//
|
|
7
|
-
|
|
1
|
+
// POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
|
|
2
|
+
// Replaces the hand-rolled action that overrode the generic publishMessage action. For the
|
|
3
|
+
// `escalation-answered` message we run the review answer flow, and for `feature-escalation-answered`
|
|
4
|
+
// (issue #25) the implementation-phase (per-task) answer flow: record the answer, resume the parked
|
|
5
|
+
// token, then re-surface the next open escalation. Any other message falls back to a plain
|
|
6
|
+
// publishMessage.
|
|
7
|
+
//
|
|
8
|
+
// The runtime validates the body against openapi.json (`name` is required, so a missing name is a 400
|
|
9
|
+
// for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
|
|
10
|
+
// is app logic, not something the JSON schema can express.
|
|
11
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
8
12
|
import { answerEscalation } from "../app/service.ts";
|
|
9
13
|
import { answerTaskEscalation, FEATURE_ESCALATION_MESSAGE } from "../app/plan.ts";
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
interface Body {
|
|
16
|
+
name?: unknown;
|
|
17
|
+
correlationKey?: unknown;
|
|
18
|
+
variables?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default defineOperation<
|
|
22
|
+
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
|
|
23
|
+
Record<string, unknown>
|
|
24
|
+
>("postMessage", async ({ body }, app) => {
|
|
25
|
+
const b = body ?? {};
|
|
17
26
|
const name = String(b.name ?? "");
|
|
18
27
|
if (!name) return { status: 400, body: { error: "name is required" } };
|
|
19
28
|
|
|
@@ -27,9 +36,9 @@ const handler: ActionHandler = async ({ body }, app) => {
|
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
if (name === FEATURE_ESCALATION_MESSAGE) {
|
|
30
|
-
// Implementation-phase task escalation (issue #25): correlationKey is the
|
|
31
|
-
//
|
|
32
|
-
//
|
|
39
|
+
// Implementation-phase task escalation (issue #25): correlationKey is the task's
|
|
40
|
+
// `<plan_key>:<task_id>`; record the answer, resume the parked child, and re-surface the next
|
|
41
|
+
// open escalation.
|
|
33
42
|
const corrKey = String(b.correlationKey ?? "");
|
|
34
43
|
const answer = String((b.variables?.answer ?? "") as string).trim();
|
|
35
44
|
if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
@@ -44,6 +53,4 @@ const handler: ActionHandler = async ({ body }, app) => {
|
|
|
44
53
|
variables: b.variables,
|
|
45
54
|
});
|
|
46
55
|
return { status: 200, body: { ok: true } };
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
export default handler;
|
|
56
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Tests for the start/message operation delegates (ADR 0058 OpenAPI surface).
|
|
2
|
+
// These cover the app-logic guards the JSON schema can't express (reference parsing, message-name
|
|
3
|
+
// dispatch); the runtime's schema validation (required `variables`/`name`) is exercised by urban's
|
|
4
|
+
// own api runtime tests.
|
|
5
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
6
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
7
|
+
import startConvergenceLoop from "./startConvergenceLoop.ts";
|
|
8
|
+
import startPlanFanout from "./startPlanFanout.ts";
|
|
9
|
+
import postMessage from "./postMessage.ts";
|
|
10
|
+
|
|
11
|
+
// deno-lint-ignore no-explicit-any
|
|
12
|
+
const app = {} as any as AppApi;
|
|
13
|
+
|
|
14
|
+
// deno-lint-ignore no-explicit-any
|
|
15
|
+
function input(body: any) {
|
|
16
|
+
return {
|
|
17
|
+
// deno-lint-ignore no-explicit-any
|
|
18
|
+
req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
|
|
19
|
+
params: {},
|
|
20
|
+
query: {},
|
|
21
|
+
body,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Deno.test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
|
|
26
|
+
const res = await startConvergenceLoop(input({ variables: { pr: "not a pr" } }), app);
|
|
27
|
+
// deno-lint-ignore no-explicit-any
|
|
28
|
+
const r = res as any;
|
|
29
|
+
assertEquals(r.status, 400);
|
|
30
|
+
assertEquals(typeof r.body.error, "string");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
Deno.test("startPlanFanout → 400 on an unparseable issue reference", async () => {
|
|
34
|
+
const res = await startPlanFanout(input({ variables: { issue: "" } }), app);
|
|
35
|
+
// deno-lint-ignore no-explicit-any
|
|
36
|
+
const r = res as any;
|
|
37
|
+
assertEquals(r.status, 400);
|
|
38
|
+
assertEquals(typeof r.body.error, "string");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Deno.test("postMessage → 400 when name is blank", async () => {
|
|
42
|
+
const res = await postMessage(input({ name: "" }), app);
|
|
43
|
+
// deno-lint-ignore no-explicit-any
|
|
44
|
+
const r = res as any;
|
|
45
|
+
assertEquals(r.status, 400);
|
|
46
|
+
assertEquals(r.body.error, "name is required");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
Deno.test("postMessage → 400 when escalation-answered lacks a correlationKey", async () => {
|
|
50
|
+
const res = await postMessage(input({ name: "escalation-answered", variables: { answer: "yes" } }), app);
|
|
51
|
+
// deno-lint-ignore no-explicit-any
|
|
52
|
+
const r = res as any;
|
|
53
|
+
assertEquals(r.status, 400);
|
|
54
|
+
assertEquals(r.body.error, "correlationKey is required");
|
|
55
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// POST /app/api/actions/start/convergence-loop → operationId `startConvergenceLoop` (ADR 0058, base /app/api).
|
|
2
|
+
// Replaces the hand-rolled action that overrode the generic "start process" palette action: parse the
|
|
3
|
+
// PR reference and register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
|
|
4
|
+
//
|
|
5
|
+
// The runtime validates the body against openapi.json (a `variables` object is required); this
|
|
6
|
+
// delegate keeps the PR-parse guard because the reference format (owner/repo#123 or a URL) is app
|
|
7
|
+
// logic, not something the JSON schema can express — an unparseable reference is a 400.
|
|
8
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
9
|
+
import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
|
|
10
|
+
|
|
11
|
+
interface Body {
|
|
12
|
+
variables?: { pr?: string; url?: string; dependsOn?: unknown; maxRounds?: unknown };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default defineOperation<
|
|
16
|
+
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
|
|
17
|
+
{ prKey: string; alreadyRunning?: boolean; processKey?: string | null } | { error: string }
|
|
18
|
+
>("startConvergenceLoop", async ({ body }, app) => {
|
|
19
|
+
const vars = body?.variables ?? {};
|
|
20
|
+
const raw = String(vars.pr ?? vars.url ?? "").trim();
|
|
21
|
+
const parsed = parsePr(raw);
|
|
22
|
+
if (!parsed) {
|
|
23
|
+
return { status: 400, body: { error: "could not parse PR (use owner/repo#123 or a PR URL)" } };
|
|
24
|
+
}
|
|
25
|
+
const dependsOn = Array.isArray(vars.dependsOn) ? vars.dependsOn.map((d) => String(d)) : [];
|
|
26
|
+
const maxRounds = clampRounds(vars.maxRounds, MAX_ROUNDS);
|
|
27
|
+
return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
|
|
28
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// POST /app/api/actions/start/plan-fanout → operationId `startPlanFanout` (ADR 0058, base /app/api).
|
|
2
|
+
// Replaces the hand-rolled action that overrode the generic "start process" palette action: parse the
|
|
3
|
+
// issue reference and register/refresh the plan aggregate (idempotent on planKey) before starting the
|
|
4
|
+
// planning fan-out. An unparseable reference is a 400; an already-running plan short-circuits.
|
|
5
|
+
import { defineOperation } from "@nanobpm/urban";
|
|
6
|
+
import { parseIssue, startPlan } from "../app/plan.ts";
|
|
7
|
+
|
|
8
|
+
interface Body {
|
|
9
|
+
variables?: { issue?: string; url?: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Res =
|
|
13
|
+
| { planKey: string; alreadyRunning?: boolean; processKey?: string | null }
|
|
14
|
+
| { error: string };
|
|
15
|
+
|
|
16
|
+
export default defineOperation<
|
|
17
|
+
{ params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
|
|
18
|
+
Res
|
|
19
|
+
>("startPlanFanout", async ({ body }, app) => {
|
|
20
|
+
const vars = body?.variables ?? {};
|
|
21
|
+
const raw = String(vars.issue ?? vars.url ?? "").trim();
|
|
22
|
+
const parsed = parseIssue(raw);
|
|
23
|
+
if (!parsed) {
|
|
24
|
+
return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
|
|
25
|
+
}
|
|
26
|
+
return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
|
|
27
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"test": "deno test -A"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@nanobpm/urban": "^0.
|
|
43
|
+
"@nanobpm/urban": "^0.32.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@semantic-release/changelog": "^6.0.3",
|
package/actions/plan-start.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
// POST /app/actions/start/plan-fanout — override the generic "start process" action for the
|
|
2
|
-
// planning fan-out. We parse the issue reference and register/refresh the plan aggregate
|
|
3
|
-
// (idempotent on planKey) before starting the process.
|
|
4
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
5
|
-
import { parseIssue, startPlan } from "../app/plan.ts";
|
|
6
|
-
|
|
7
|
-
const handler: ActionHandler = async ({ body }, app) => {
|
|
8
|
-
const vars = ((body as { variables?: Record<string, unknown> })?.variables ?? {}) as Record<string, unknown>;
|
|
9
|
-
const raw = String((vars.issue ?? vars.url ?? "") as string).trim();
|
|
10
|
-
const parsed = parseIssue(raw);
|
|
11
|
-
if (!parsed) {
|
|
12
|
-
return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
|
|
13
|
-
}
|
|
14
|
-
return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export default handler;
|
package/actions/start.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
// POST /app/actions/start/convergence-loop — override the generic "start process" action.
|
|
2
|
-
// The generic runtime would just createInstance; we first parse the PR reference and
|
|
3
|
-
// register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
|
|
4
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
5
|
-
import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
|
|
6
|
-
|
|
7
|
-
const handler: ActionHandler = async ({ body }, app) => {
|
|
8
|
-
const vars = ((body as { variables?: Record<string, unknown> })?.variables ?? {}) as Record<string, unknown>;
|
|
9
|
-
const raw = String((vars.pr ?? vars.url ?? "") as string).trim();
|
|
10
|
-
const parsed = parsePr(raw);
|
|
11
|
-
if (!parsed) {
|
|
12
|
-
return { status: 400, body: { error: "could not parse PR (use owner/repo#123 or a PR URL)" } };
|
|
13
|
-
}
|
|
14
|
-
const dependsOn = Array.isArray(vars.dependsOn) ? vars.dependsOn.map((d) => String(d)) : [];
|
|
15
|
-
const maxRounds = clampRounds(vars.maxRounds, MAX_ROUNDS);
|
|
16
|
-
return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export default handler;
|
package/actions/status.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// GET /app/status — list the PRs currently in flight (every tracked PR not converged/abandoned).
|
|
2
|
-
// A read-only projection over the app datasource so an operator or an external automation
|
|
3
|
-
// harness can see active work — and grab a `processKey` to cancel — without opening the DB or the UI.
|
|
4
|
-
//
|
|
5
|
-
// Optional shared-secret guard, mirroring /hooks/submit: when NANO_PR_WEBHOOK_SECRET is set,
|
|
6
|
-
// callers must present it via the x-hook-secret header. Unset → open (unchanged default). The
|
|
7
|
-
// pages UI does not call this endpoint (its grid reads the datasource directly), so the guard
|
|
8
|
-
// never affects the UI.
|
|
9
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
10
|
-
import { activePrs } from "../app/service.ts";
|
|
11
|
-
|
|
12
|
-
const SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
|
|
13
|
-
|
|
14
|
-
const handler: ActionHandler = async ({ req }, app) => {
|
|
15
|
-
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
16
|
-
return { status: 401, body: { error: "unauthorized" } };
|
|
17
|
-
}
|
|
18
|
-
const prs = await activePrs(app.data);
|
|
19
|
-
return { status: 200, body: { count: prs.length, prs } };
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export default handler;
|
package/actions/version.test.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// Tests for GET /app/version (version/identity endpoint).
|
|
2
|
-
import { assert, assertEquals } from "jsr:@std/assert@1";
|
|
3
|
-
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
-
import handler from "./version.ts";
|
|
5
|
-
import { buildVersionInfo } from "../app/version.ts";
|
|
6
|
-
|
|
7
|
-
// deno-lint-ignore no-explicit-any
|
|
8
|
-
const app = {} as any as AppApi;
|
|
9
|
-
|
|
10
|
-
function req(method: string, headers: Record<string, string> = {}) {
|
|
11
|
-
return {
|
|
12
|
-
method,
|
|
13
|
-
path: "/app/version",
|
|
14
|
-
query: new URLSearchParams(),
|
|
15
|
-
headers: new Headers(headers),
|
|
16
|
-
text: async () => "",
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function call(method: string, headers: Record<string, string> = {}) {
|
|
21
|
-
// deno-lint-ignore no-explicit-any
|
|
22
|
-
const res = await handler({ req: req(method, headers) as any, body: undefined }, app);
|
|
23
|
-
// deno-lint-ignore no-explicit-any
|
|
24
|
-
return res as any;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
Deno.test("GET returns 200 with the app identity", async () => {
|
|
28
|
-
const res = await call("GET");
|
|
29
|
-
assertEquals(res.status, 200);
|
|
30
|
-
assertEquals(res.body.name, "nano-workforce");
|
|
31
|
-
// These are always present; their values are environment-dependent so we only assert shape.
|
|
32
|
-
assert("version" in res.body);
|
|
33
|
-
assert("urbanVersion" in res.body);
|
|
34
|
-
assert("gitSha" in res.body);
|
|
35
|
-
assert("gitBranch" in res.body);
|
|
36
|
-
assert(typeof res.body.runtime === "string" && res.body.runtime.length > 0);
|
|
37
|
-
assert(typeof res.body.startedAt === "string");
|
|
38
|
-
assert(typeof res.body.uptimeSeconds === "number");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
Deno.test("non-GET is rejected with 405", async () => {
|
|
42
|
-
const res = await call("POST");
|
|
43
|
-
assertEquals(res.status, 405);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
Deno.test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
47
|
-
const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
|
|
48
|
-
Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
|
|
49
|
-
try {
|
|
50
|
-
// The handler binds SECRET at import time, so a freshly-imported module is needed to observe
|
|
51
|
-
// the guard. Import a cache-busted copy so this test is independent of import order.
|
|
52
|
-
const mod = await import(`./version.ts?guard=${Date.now()}`);
|
|
53
|
-
const guarded = mod.default as typeof handler;
|
|
54
|
-
// deno-lint-ignore no-explicit-any
|
|
55
|
-
const bad = (await guarded({ req: req("GET") as any, body: undefined }, app)) as any;
|
|
56
|
-
assertEquals(bad.status, 401);
|
|
57
|
-
// deno-lint-ignore no-explicit-any
|
|
58
|
-
const ok = (await guarded(
|
|
59
|
-
// deno-lint-ignore no-explicit-any
|
|
60
|
-
{ req: req("GET", { "x-hook-secret": "s3cr3t" }) as any, body: undefined },
|
|
61
|
-
app,
|
|
62
|
-
)) as any;
|
|
63
|
-
assertEquals(ok.status, 200);
|
|
64
|
-
} finally {
|
|
65
|
-
if (prev === undefined) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
|
|
66
|
-
else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
Deno.test("buildVersionInfo is side-effect free and stable in shape", () => {
|
|
71
|
-
const a = buildVersionInfo();
|
|
72
|
-
const b = buildVersionInfo();
|
|
73
|
-
assertEquals(a.name, b.name);
|
|
74
|
-
assertEquals(Object.keys(a).sort(), [
|
|
75
|
-
"gitBranch",
|
|
76
|
-
"gitSha",
|
|
77
|
-
"name",
|
|
78
|
-
"pid",
|
|
79
|
-
"runtime",
|
|
80
|
-
"startedAt",
|
|
81
|
-
"uptimeSeconds",
|
|
82
|
-
"urbanVersion",
|
|
83
|
-
"version",
|
|
84
|
-
]);
|
|
85
|
-
});
|
package/actions/version.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
// GET /app/version — the running app's identity (ADR: version endpoint for debugging).
|
|
2
|
-
//
|
|
3
|
-
// Answers "which code is this process actually running?" — the app version, the resolved
|
|
4
|
-
// `@nanobpm/urban` runtime version, the git commit/branch of the working tree, the JS runtime,
|
|
5
|
-
// pid, and how long it has been up. Because the app runs its `.ts` sources directly from a
|
|
6
|
-
// checkout with no build step, restarts alone don't tell you whether the fix you shipped is live;
|
|
7
|
-
// this endpoint does.
|
|
8
|
-
//
|
|
9
|
-
// Read-only and unauthenticated by design (no secrets in the payload); it mirrors the open
|
|
10
|
-
// posture of the pages surface. Optional shared-secret guard when NANO_PR_WEBHOOK_SECRET is set,
|
|
11
|
-
// mirroring /app/status.
|
|
12
|
-
import type { ActionHandler } from "@nanobpm/urban";
|
|
13
|
-
import { buildVersionInfo } from "../app/version.ts";
|
|
14
|
-
|
|
15
|
-
const SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
|
|
16
|
-
|
|
17
|
-
const handler: ActionHandler = ({ req }) => {
|
|
18
|
-
if (req.method !== "GET") return { status: 405, body: { error: "method not allowed (use GET)" } };
|
|
19
|
-
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
20
|
-
return { status: 401, body: { error: "unauthorized" } };
|
|
21
|
-
}
|
|
22
|
-
return { status: 200, body: buildVersionInfo() };
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export default handler;
|