@nanobpm/nano-workforce 0.36.0 → 0.38.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 (75) hide show
  1. package/.github/workflows/ci.yml +7 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +6 -27
  5. package/SPEC.md +15 -13
  6. package/app/abandon.test.ts +14 -19
  7. package/app/abandon.ts +2 -2
  8. package/app/baseGuard.test.ts +7 -6
  9. package/app/blackboard.test.ts +25 -35
  10. package/app/blackboard.ts +2 -2
  11. package/app/ensure-pr.test.ts +10 -12
  12. package/app/github.test.ts +9 -8
  13. package/app/github.ts +1 -21
  14. package/app/instance-tracking.test.ts +8 -6
  15. package/app/mergeExclusion.test.ts +11 -20
  16. package/app/mergeProtocol.test.ts +14 -13
  17. package/app/mergeRebaseArm.test.ts +8 -6
  18. package/app/mergeTrain.test.ts +10 -9
  19. package/app/persist-escalation.test.ts +10 -31
  20. package/app/persist-round.test.ts +7 -20
  21. package/app/plan.test.ts +19 -42
  22. package/app/record-plan-review.test.ts +8 -7
  23. package/app/retro.test.ts +24 -48
  24. package/app/reviewWait.test.ts +12 -11
  25. package/app/rounds.test.ts +13 -12
  26. package/app/service.test.ts +14 -27
  27. package/app/service.ts +1 -1
  28. package/app/taskDelta.test.ts +8 -17
  29. package/app/trialMerge.test.ts +4 -3
  30. package/app/version.ts +6 -21
  31. package/app/waves.test.ts +17 -16
  32. package/biome.json +0 -2
  33. package/main.ts +3 -3
  34. package/nano.app.json +2 -25
  35. package/openapi.yaml +649 -0
  36. package/operations/answerFeatureEscalation.test.ts +100 -0
  37. package/operations/answerFeatureEscalation.ts +49 -0
  38. package/operations/appendBlackboard.ts +61 -0
  39. package/{actions → operations}/blackboard.test.ts +28 -30
  40. package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
  41. package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
  42. package/operations/getVersion.test.ts +8 -12
  43. package/operations/getVersion.ts +2 -3
  44. package/operations/listActivePrs.test.ts +8 -14
  45. package/operations/listActivePrs.ts +3 -4
  46. package/operations/postMessage.ts +1 -1
  47. package/operations/readBlackboard.ts +29 -0
  48. package/operations/startAndMessage.test.ts +11 -15
  49. package/operations/startConvergenceLoop.ts +17 -11
  50. package/operations/startPlanFanout.ts +13 -7
  51. package/package.json +9 -7
  52. package/pages/epic.page.json +1 -1
  53. package/pages/home.page.json +1 -1
  54. package/scripts/check-agent-prompts.test.ts +15 -11
  55. package/scripts/layout-bpmn.ts +6 -28
  56. package/scripts/pages-contract.test.ts +14 -13
  57. package/scripts/purge-db.ts +1 -1
  58. package/scripts/upgrade-from-pack.ts +1 -1
  59. package/test/assert.ts +74 -0
  60. package/tsconfig.json +4 -1
  61. package/workers/persist-task-escalation/worker.ts +1 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-results/worker.test.ts +5 -8
  64. package/workers/record-trial-merge/worker.test.ts +7 -18
  65. package/workers/record-wave/worker.test.ts +13 -20
  66. package/workers/retro-gather/worker.test.ts +4 -17
  67. package/workers/retro-record/worker.test.ts +8 -27
  68. package/workers/select-wave/worker.test.ts +5 -8
  69. package/actions/blackboard.ts +0 -77
  70. package/actions/feature-answer-hook.ts +0 -45
  71. package/actions/plan-hook.ts +0 -20
  72. package/actions/webhook-submit.ts +0 -22
  73. package/deno.json +0 -24
  74. package/deno.lock +0 -1777
  75. package/openapi.json +0 -248
