@nanobpm/nano-workforce 0.37.0 → 0.39.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -6
  3. package/SPEC.md +15 -13
  4. package/app/abandon.test.ts +4 -4
  5. package/app/abandon.ts +2 -2
  6. package/app/blackboard.test.ts +3 -3
  7. package/app/blackboard.ts +2 -2
  8. package/app/persist-escalation.test.ts +1 -1
  9. package/app/persist-round.test.ts +1 -1
  10. package/app/service.ts +1 -1
  11. package/biome.json +0 -2
  12. package/main.ts +3 -3
  13. package/nano.app.json +12 -25
  14. package/openapi.yaml +653 -0
  15. package/operations/answerFeatureEscalation.test.ts +100 -0
  16. package/operations/answerFeatureEscalation.ts +40 -0
  17. package/operations/appendBlackboard.ts +58 -0
  18. package/{actions → operations}/blackboard.test.ts +16 -10
  19. package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +5 -11
  20. package/{actions/abandon.ts → operations/checkAbandon.ts} +6 -7
  21. package/operations/getVersion.ts +4 -6
  22. package/operations/listActivePrs.ts +5 -9
  23. package/operations/postMessage.ts +4 -12
  24. package/operations/readBlackboard.ts +26 -0
  25. package/operations/startAndMessage.test.ts +5 -3
  26. package/operations/startConvergenceLoop.ts +16 -19
  27. package/operations/startPlanFanout.ts +14 -19
  28. package/package.json +5 -4
  29. package/pages/epic.page.json +1 -1
  30. package/pages/home.page.json +1 -1
  31. package/tsconfig.json +1 -0
  32. package/workers/persist-task-escalation/worker.ts +1 -1
  33. package/actions/blackboard.ts +0 -77
  34. package/actions/feature-answer-hook.ts +0 -45
  35. package/actions/plan-hook.ts +0 -20
  36. package/actions/webhook-submit.ts +0 -22
  37. package/openapi.json +0 -248
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.39.0](https://github.com/nanobpm/nano-workforce/compare/v0.38.0...v0.39.0) (2026-08-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * adopt urban 0.38.0 typed delegate registry for the OpenAPI surface ([#110](https://github.com/nanobpm/nano-workforce/issues/110)) ([fe62908](https://github.com/nanobpm/nano-workforce/commit/fe62908968c0bd707f8e2da58fa550cca9fbab5b))
7
+
8
+ # [0.38.0](https://github.com/nanobpm/nano-workforce/compare/v0.37.0...v0.38.0) (2026-08-10)
9
+
10
+
11
+ ### Features
12
+
13
+ * migrate hooks to OpenAPI operations, one HTTP surface (ADR 0059) ([#109](https://github.com/nanobpm/nano-workforce/issues/109)) ([493d78c](https://github.com/nanobpm/nano-workforce/commit/493d78c5b58ba18098c7d458bd9538660eb2d0f3))
14
+
1
15
  # [0.37.0](https://github.com/nanobpm/nano-workforce/compare/v0.36.0...v0.37.0) (2026-08-10)
2
16
 
3
17
 
package/README.md CHANGED
@@ -213,7 +213,7 @@ the UI and the process re-arms and retries.
213
213
  ### Fleet mode: hand it an issue (plan → implement → converge)
214
214
 
215
215
  ```
216
- issue (UI / POST /hooks/plan) ─► plan-fanout (BPMN)
216
+ issue (UI / POST /app/api/actions/start/plan-fanout) ─► plan-fanout (BPMN)
217
217
  plan (senior:plan) ─► record-plan ─► implement × N (parallel, senior:feature) ─► record-results
218
218
 
219
219
  each opened PR ──► convergence-loop (above)
@@ -222,12 +222,11 @@ the UI and the process re-arms and retries.
222
222
  A planning agent decomposes the issue into tasks; a parallel multi-instance activity
223
223
  fans them out over implementation agents (one PR per task); `record-results` enrols
224
224
  every opened PR into the convergence loop. Submit from the **"Hand an issue to the
225
- fleet"** form, or:
225
+ fleet"** form, or POST the same operation the form does:
226
226
 
227
227
  ```bash
228
- curl -sS -X POST http://localhost:3000/hooks/plan \
228
+ curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
229
229
  -H 'content-type: application/json' \
230
- -H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET" \
231
230
  -d '{ "issue": "owner/repo#123" }'
232
231
  ```
233
232
 
@@ -243,8 +242,8 @@ curl -sS -X POST http://localhost:3000/hooks/plan \
243
242
  | `GITHUB_TOKEN` | — | token for the review poller / merge (or use the host `gh` CLI) |
244
243
  | `NANO_PR_GITHUB_TRANSPORT` | `auto` | how the poller reads GitHub: `gh` (host CLI), `token` (`GITHUB_TOKEN` over HTTP), or `auto` |
245
244
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
246
- | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / webhook `maxRounds`; clamped 1–100) |
247
- | `NANO_PR_WEBHOOK_SECRET` | — | shared secret for `POST /hooks/submit` (`X-Hook-Secret`) |
245
+ | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
246
+ | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `POST /app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`); unset = open |
248
247
  | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only) |
249
248
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
250
249
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
package/SPEC.md CHANGED
@@ -56,7 +56,7 @@ surface are proposed and open for adjustment.
56
56
  ```
57
57
  nano-workforce/
58
58
  nano.app.json # manifest (ADR 0027): sqlite data, domain types, submit webhook trigger
59
- main.ts # Deno entrypoint: deploy + start workers + Deno.serve (page runtime + action overrides + poller)
59
+ main.ts # Node entrypoint: deploy + start workers + start the runtime (page runtime + OpenAPI operations + poller)
60
60
  deno.json
61
61
  pages/
62
62
  home.page.json # the screen, authored declaratively (ADR 0042 Page Composer)
@@ -263,20 +263,22 @@ row by migration `003`).
263
263
 
264
264
  The app-specific business-logic endpoints are **OpenAPI operations** mounted
265
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
+ `operations/`. The webhook endpoints are ordinary operations too (ADR 0059 — the
267
+ `actions[]` array is retired), mounted under `/app/api/hooks/*`. The runtime
268
+ serves them all; `main.ts` only starts the runtime and the review-ready poller.
269
+ The full, authoritative contract is `openapi.yaml` (Swagger UI at
270
+ `/app/api-docs`); the OpenAPI rows below are the complete set of operations:
271
271
 
272
272
  | method | route | purpose |
273
273
  |---|---|---|
274
274
  | `GET` | `/app/api/status` | list tracked PRs + count |
275
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 |
276
+ | `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process (the ONE submit door — page + external callers) |
277
+ | `POST` | `/app/api/actions/start/plan-fanout` | parse the issue ref → start a plan fan-out run (the ONE plan door) |
278
278
  | `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open escalation → publish `escalation-answered` |
279
- | `POST` | `/hooks/submit` | webhook submit (shared-secret auth) start the process |
279
+ | `POST` | `/app/api/hooks/feature-answer` | answer an implementation-phase task escalation out of band (optional shared-secret) |
280
+ | `GET`/`POST` | `/app/api/hooks/blackboard` | per-plan coordination blackboard (capability-token side-channel) |
281
+ | `GET` | `/app/api/hooks/abandon` | cooperative abandon check (per-PR capability token) |
280
282
 
281
283
  Everything else (`GET /`, `GET /app/pages/*`, `GET /app/data/*`, the renderer) is
282
284
  served by the runtime — including `POST /app/actions/cancel`, which is Urban's
@@ -393,7 +395,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
393
395
  | `GITHUB_TOKEN` | — | GitHub API (poller + agent) |
394
396
  | `NANO_PR_POLL_MS` | 60000 | poll interval |
395
397
  | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
396
- | `NANO_PR_WEBHOOK_SECRET` | — | HMAC for `/hooks/submit` |
398
+ | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `/app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`) |
397
399
  | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only) |
