@nanobpm/nano-workforce 0.36.0 → 0.37.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 +7 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- 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 +9 -30
- package/app/persist-round.test.ts +6 -19
- 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/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/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +2 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +6 -4
- 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/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/deno.json +0 -24
- package/deno.lock +0 -1777
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,10 @@
|
|
|
1
|
+
# [0.37.0](https://github.com/nanobpm/nano-workforce/compare/v0.36.0...v0.37.0) (2026-08-10)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* 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)
|
|
7
|
+
|
|
1
8
|
# [0.36.0](https://github.com/nanobpm/nano-workforce/compare/v0.35.2...v0.36.0) (2026-08-10)
|
|
2
9
|
|
|
3
10
|
|
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.
|
|
@@ -277,25 +276,6 @@ per-instance context (e.g. a human's escalation answer) is appended by the harne
|
|
|
277
276
|
|
|
278
277
|
---
|
|
279
278
|
|
|
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
279
|
## Architecture & contributing
|
|
300
280
|
|
|
301
281
|
- **[SPEC.md](SPEC.md)** — the behavioural source of truth for the processes.
|
package/actions/abandon.test.ts
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
// Tests for the GET /hooks/abandon endpoint (issue #76).
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assertEquals } from "#test-assert";
|
|
3
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
4
5
|
import handler from "./abandon.ts";
|
|
5
6
|
|
|
6
|
-
// deno-lint-ignore no-explicit-any
|
|
7
7
|
function memApp(): { app: AppApi } {
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
8
|
const stores: Record<string, any[]> = {};
|
|
10
9
|
function tbl(name: string) {
|
|
11
|
-
// deno-lint-ignore no-explicit-any
|
|
12
10
|
const rows = (stores[name] ??= [] as any[]);
|
|
13
11
|
return {
|
|
14
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
15
12
|
async insert(row: any) {
|
|
16
13
|
rows.push({ ...row });
|
|
17
14
|
return row.pr_key;
|
|
18
15
|
},
|
|
19
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
20
16
|
async findOne(where: any = {}) {
|
|
21
17
|
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
22
18
|
},
|
|
23
19
|
};
|
|
24
20
|
}
|
|
25
|
-
// deno-lint-ignore no-explicit-any
|
|
26
21
|
const app = { data: { table: (n: string) => tbl(n) } } as any as AppApi;
|
|
27
22
|
return { app };
|
|
28
23
|
}
|
|
@@ -38,9 +33,7 @@ function req(method: string, query: Record<string, string>) {
|
|
|
38
33
|
}
|
|
39
34
|
|
|
40
35
|
async function call(app: AppApi, method: string, query: Record<string, string>) {
|
|
41
|
-
// deno-lint-ignore no-explicit-any
|
|
42
36
|
const res = await handler({ req: req(method, query) as any, body: undefined }, app);
|
|
43
|
-
// deno-lint-ignore no-explicit-any
|
|
44
37
|
return res as any;
|
|
45
38
|
}
|
|
46
39
|
|
|
@@ -48,18 +41,18 @@ async function seedPr(app: AppApi, prKey: string, token: string, status: string)
|
|
|
48
41
|
await app.data.table("pull_requests", "pr_key").insert({ pr_key: prKey, abandon_token: token, status });
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
|
|
44
|
+
test("missing token → 400", async () => {
|
|
52
45
|
const { app } = memApp();
|
|
53
46
|
assertEquals((await call(app, "GET", {})).status, 400);
|
|
54
47
|
});
|
|
55
48
|
|
|
56
|
-
|
|
49
|
+
test("unknown token → 404 (does not reveal PRs)", async () => {
|
|
57
50
|
const { app } = memApp();
|
|
58
51
|
await seedPr(app, "o/r#1", "good", "converging");
|
|
59
52
|
assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
|
|
60
53
|
});
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
test("a running PR → { abandoned: false }", async () => {
|
|
63
56
|
const { app } = memApp();
|
|
64
57
|
await seedPr(app, "o/r#1", "tok", "converging");
|
|
65
58
|
const res = await call(app, "GET", { token: "tok" });
|
|
@@ -67,7 +60,7 @@ Deno.test("a running PR → { abandoned: false }", async () => {
|
|
|
67
60
|
assertEquals(res.body, { prKey: "o/r#1", status: "converging", abandoned: false });
|
|
68
61
|
});
|
|
69
62
|
|
|
70
|
-
|
|
63
|
+
test("a cancelled PR → { abandoned: true }", async () => {
|
|
71
64
|
const { app } = memApp();
|
|
72
65
|
await seedPr(app, "o/r#1", "tok", "abandoned");
|
|
73
66
|
const res = await call(app, "GET", { token: "tok" });
|
|
@@ -75,18 +68,17 @@ Deno.test("a cancelled PR → { abandoned: true }", async () => {
|
|
|
75
68
|
assertEquals(res.body.abandoned, true);
|
|
76
69
|
});
|
|
77
70
|
|
|
78
|
-
|
|
71
|
+
test("token via header is accepted", async () => {
|
|
79
72
|
const { app } = memApp();
|
|
80
73
|
await seedPr(app, "o/r#1", "tok", "abandoned");
|
|
81
74
|
const r = req("GET", {});
|
|
82
75
|
r.headers.set("x-abandon-token", "tok");
|
|
83
|
-
// deno-lint-ignore no-explicit-any
|
|
84
76
|
const res = await handler({ req: r as any, body: undefined }, app) as any;
|
|
85
77
|
assertEquals(res.status, 200);
|
|
86
78
|
assertEquals(res.body.abandoned, true);
|
|
87
79
|
});
|
|
88
80
|
|
|
89
|
-
|
|
81
|
+
test("non-GET → 405", async () => {
|
|
90
82
|
const { app } = memApp();
|
|
91
83
|
await seedPr(app, "o/r#1", "tok", "converging");
|
|
92
84
|
assertEquals((await call(app, "POST", { token: "tok" })).status, 405);
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
// Tests for the /hooks/blackboard endpoint (Tier 1, issues #51 / #49 D4).
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assertEquals } from "#test-assert";
|
|
3
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
4
5
|
import handler from "./blackboard.ts";
|
|
5
6
|
|
|
6
|
-
// deno-lint-ignore no-explicit-any
|
|
7
7
|
function memApp(): { app: AppApi; stores: Record<string, any[]> } {
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
8
|
const stores: Record<string, any[]> = {};
|
|
10
9
|
const seq: Record<string, number> = {};
|
|
11
10
|
function tbl(name: string, pk = "id") {
|
|
12
|
-
// deno-lint-ignore no-explicit-any
|
|
13
11
|
const rows = (stores[name] ??= [] as any[]);
|
|
14
12
|
return {
|
|
15
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
16
13
|
async insert(row: any) {
|
|
17
14
|
if (pk === "id") {
|
|
18
15
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
@@ -22,17 +19,14 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
|
|
|
22
19
|
rows.push({ ...row });
|
|
23
20
|
return row[pk];
|
|
24
21
|
},
|
|
25
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
26
22
|
async find(where: any = {}) {
|
|
27
23
|
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
28
24
|
},
|
|
29
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
30
25
|
async findOne(where: any = {}) {
|
|
31
26
|
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
32
27
|
},
|
|
33
28
|
};
|
|
34
29
|
}
|
|
35
|
-
// deno-lint-ignore no-explicit-any
|
|
36
30
|
const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) } } as any as AppApi;
|
|
37
31
|
return { app, stores };
|
|
38
32
|
}
|
|
@@ -53,9 +47,7 @@ async function call(
|
|
|
53
47
|
query: Record<string, string>,
|
|
54
48
|
body?: unknown,
|
|
55
49
|
) {
|
|
56
|
-
// deno-lint-ignore no-explicit-any
|
|
57
50
|
const res = await handler({ req: req(method, query) as any, body }, app);
|
|
58
|
-
// deno-lint-ignore no-explicit-any
|
|
59
51
|
return res as any;
|
|
60
52
|
}
|
|
61
53
|
|
|
@@ -63,18 +55,18 @@ async function seedPlan(app: AppApi, planKey: string, token: string) {
|
|
|
63
55
|
await app.data.table("plans", "plan_key").insert({ plan_key: planKey, blackboard_token: token });
|
|
64
56
|
}
|
|
65
57
|
|
|
66
|
-
|
|
58
|
+
test("missing token → 400", async () => {
|
|
67
59
|
const { app } = memApp();
|
|
68
60
|
assertEquals((await call(app, "GET", {})).status, 400);
|
|
69
61
|
});
|
|
70
62
|
|
|
71
|
-
|
|
63
|
+
test("unknown token → 404 (does not reveal plans)", async () => {
|
|
72
64
|
const { app } = memApp();
|
|
73
65
|
await seedPlan(app, "o/r#1", "good");
|
|
74
66
|
assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
|
|
75
67
|
});
|
|
76
68
|
|
|
77
|
-
|
|
69
|
+
test("POST appends then GET reads back, scoped by the token's plan", async () => {
|
|
78
70
|
const { app } = memApp();
|
|
79
71
|
await seedPlan(app, "o/r#1", "tok");
|
|
80
72
|
const post = await call(app, "POST", { token: "tok" }, {
|
|
@@ -94,13 +86,13 @@ Deno.test("POST appends then GET reads back, scoped by the token's plan", async
|
|
|
94
86
|
assertEquals(get.body.entries[0].kind, "file-claim");
|
|
95
87
|
});
|
|
96
88
|
|
|
97
|
-
|
|
89
|
+
test("POST with a blank body → 400", async () => {
|
|
98
90
|
const { app } = memApp();
|
|
99
91
|
await seedPlan(app, "o/r#1", "tok");
|
|
100
92
|
assertEquals((await call(app, "POST", { token: "tok" }, { body: " " })).status, 400);
|
|
101
93
|
});
|
|
102
94
|
|
|
103
|
-
|
|
95
|
+
test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
|
|
104
96
|
const { app, stores } = memApp();
|
|
105
97
|
await seedPlan(app, "o/r#1", "tok");
|
|
106
98
|
const body = { author_task: "t", body: "claim", dedupe_key: "t:claim:1" };
|
|
@@ -111,7 +103,7 @@ Deno.test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", a
|
|
|
111
103
|
assertEquals(stores["plan_blackboard"].length, 1);
|
|
112
104
|
});
|
|
113
105
|
|
|
114
|
-
|
|
106
|
+
test("GET ?since returns only newer entries", async () => {
|
|
115
107
|
const { app } = memApp();
|
|
116
108
|
await seedPlan(app, "o/r#1", "tok");
|
|
117
109
|
await call(app, "POST", { token: "tok" }, { body: "one" });
|
|
@@ -122,7 +114,7 @@ Deno.test("GET ?since returns only newer entries", async () => {
|
|
|
122
114
|
assertEquals(tail.body.entries.map((e: { body: string }) => e.body), ["two"]);
|
|
123
115
|
});
|
|
124
116
|
|
|
125
|
-
|
|
117
|
+
test("GET returns a cursor at the plan head for incremental polling (Tier 2)", async () => {
|
|
126
118
|
const { app } = memApp();
|
|
127
119
|
await seedPlan(app, "o/r#1", "tok");
|
|
128
120
|
await call(app, "POST", { token: "tok" }, { body: "one" });
|
|
@@ -135,7 +127,7 @@ Deno.test("GET returns a cursor at the plan head for incremental polling (Tier 2
|
|
|
135
127
|
assertEquals(caughtUp.body.cursor, all.body.cursor);
|
|
136
128
|
});
|
|
137
129
|
|
|
138
|
-
|
|
130
|
+
test("POST file-claim surfaces a sibling's prior claim as a conflict (advisory)", async () => {
|
|
139
131
|
const { app } = memApp();
|
|
140
132
|
await seedPlan(app, "o/r#1", "tok");
|
|
141
133
|
const first = await call(app, "POST", { token: "tok" }, {
|
|
@@ -158,7 +150,7 @@ Deno.test("POST file-claim surfaces a sibling's prior claim as a conflict (advis
|
|
|
158
150
|
assertEquals(second.body.conflicts[0].file, "engine/state.rs");
|
|
159
151
|
});
|
|
160
152
|
|
|
161
|
-
|
|
153
|
+
test("POST file-claim without author_task does not report the caller's own prior 'system' claim as a conflict", async () => {
|
|
162
154
|
const { app } = memApp();
|
|
163
155
|
await seedPlan(app, "o/r#1", "tok");
|
|
164
156
|
// First claim omits author_task → stored as "system".
|
|
@@ -181,14 +173,14 @@ Deno.test("POST file-claim without author_task does not report the caller's own
|
|
|
181
173
|
assertEquals(second.body.conflicts, [], "own prior 'system' claim is not a conflict");
|
|
182
174
|
});
|
|
183
175
|
|
|
184
|
-
|
|
176
|
+
test("POST a non-file-claim carries no conflicts", async () => {
|
|
185
177
|
const { app } = memApp();
|
|
186
178
|
await seedPlan(app, "o/r#1", "tok");
|
|
187
179
|
const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
|
|
188
180
|
assertEquals(res.body.conflicts, []);
|
|
189
181
|
});
|
|
190
182
|
|
|
191
|
-
|
|
183
|
+
test("unsupported method → 405", async () => {
|
|
192
184
|
const { app } = memApp();
|
|
193
185
|
await seedPlan(app, "o/r#1", "tok");
|
|
194
186
|
assertEquals((await call(app, "DELETE", { token: "tok" })).status, 405);
|
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,21 +50,21 @@ 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
56
|
"https://host/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);
|
|
@@ -77,7 +72,7 @@ Deno.test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage in
|
|
|
77
72
|
assertEquals(abandonTokenFromUrl("not a url"), undefined, "unparseable input never throws");
|
|
78
73
|
});
|
|
79
74
|
|
|
80
|
-
|
|
75
|
+
test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
|
|
81
76
|
const brief = renderAbandonBrief("https://host/hooks/abandon?token=tok");
|
|
82
77
|
assertEquals(brief.includes("https://host/hooks/abandon?token=tok"), true);
|
|
83
78
|
assertEquals(brief.includes("abandoned"), true);
|
|
@@ -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/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
|
});
|