@@ -11,7 +11,7 @@ permissions:
11
11
 
12
12
  jobs:
13
13
  typecheck:
14
- name: typecheck (Node + Deno)
14
+ name: typecheck + test (Node)
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
17
  - name: Checkout
@@ -41,23 +41,13 @@ jobs:
41
41
  - name: Check BPMN diagram freshness (layout)
42
42
  run: npm run layout:check
43
43
 
44
- - name: Setup Deno
45
- uses: denoland/setup-deno@v2
46
- with:
47
- deno-version: v2.x
48
-
49
- - name: Typecheck (Deno)
50
- run: deno check main.ts 'workers/**/*.ts' 'actions/**/*.ts' 'operations/**/*.ts'
51
-
52
44
  # Deploy-safety gate: every model-authored `{{template}}` agent-prompt header must resolve
53
45
  # to a declared, non-blank prompt (and no agent prompt may ship blank). A broken/blank token
54
46
  # means a prompt-less agent — the root of the empty escalations on nano-bpm #597/#599.
55
47
  - name: Check agent prompt templates
56
- run: deno run -A scripts/check-agent-prompts.ts
57
-
58
- # Runs the app/*.test.ts suite (never exercised by the check globs above)
59
- # AND guards the committed lockfile: --frozen fails the build if deno.lock
60
- # is out of date for the full graph (incl. tests), so lockfile drift — e.g.
61
- # a regenerate that drops the jsr:@std/* test deps — can't be merged.
62
- - name: Test + frozen lockfile guard (Deno)
63
- run: deno test -A --frozen
48
+ run: npm run check:prompts
49
+
50
+ # Runs the full *.test.ts suite under Node's built-in test runner (node:test), which strips
51
+ # TypeScript types on the fly (Node >= 22.6) no build step.
52
+ - name: Test (Node)
53
+ run: npm test
package/AGENTS.md CHANGED
@@ -51,7 +51,7 @@ path, a second review loop) — extend the canonical one.
51
51
 
52
52
  ### Zero tolerance for warnings, errors, and test failures
53
53
  There are no pre-existing failures or warnings, and you will not allow any to
54
- enter the codebase. `tsc`, `urban check`, `deno check`, and `deno test` must all
54
+ enter the codebase. `tsc`, `urban check`, `biome check`, and `node --test` must all
55
55
  be clean.
56
56
 
57
57
  ### No task without a tracked issue or PR
@@ -138,20 +138,21 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
138
138
 
139
139
  ## Runtime & CI gates
140
140
 
141
- Node-hosted (`node --experimental-strip-types`), but the sources are
142
- **cross-runtime**: workers/actions/`main.ts` also typecheck and run under Deno.
141
+ Node-hosted (`node --experimental-strip-types`); Node is the only runtime. Tests run
142
+ on Node's built-in runner (`node:test`), which strips TypeScript types on the fly.
143
143
  Match CI locally before pushing:
144
144
 
145
145
  ```bash
146
+ npm run lint # biome check (incl. ban-`as` gate)
146
147
  npm run typecheck # tsc --noEmit (Node)
147
148
  npm run check # urban check (manifest validation)
148
149
  npm run layout:check # BPMN diagram freshness (no drift)
149
- deno check main.ts 'workers/**/*.ts' 'actions/**/*.ts' # Deno typecheck
150
- deno test -A # unit tests (run with -A)
150
+ npm run check:prompts # agent-prompt template resolution
151
+ npm test # unit tests (node --test)
151
152
  ```
152
153
 
153
- CI (`.github/workflows/ci.yml`) gates typecheck, `urban check`, `layout:check`,
154
- Deno typecheck, and the Deno test suite. Run `npm run layout <file.bpmn>` after
154
+ CI (`.github/workflows/ci.yml`) gates lint, typecheck, `urban check`, `layout:check`,
155
+ the prompt check, and the Node test suite. Run `npm run layout <file.bpmn>` after
155
156
  any BPMN flow change and commit the regenerated diagram — the `layout:check`