398
400
  | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
399
401
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
@@ -443,9 +445,9 @@ scalar-only and cannot express the `tasks`/`results` lists, so the workers self-
443
445
  **Domain model** (`db/migrations/004_planning.sql`): `plans` (one row per issue) +
444
446
  `plan_tasks` (one row per slice, tracking its `status`/`pr_key`/`summary`).
445
447
 
446
- **Entry points**: the page's "Hand an issue to the fleet" form
447
- (`startProcess plan-fanout` `actions/plan-start.ts`), or `POST /hooks/plan`
448
- (`{ issue | url }`, optional `X-Hook-Secret`).
448
+ **Entry points**: the epic page's "Hand an issue to the fleet" form or
449
+ `POST /app/api/actions/start/plan-fanout` (`{ issue | url }`) the same flat
450
+ operation the form posts.
449
451
 
450
452
  **Visibility**: the home page adds a **Plans** grid (Active: planning/dispatched;
451
453
  History: done/failed/abandoned) with a `plan_tasks` child grid showing each task's
@@ -53,7 +53,7 @@ test("mintAbandonToken is url-safe, unpadded, and unique", () => {
53
53
  test("abandonUrl carries the token on the query string (url-encoded)", () => {
54
54
  assertEquals(
55
55
  abandonUrl("tok+/=", "https://host"),
56
- "https://host/hooks/abandon?token=tok%2B%2F%3D",
56
+ "https://host/app/api/hooks/abandon?token=tok%2B%2F%3D",
57
57
  );
58
58
  });
59
59
 
@@ -68,13 +68,13 @@ test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage input",
68
68
  assertEquals(abandonTokenFromUrl(undefined), undefined);
69
69
  assertEquals(abandonTokenFromUrl(null), undefined);
70
70
  assertEquals(abandonTokenFromUrl(""), undefined);
71
- assertEquals(abandonTokenFromUrl("https://host/hooks/abandon"), undefined, "no token param");
71
+ assertEquals(abandonTokenFromUrl("https://host/app/api/hooks/abandon"), undefined, "no token param");
72
72
  assertEquals(abandonTokenFromUrl("not a url"), undefined, "unparseable input never throws");
73
73
  });
