@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.
- package/.github/workflows/ci.yml +7 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +6 -27
- package/SPEC.md +15 -13
- package/app/abandon.test.ts +14 -19
- package/app/abandon.ts +2 -2
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +25 -35
- package/app/blackboard.ts +2 -2
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +1 -21
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +10 -31
- package/app/persist-round.test.ts +7 -20
- package/app/plan.test.ts +19 -42
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +1 -1
- package/app/taskDelta.test.ts +8 -17
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +6 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +0 -2
- package/main.ts +3 -3
- package/nano.app.json +2 -25
- package/openapi.yaml +649 -0
- package/operations/answerFeatureEscalation.test.ts +100 -0
- package/operations/answerFeatureEscalation.ts +49 -0
- package/operations/appendBlackboard.ts +61 -0
- package/{actions → operations}/blackboard.test.ts +28 -30
- package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
- package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +3 -4
- package/operations/postMessage.ts +1 -1
- package/operations/readBlackboard.ts +29 -0
- package/operations/startAndMessage.test.ts +11 -15
- package/operations/startConvergenceLoop.ts +17 -11
- package/operations/startPlanFanout.ts +13 -7
- package/package.json +9 -7
- package/pages/epic.page.json +1 -1
- package/pages/home.page.json +1 -1
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/layout-bpmn.ts +6 -28
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +1 -1
- package/scripts/upgrade-from-pack.ts +1 -1
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/persist-task-escalation/worker.ts +1 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/select-wave/worker.test.ts +5 -8
- package/actions/blackboard.ts +0 -77
- package/actions/feature-answer-hook.ts +0 -45
- package/actions/plan-hook.ts +0 -20
- package/actions/webhook-submit.ts +0 -22
- package/deno.json +0 -24
- package/deno.lock +0 -1777
- package/openapi.json +0 -248
package/.github/workflows/ci.yml
CHANGED
|
@@ -11,7 +11,7 @@ permissions:
|
|
|
11
11
|
|
|
12
12
|
jobs:
|
|
13
13
|
typecheck:
|
|
14
|
-
name: typecheck (Node
|
|
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:
|
|
57
|
-
|
|
58
|
-
# Runs the
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
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`, `
|
|
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`)
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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`).
|
|
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 /
|
|
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/
|
|
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 /
|
|
248
|
-
| `NANO_PR_WEBHOOK_SECRET` | — | shared secret for `POST /hooks/
|
|
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 #
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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/
|
|
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` | — |
|
|
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
|
-
|
|
448
|
-
|
|
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
|
package/app/abandon.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Tests for the cooperative abandon-check helpers (issue #76).
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/app/baseGuard.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the dead-end-base guard decision (#60).
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/app/blackboard.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" })));
|