156
157
  gate fails the build otherwise.
157
158
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.38.0](https://github.com/nanobpm/nano-workforce/compare/v0.37.0...v0.38.0) (2026-08-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * 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))
7
+
8
+ # [0.37.0](https://github.com/nanobpm/nano-workforce/compare/v0.36.0...v0.37.0) (2026-08-10)
9
+
10
+
11
+ ### Features
12
+
13
+ * remove Deno — Node is the only runtime ([#108](https://github.com/nanobpm/nano-workforce/issues/108)) ([d077e9c](https://github.com/nanobpm/nano-workforce/commit/d077e9c94696e2030a2033ad53a05c86a4c18a3e)), closes [#test-assert](https://github.com/nanobpm/nano-workforce/issues/test-assert)
14
+
1
15
  # [0.36.0](https://github.com/nanobpm/nano-workforce/compare/v0.35.2...v0.36.0) (2026-08-10)
2
16
 
3
17
 
package/README.md CHANGED
@@ -84,8 +84,7 @@ it's the same app and the same behaviour.
84
84
  - A running **Nano gateway/engine** (default `http://localhost:8080`) — what the app
85
85
  deploys to and what agents pull jobs from.
86
86
  - **[Node](https://nodejs.org/) >= 22.6** (the app hosts on Node built-ins;
87
- `@nanobpm/urban` declares `engines.node >=22.6`). **[Deno](https://deno.land/)** is
88
- optional (only for `npm run compile`).
87
+ `@nanobpm/urban` declares `engines.node >=22.6`).
89
88
  - The **c8ctl CLI with the `nano` plugin** (to hire/run agents).
90
89
  - On each agent host: the **GitHub CLI** logged in (`gh auth login`) or a
91
90
  `GITHUB_TOKEN`/`GH_TOKEN` in the environment, plus the agent harness itself (e.g.
@@ -214,7 +213,7 @@ the UI and the process re-arms and retries.
214
213
  ### Fleet mode: hand it an issue (plan → implement → converge)
215
214
 
216
215
  ```
217
- issue (UI / POST /hooks/plan) ─► plan-fanout (BPMN)
216
+ issue (UI / POST /app/api/actions/start/plan-fanout) ─► plan-fanout (BPMN)
218
217
  plan (senior:plan) ─► record-plan ─► implement × N (parallel, senior:feature) ─► record-results
219
218
 
220
219
  each opened PR ──► convergence-loop (above)
@@ -223,12 +222,11 @@ the UI and the process re-arms and retries.
223
222
  A planning agent decomposes the issue into tasks; a parallel multi-instance activity
224
223
  fans them out over implementation agents (one PR per task); `record-results` enrols
225
224
  every opened PR into the convergence loop. Submit from the **"Hand an issue to the
226
- fleet"** form, or:
225
+ fleet"** form, or POST the same operation the form does:
227
226
 
228
227
  ```bash
229
- curl -sS -X POST http://localhost:3000/hooks/plan \
228
+ curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
230
229
  -H 'content-type: application/json' \
231
- -H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET" \
232
230
  -d '{ "issue": "owner/repo#123" }'
233
231
  ```
234
232
 
@@ -244,8 +242,8 @@ curl -sS -X POST http://localhost:3000/hooks/plan \
244
242
  | `GITHUB_TOKEN` | — | token for the review poller / merge (or use the host `gh` CLI) |
245
243
  | `NANO_PR_GITHUB_TRANSPORT` | `auto` | how the poller reads GitHub: `gh` (host CLI), `token` (`GITHUB_TOKEN` over HTTP), or `auto` |
246
244
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
247
- | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / webhook `maxRounds`; clamped 1–100) |
248
- | `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 |
249
247
  | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only) |
250
248
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
251
249
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
@@ -277,25 +275,6 @@ per-instance context (e.g. a human's escalation answer) is appended by the harne
277
275
 
278
276
  ---
279
277
 
280
- ## Compile to a native binary
281
-
282
- The app is authored on Node built-ins, so **Deno** can cross-compile it into a native
283
- executable that bundles the runtime, deps, and entry code (the target box needs no
284
- Node/Deno/`node_modules`):
285
-
286
- ```sh
287
- npm run compile # → dist/nano-workforce (via deno compile)
288
- ```
289
-
290
- > **Not yet a single self-contained file.** The binary bundles the runtime + deps +
291
- > entry code, but the Urban runtime loads the app's declarative files
292
- > (`nano.app.json`, `pages/`, `db/migrations/`, `prompts/`, `resources/`, and the
293
- > `actions/`/`workers/` modules) from the **working directory at runtime** — so ship
294
- > the binary alongside those app files and run it from that directory. Add `--target`
295
- > in the `deno.json` `compile` task to cross-compile for another OS/arch.
296
-
297
- ---
298
-
299
278
  ## Architecture & contributing
300
279
 
301
280
  - **[SPEC.md](SPEC.md)** — the behavioural source of truth for the processes.
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
@@ -1,5 +1,6 @@
1
1
  // Tests for the cooperative abandon-check helpers (issue #76).
2
- import { assertEquals, assertNotEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals, assertNotEquals } from "#test-assert";
3
4
  import type { DataLayer } from "@nanobpm/urban";
4
5
  import {
5
6
  abandonStatusForToken,
@@ -11,26 +12,20 @@ import {
11
12
  renderAbandonBrief,
12
13
  } from "./abandon.ts";
13
14
 
14
- // deno-lint-ignore no-explicit-any
15
15
  function memData(): DataLayer {
16
- // deno-lint-ignore no-explicit-any
17
16
  const stores: Record<string, any[]> = {};
18
17
  function tbl(name: string) {
19
- // deno-lint-ignore no-explicit-any
20
18
  const rows = (stores[name] ??= [] as any[]);
21
19
  return {
22
- // deno-lint-ignore no-explicit-any require-await
23
20
  async insert(row: any) {
24
21
  rows.push({ ...row });
25
22
  return row.pr_key;
26
23
  },
27
- // deno-lint-ignore no-explicit-any require-await
28
24
  async findOne(where: any = {}) {
29
25
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
30
26
  },
31
27
  };
32
28
  }
33
- // deno-lint-ignore no-explicit-any
34
29
  return { table: (n: string) => tbl(n) } as any as DataLayer;
35
30
  }
36
31
 
@@ -38,7 +33,7 @@ async function seedPr(data: DataLayer, pr_key: string, abandon_token: string, st
38
33
  await data.table("pull_requests", "pr_key").insert({ pr_key, abandon_token, status });
39
34
  }
40
35
 
41
- Deno.test("isAbandoned is true only for the 'abandoned' status", () => {
36
+ test("isAbandoned is true only for the 'abandoned' status", () => {
42
37
  assertEquals(isAbandoned("abandoned"), true);
43
38
  assertEquals(isAbandoned("converging"), false);
44
39
  assertEquals(isAbandoned("converged"), false);
@@ -47,7 +42,7 @@ Deno.test("isAbandoned is true only for the 'abandoned' status", () => {
47
42
  assertEquals(isAbandoned(undefined), false);
48
43
  });
49
44
 
50
- Deno.test("mintAbandonToken is url-safe, unpadded, and unique", () => {
45
+ test("mintAbandonToken is url-safe, unpadded, and unique", () => {
51
46
  const a = mintAbandonToken();
52
47
  const b = mintAbandonToken();
53
48
  assertNotEquals(a, b);
@@ -55,31 +50,31 @@ Deno.test("mintAbandonToken is url-safe, unpadded, and unique", () => {
55
50
  assertEquals(a.includes("="), false);
56
51
  });
57
52
 
58
- Deno.test("abandonUrl carries the token on the query string (url-encoded)", () => {
53
+ test("abandonUrl carries the token on the query string (url-encoded)", () => {
59
54
  assertEquals(
60
55
  abandonUrl("tok+/=", "https://host"),
61
- "https://host/hooks/abandon?token=tok%2B%2F%3D",
56
+ "https://host/app/api/hooks/abandon?token=tok%2B%2F%3D",
62
57
  );
63
58
  });
64
59
 
65
- Deno.test("abandonTokenFromUrl round-trips the token minted into an abandonUrl", () => {
60
+ test("abandonTokenFromUrl round-trips the token minted into an abandonUrl", () => {
66
61
  const tok = mintAbandonToken();
67
62
  assertEquals(abandonTokenFromUrl(abandonUrl(tok, "https://host")), tok);
68
63
  // url-special tokens survive the encode/decode round-trip too.
69
64
  assertEquals(abandonTokenFromUrl(abandonUrl("tok+/=", "https://host")), "tok+/=");
70
65
  });
71
66
 
72
- Deno.test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage input", () => {
67
+ test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage input", () => {
73
68
  assertEquals(abandonTokenFromUrl(undefined), undefined);
74
69
  assertEquals(abandonTokenFromUrl(null), undefined);
75
70
  assertEquals(abandonTokenFromUrl(""), undefined);
76
- assertEquals(abandonTokenFromUrl("https://host/hooks/abandon"), undefined, "no token param");
71
+ assertEquals(abandonTokenFromUrl("https://host/app/api/hooks/abandon"), undefined, "no token param");
77
72
  assertEquals(abandonTokenFromUrl("not a url"), undefined, "unparseable input never throws");
78
73
  });
79
74
 
80
- Deno.test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
81
- const brief = renderAbandonBrief("https://host/hooks/abandon?token=tok");
82
- assertEquals(brief.includes("https://host/hooks/abandon?token=tok"), true);
75
+ test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
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);
83
78
  assertEquals(brief.includes("abandoned"), true);
84
79
  assertEquals(brief.includes("Abort"), true);
85
80
  // Must use `curl -f` so a 404 (torn-down run) fails the command instead of exiting 0 with an
@@ -87,7 +82,7 @@ Deno.test("renderAbandonBrief embeds the concrete URL and the stop contract", ()
87
82
  assertEquals(brief.includes("curl -fsS"), true);
88
83
  });
89
84
 
90
- Deno.test("prKeyForAbandonToken resolves a known token and rejects unknowns", async () => {
85
+ test("prKeyForAbandonToken resolves a known token and rejects unknowns", async () => {
91
86
  const data = memData();
92
87
  await seedPr(data, "o/r#1", "tok", "converging");
93
88
  assertEquals(await prKeyForAbandonToken(data, "tok"), "o/r#1");
@@ -95,7 +90,7 @@ Deno.test("prKeyForAbandonToken resolves a known token and rejects unknowns", as
95
90
  assertEquals(await prKeyForAbandonToken(data, ""), undefined);
96
91
  });
97
92
 
98
- Deno.test("abandonStatusForToken derives abandoned from the row status", async () => {
93
+ test("abandonStatusForToken derives abandoned from the row status", async () => {
99
94
  const data = memData();
100
95
  await seedPr(data, "o/r#1", "live", "converging");
101
96
  await seedPr(data, "o/r#2", "dead", "abandoned");
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;
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the dead-end-base guard decision (#60).
2
- import { assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals } from "#test-assert";
3
4
  import { type BaseTarget, isDeadEndBase } from "./baseGuard.ts";
4
5
 
5
6
  const t = (base: string, defaultBranch: string, landed: BaseTarget["landed"]): BaseTarget => ({
@@ -8,28 +9,28 @@ const t = (base: string, defaultBranch: string, landed: BaseTarget["landed"]): B
8
9
  landed,
9
10
  });
10
11
 
11
- Deno.test("dead-end: a non-default base that has already landed", () => {
12
+ test("dead-end: a non-default base that has already landed", () => {
12
13
  // The exact #54 case: base 'feat/coordination-blackboard' merged to 'main' but wasn't deleted.
13
14
  assertEquals(isDeadEndBase(t("feat/coordination-blackboard", "main", "landed")), true);
14
15
  });
15
16
 
16
- Deno.test("NOT a dead-end: the base IS the default branch (the common straight-to-main PR)", () => {
17
+ test("NOT a dead-end: the base IS the default branch (the common straight-to-main PR)", () => {
17
18
  // Even if a same-named 'main' somehow reported landed, a PR targeting the default branch is the
18
19
  // normal terminal target — never a dead-end.
19
20
  assertEquals(isDeadEndBase(t("main", "main", "landed")), false);
20
21
  });
21
22
 
22
- Deno.test("NOT a dead-end: a live stacked base whose PR is still open", () => {
23
+ test("NOT a dead-end: a live stacked base whose PR is still open", () => {
23
24
  assertEquals(isDeadEndBase(t("feat/tier2", "main", "open")), false);
24
25
  });
25
26
 
26
- Deno.test("NOT a dead-end on ambiguity: base has no PR / transport couldn't tell (unknown)", () => {
27
+ test("NOT a dead-end on ambiguity: base has no PR / transport couldn't tell (unknown)", () => {
27
28
  // We block only on a positive `landed` signal, so a legitimately-stacked feature branch that
28
29
  // simply has no PR yet is never wrongly held.
29
30
  assertEquals(isDeadEndBase(t("feat/tier2", "main", "unknown")), false);
30
31
  });
31
32
 
32
- Deno.test("unknown-safe: blank base or blank default is never a dead-end", () => {
33
+ test("unknown-safe: blank base or blank default is never a dead-end", () => {
33
34
  assertEquals(isDeadEndBase(t("", "main", "landed")), false);
34
35
  assertEquals(isDeadEndBase(t("feat/x", "", "landed")), false);
35
36
  });
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
2
- import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
3
4
  import type { DataLayer } from "@nanobpm/urban";
4
5
  import {
5
6
  appendEntry,
@@ -17,16 +18,12 @@ import {
17
18
 
18
19
  // A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
19
20
  // blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
20
- // deno-lint-ignore no-explicit-any
21
21
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
22
- // deno-lint-ignore no-explicit-any
23
22
  const stores: Record<string, any[]> = {};
24
23
  const seq: Record<string, number> = {};
25
24
  function tbl(name: string, pk = "id") {
26
- // deno-lint-ignore no-explicit-any
27
25
  const rows = (stores[name] ??= [] as any[]);
28
26
  return {
29
- // deno-lint-ignore no-explicit-any require-await
30
27
  async insert(row: any) {
31
28
  if (pk === "id") {
32
29
  const id = (seq[name] = (seq[name] ?? 0) + 1);
@@ -36,22 +33,19 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
36
33
  rows.push({ ...row });
37
34
  return row[pk];
38
35
  },
39
- // deno-lint-ignore no-explicit-any require-await
40
36
  async find(where: any = {}) {
41
37
  return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
42
38
  },
43
- // deno-lint-ignore no-explicit-any require-await
44
39
  async findOne(where: any = {}) {
45
40
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
46
41
  },
47
42
  };
48
43
  }
49
- // deno-lint-ignore no-explicit-any
50
44
  const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
51
45
  return { data, stores };
52
46
  }
53
47
 
54
- Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
48
+ test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
55
49
  const a = mintBlackboardToken();
56
50
  const b = mintBlackboardToken();
57
51
  assert(a !== b, "two mints must differ");
@@ -59,32 +53,32 @@ Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
59
53
  assert(a.length >= 32, "token should carry enough entropy");
60
54
  });
61
55
 
62
- Deno.test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
56
+ test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
63
57
  assertEquals(publicBaseUrl("https://pr.example.com/"), "https://pr.example.com");
64
58
  assertEquals(publicBaseUrl("https://pr.example.com///"), "https://pr.example.com");
65
59
  });
66
60
 
67
- Deno.test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
61
+ test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
68
62
  const prev = process.env.NANO_PR_BASE_URL;
69
63
  delete process.env.NANO_PR_BASE_URL;
70
64
  try {
71
65
  assertEquals(publicBaseUrl(""), "http://localhost:3000");
72
66
  assertEquals(publicBaseUrl(" "), "http://localhost:3000");
73
- 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");
74
68
  } finally {
75
69
  if (prev === undefined) delete process.env.NANO_PR_BASE_URL;
76
70
  else process.env.NANO_PR_BASE_URL = prev;
77
71
  }
78
72
  });
79
73
 
80
- Deno.test("blackboardUrl: capability token rides the query string", () => {
74
+ test("blackboardUrl: capability token rides the query string", () => {
81
75
  assertEquals(
82
76
  blackboardUrl("tok+en/x", "https://h"),
83
- "https://h/hooks/blackboard?token=tok%2Ben%2Fx",
77
+ "https://h/app/api/hooks/blackboard?token=tok%2Ben%2Fx",
84
78
  );
85
79
  });
86
80
 
87
- Deno.test("normalizeKind: valid passes through, anything else becomes note", () => {
81
+ test("normalizeKind: valid passes through, anything else becomes note", () => {
88
82
  assertEquals(normalizeKind("file-claim"), "file-claim");
89
83
  assertEquals(normalizeKind("constraint-change"), "constraint-change");
90
84
  assertEquals(normalizeKind("learning"), "learning");
@@ -92,8 +86,8 @@ Deno.test("normalizeKind: valid passes through, anything else becomes note", ()
92
86
  assertEquals(normalizeKind(undefined), "note");
93
87
  });
94
88
 
95
- Deno.test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
96
- const url = "https://h/hooks/blackboard?token=abc";
89
+ test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
90
+ const url = "https://h/app/api/hooks/blackboard?token=abc";
97
91
  const brief = renderCoordinationBrief(url);
98
92
  assert(brief.startsWith("\n\n---"), "must own a leading separator (appendPrompt adds none)");
99
93
  assertStringIncludes(brief, url);
@@ -112,7 +106,7 @@ Deno.test("renderCoordinationBrief: leads with a separator and teaches the proto
112
106
  assertStringIncludes(brief, "Share what you learn");
113
107
  });
114
108
 
115
- Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
109
+ test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
116
110
  const { data } = memData();
117
111
  await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
118
112
  assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
@@ -120,7 +114,7 @@ Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise",
120
114
  assertEquals(await planKeyForToken(data, ""), undefined);
121
115
  });
122
116
 
123
- Deno.test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
117
+ test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
124
118
  const { data } = memData();
125
119
  await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
126
120
  await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
@@ -133,14 +127,14 @@ Deno.test("appendEntry + readBlackboard: append, encode files, read back in writ
133
127
  assertEquals(entries[1].author_task, "gap-8");
134
128
  });
135
129
 
136
- Deno.test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
130
+ test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
137
131
  const { data } = memData();
138
132
  await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
139
133
  const [e] = await readBlackboard(data, "p");
140
134
  assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
141
135
  });
142
136
 
143
- Deno.test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
137
+ test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
144
138
  const { data } = memData();
145
139
  await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
146
140
  const [e] = await readBlackboard(data, "p");
@@ -148,7 +142,7 @@ Deno.test("appendEntry: a missing author defaults to 'system' and kind is normal
148
142
  assertEquals(e.kind, "note");
149
143
  });
150
144
 
151
- Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
145
+ test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
152
146
  const { data, stores } = memData();
153
147
  const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
154
148
  const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
@@ -158,15 +152,13 @@ Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op
158
152
  assertEquals(stores["plan_blackboard"].length, 1, "exactly one row persisted");
159
153
  });
160
154
 
161
- Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
155
+ test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
162
156
  // Simulate the concurrency window: two POSTs share a dedupe_key, both miss the findOne
163
157
  // pre-check, then insert loses the race on the UNIQUE (plan_key, dedupe_key) index. The
164
158
  // catch branch must re-read the winner's row and return it rather than propagate the throw.
165
159
  const winner = { id: 42, plan_key: "p", dedupe_key: "t:claim:1", author_task: "t", body: "claim" };
166
160
  let preCheckDone = false;
167
- // deno-lint-ignore no-explicit-any
168
161
  const table: any = {
169
- // deno-lint-ignore require-await
170
162
  async findOne() {
171
163
  // Pre-check misses (row not yet visible); the recovery read after the collision hits.
172
164
  if (!preCheckDone) {
@@ -175,21 +167,19 @@ Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500
175
167
  }
176
168
  return winner;
177
169
  },
178
- // deno-lint-ignore require-await
179
170
  async insert() {
180
171
  throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
181
172
  code: "SQLITE_CONSTRAINT_UNIQUE",
182
173
  });
183
174
  },
184
175
  };
185
- // deno-lint-ignore no-explicit-any
186
176
  const data = { table: () => table } as any as DataLayer;
187
177
  const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
188
178
  assertEquals(res.inserted, false, "a lost race is not a fresh insert");
189
179
  assertEquals(res.id, 42, "returns the winning row's id");
190
180
  });
191
181
 
192
- Deno.test("appendEntry: a blank body is rejected", async () => {
182
+ test("appendEntry: a blank body is rejected", async () => {
193
183
  const { data } = memData();
194
184
  let threw = false;
195
185
  try {
@@ -200,7 +190,7 @@ Deno.test("appendEntry: a blank body is rejected", async () => {
200
190
  assert(threw, "blank body must throw");
201
191
  });
202
192
 
203
- Deno.test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
193
+ test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
204
194
  const { data } = memData();
205
195
  await appendEntry(data, "p", { body: "one" });
206
196
  await appendEntry(data, "p", { body: "two" });
@@ -210,7 +200,7 @@ Deno.test("readBlackboard: since returns only newer entries (incremental poll)",
210
200
  assertEquals(tail.map((e) => e.body), ["two", "three"]);
211
201
  });
212
202
 
213
- Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
203
+ test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
214
204
  const { data } = memData();
215
205
  await appendEntry(data, "p", { body: "one" });
216
206
  await appendEntry(data, "p", { body: "two" });
@@ -231,14 +221,14 @@ Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to
231
221
  assertEquals(next.cursor, next.entries[0].id);
232
222
  });
233
223
 
234
- Deno.test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
224
+ test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
235
225
  const { data } = memData();
236
226
  const page = await readBlackboardPage(data, "empty");
237
227
  assertEquals(page.entries, []);
238
228
  assertEquals(page.cursor, 0);
239
229
  });
240
230
 
241
- Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
231
+ test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
242
232
  const { data } = memData();
243
233
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
244
234
 
@@ -251,7 +241,7 @@ Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is
251
241
  assertEquals(conflicts[0].author_task, "gap-2", "reports the first (winning) claimer");
252
242
  });
253
243
 
254
- Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
244
+ test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
255
245
  const { data } = memData();
256
246
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
257
247
  await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
@@ -265,7 +255,7 @@ Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim ent
265
255
  assertEquals(await detectFileClaimConflicts(data, "p", { author_task: "gap-9", files: [] }), []);
266
256
  });
267
257
 
268
- Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
258
+ test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
269
259
  const { data } = memData();
270
260
  const prior = await appendEntry(data, "p", {
271
261
  author_task: "gap-2",
@@ -299,7 +289,7 @@ Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims
299
289
  assert(Number(later.id) > Number(mine.id));
300
290
  });
301
291
 
302
- Deno.test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
292
+ test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
303
293
  // Extended SQLite codes.
304
294
  assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_UNIQUE" })));
305
295
  assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_PRIMARYKEY" })));