74
74
 
75
75
  test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
76
- const brief = renderAbandonBrief("https://host/hooks/abandon?token=tok");
77
- assertEquals(brief.includes("https://host/hooks/abandon?token=tok"), true);
76
+ const brief = renderAbandonBrief("https://host/app/api/hooks/abandon?token=tok");
77
+ assertEquals(brief.includes("https://host/app/api/hooks/abandon?token=tok"), true);
78
78
  assertEquals(brief.includes("abandoned"), true);
79
79
  assertEquals(brief.includes("Abort"), true);
80
80
  // Must use `curl -f` so a 404 (torn-down run) fails the command instead of exiting 0 with an
package/app/abandon.ts CHANGED
@@ -42,13 +42,13 @@ export function mintAbandonToken(): string {
42
42
  /** The capability URL for a PR's abandon check: the token rides the query string so the agent can
43
43
  * GET the exact string it was handed with no header assembly. */
44
44
  export function abandonUrl(token: string, base: string = publicBaseUrl()): string {
45
- return `${base}/hooks/abandon?token=${encodeURIComponent(token)}`;
45
+ return `${base}/app/api/hooks/abandon?token=${encodeURIComponent(token)}`;
46
46
  }
47
47
 
48
48
  /** Recover the capability token from an abandon URL — the inverse of `abandonUrl`. Returns
49
49
  * undefined when the input is absent or carries no `token` query param. A desync-heal uses this to
50
50
  * reconstruct a missing `pull_requests` row with the SAME token the running agent was already
51
- * handed (via the `abandonUrl` process variable), so its `curl -f "…/hooks/abandon?token=…"` abort
51
+ * handed (via the `abandonUrl` process variable), so its `curl -f "…/app/api/hooks/abandon?token=…"` abort
52
52
  * check keeps resolving instead of 404-ing on a freshly-minted token and aborting a live run. */
53
53
  export function abandonTokenFromUrl(url: string | null | undefined): string | undefined {
54
54
  if (!url) return undefined;
@@ -64,7 +64,7 @@ test("publicBaseUrl: a blank/whitespace override falls back instead of yielding
64
64
  try {
65
65
  assertEquals(publicBaseUrl(""), "http://localhost:3000");
66
66
  assertEquals(publicBaseUrl(" "), "http://localhost:3000");
67
- assertEquals(blackboardUrl("t", publicBaseUrl("")), "http://localhost:3000/hooks/blackboard?token=t");
67
+ assertEquals(blackboardUrl("t", publicBaseUrl("")), "http://localhost:3000/app/api/hooks/blackboard?token=t");
68
68
  } finally {
69
69
  if (prev === undefined) delete process.env.NANO_PR_BASE_URL;
70
70
  else process.env.NANO_PR_BASE_URL = prev;
@@ -74,7 +74,7 @@ test("publicBaseUrl: a blank/whitespace override falls back instead of yielding
74
74
  test("blackboardUrl: capability token rides the query string", () => {
75
75
  assertEquals(
76
76
  blackboardUrl("tok+en/x", "https://h"),
77
- "https://h/hooks/blackboard?token=tok%2Ben%2Fx",
77
+ "https://h/app/api/hooks/blackboard?token=tok%2Ben%2Fx",
78
78
  );
79
79
  });
80
80
 
@@ -87,7 +87,7 @@ test("normalizeKind: valid passes through, anything else becomes note", () => {
87
87
  });
88
88
 
89
89
  test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
90
- const url = "https://h/hooks/blackboard?token=abc";
90
+ const url = "https://h/app/api/hooks/blackboard?token=abc";
91
91
  const brief = renderCoordinationBrief(url);
92
92
  assert(brief.startsWith("\n\n---"), "must own a leading separator (appendPrompt adds none)");
93
93
  assertStringIncludes(brief, url);
package/app/blackboard.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  // stable `dedupe_key` is a no-op (backed by a unique index; we also short-circuit here).
14
14
  // - CAPABILITY URL. The per-plan token IS the credential; the agent curls the exact URL it was
15
15
  // handed (delivered in `appendPrompt`). Delivery is in-band (rides the prompt the harness
16
- // already forwards); use is out-of-band (a direct side-channel to `/hooks/blackboard`).
16
+ // already forwards); use is out-of-band (a direct side-channel to `/app/api/hooks/blackboard`).
17
17
  //
18
18
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
19
19
  // app/service.ts and app/plan.ts.
@@ -87,7 +87,7 @@ export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBL
87
87
  /** The capability URL for a plan's blackboard: the token rides the query string, so the agent can
88
88
  * GET/POST the exact string it was handed with no header assembly. */
89
89
  export function blackboardUrl(token: string, base: string = publicBaseUrl()): string {
90
- return `${base}/hooks/blackboard?token=${encodeURIComponent(token)}`;
90
+ return `${base}/app/api/hooks/blackboard?token=${encodeURIComponent(token)}`;
91
91
  }
92
92
 
93
93
  /** The coordination-protocol block appended (verbatim, via `appendPrompt`) to each implementer
@@ -149,7 +149,7 @@ test("persist-escalation heals from the prKey when repo/prNumber are absent", as
149
149
  round: 2,
150
150
  status: "needs_input",
151
151
  question: "decide",
152
- abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
152
+ abandonUrl: "https://host/app/api/hooks/abandon?token=TOK-en_123",
153
153
  },
154
154
  };
155
155
  await handler(job as any, app as any);
@@ -103,7 +103,7 @@ test("persist-round heals from the prKey when repo/prNumber are absent", async (
103
103
  prKey: "o/r#12",
104
104
  round: 2,
105
105
  status: "addressed",
106
- abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
106
+ abandonUrl: "https://host/app/api/hooks/abandon?token=TOK-en_123",
107
107
  },
108
108
  };
109
109
  await handler(job as any, app as any);
package/app/service.ts CHANGED
@@ -129,7 +129,7 @@ interface PullRequest {
129
129
  // frugal-CI fresh-head-run remedy. A rebase changes the head and therefore permits a new nudge.
130
130
  fresh_head_run_head: string | null;
131
131
  // Cooperative abandon check (015_pr_abandon_token.sql, issue #76): the per-PR capability token a
132
- // running agent curls (GET /hooks/abandon?token=…) to learn whether this run was cancelled before
132
+ // running agent curls (GET /app/api/hooks/abandon?token=…) to learn whether this run was cancelled before
133
133
  // it performs a side effect. Minted at submit, reused across the convergence + merge instances.
134
134
  abandon_token: string | null;
135
135
  // Technical-incident surfacing (017_pr_incident.sql, issue #94), written by the poller's
package/biome.json CHANGED
@@ -4,7 +4,6 @@
4
4
  "includes": [
5
5
  "app/**/*.ts",
6
6
  "operations/**/*.ts",
7
- "actions/**/*.ts",
8
7
  "workers/**/*.ts",
9
8
  "pages/**/*.ts",
10
9
  "components/**/*.ts",
@@ -20,7 +19,6 @@
20
19
  "includes": [
21
20
  "app/**/*.ts",
22
21
  "operations/**/*.ts",
23
- "actions/**/*.ts",
24
22
  "workers/**/*.ts",
25
23
  "pages/**/*.ts",
26
24
  "components/**/*.ts",
package/main.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // nano-workforce — Urban App entrypoint (ADR 0055).
2
2
  //
3
3
  // The whole app is declared in `nano.app.json` (models, sqlite datasource, app-hosted record
4
- // workers, the schema-driven pages surface, and the action overrides) and materialized by the
4
+ // workers, the schema-driven pages surface, and the OpenAPI control surface) and materialized by the
5
5
  // `@nanobpm/urban` runtime via `runFromEnv`:
6
6
  // • deploys the BPMN + hosts the `pr.*` record workers (workers/*/worker.ts),
7
7
  // • serves the schema-driven page runtime (ADR 0042) from `pages/home.page.json`,
8
- // • mounts the app-specific action overrides (actions/*.ts) that wrap the generic
9
- // start/cancel/message actions, plus the `/hooks/submit` webhook.
8
+ // • mounts the OpenAPI operations (openapi.yaml → operations/*.ts) the start/cancel/message
9
+ // control endpoints plus the webhook operations under `/app/api/hooks/*` (ADR 0059).
10
10
  //
11
11
  // The only thing that isn't declarative is the review-ready poller: it does arbitrary GitHub
12
12
  // polling and then correlates a `review-ready` message. A cron trigger can only fire an engine
package/nano.app.json CHANGED
@@ -124,6 +124,16 @@
124
124
  "handler": "workers/retro-record/worker.ts"
125
125
  }
126
126
  ],
127
+ "externalTaskTypes": [
128
+ "senior:pr-review",
129
+ "senior:fix-ci",
130
+ "senior:rebase",
131
+ "senior:plan",
132
+ "senior:plan-review",
133
+ "senior:feature",
134
+ "senior:trial-merge",
135
+ "senior:retro"
136
+ ],
127
137
  "surfaces": {
128
138
  "pages": {
129
139
  "enabled": true,
@@ -140,31 +150,8 @@
140
150
  "icon": "assets/icon.svg"
141
151
  },
142
152
  "api": {
143
- "spec": "openapi.json",
153
+ "spec": "openapi.yaml",
144
154
  "dir": "operations",
145
155
  "validateResponses": "dev"
146
- },
147
- "actions": [
148
- {
149
- "path": "/hooks/submit",
150
- "module": "actions/webhook-submit.ts"
151
- },
152
- {
153
- "path": "/hooks/plan",
154
- "module": "actions/plan-hook.ts"
155
- },
156
- {
157
- "path": "/hooks/feature-answer",
158
- "module": "actions/feature-answer-hook.ts"
159
- },
160
- {
161
- "path": "/hooks/blackboard",
162
- "module": "actions/blackboard.ts"
163
- },
164
- {
165
- "path": "/hooks/abandon",
166
- "module": "actions/abandon.ts",
167
- "method": "GET"
168
- }
169
- ]
156
+ }
170
157
  }