@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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Tests for GET /app/api/version → operation `getVersion` (ADR 0058 OpenAPI surface).
|
|
2
2
|
// Ported from the previous actions/version.test.ts. Method handling now belongs to the router
|
|
3
3
|
// (only GET is routed here), so there is no 405 case to test at the delegate level.
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
5
6
|
import type { AppApi } from "@nanobpm/urban";
|
|
6
7
|
import handler from "./getVersion.ts";
|
|
7
8
|
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
9
|
const app = {} as any as AppApi;
|
|
10
10
|
|
|
11
11
|
function input(headers: Record<string, string> = {}) {
|
|
@@ -16,7 +16,6 @@ function input(headers: Record<string, string> = {}) {
|
|
|
16
16
|
query: new URLSearchParams(),
|
|
17
17
|
headers: new Headers(headers),
|
|
18
18
|
text: async () => "",
|
|
19
|
-
// deno-lint-ignore no-explicit-any
|
|
20
19
|
} as any,
|
|
21
20
|
params: {},
|
|
22
21
|
query: {},
|
|
@@ -24,9 +23,8 @@ function input(headers: Record<string, string> = {}) {
|
|
|
24
23
|
};
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
test("returns 200 with the app identity", async () => {
|
|
28
27
|
const res = await handler(input(), app);
|
|
29
|
-
// deno-lint-ignore no-explicit-any
|
|
30
28
|
const r = res as any;
|
|
31
29
|
assertEquals(r.status, 200);
|
|
32
30
|
assertEquals(r.body.name, "nano-workforce");
|
|
@@ -40,21 +38,19 @@ Deno.test("returns 200 with the app identity", async () => {
|
|
|
40
38
|
assert(typeof r.body.uptimeSeconds === "number");
|
|
41
39
|
});
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
const prev =
|
|
45
|
-
|
|
41
|
+
test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
42
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
43
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
46
44
|
try {
|
|
47
45
|
// SECRET is bound at import time, so import a cache-busted copy to observe the guard.
|
|
48
46
|
const mod = await import(`./getVersion.ts?guard=${Date.now()}`);
|
|
49
47
|
const guarded = mod.default as typeof handler;
|
|
50
|
-
// deno-lint-ignore no-explicit-any
|
|
51
48
|
const bad = (await guarded(input(), app)) as any;
|
|
52
49
|
assertEquals(bad.status, 401);
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
50
|
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
55
51
|
assertEquals(ok.status, 200);
|
|
56
52
|
} finally {
|
|
57
|
-
if (prev === undefined)
|
|
58
|
-
else
|
|
53
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
54
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
59
55
|
}
|
|
60
56
|
});
|
package/operations/getVersion.ts
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
import { defineOperation } from "@nanobpm/urban";
|
|
10
10
|
import { buildVersionInfo, envVar, type VersionInfo } from "../app/version.ts";
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
|
|
12
|
+
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
13
|
+
// the x-hook-secret header. Captured once, at module load.
|
|
15
14
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
16
15
|
|
|
17
16
|
export default defineOperation<
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
// Tests for GET /app/api/status → operation `listActivePrs` (ADR 0058 OpenAPI surface).
|
|
2
2
|
// Covers the happy path (count/prs projection) and the optional shared-secret guard. A minimal
|
|
3
3
|
// in-memory DataLayer backs `activePrs` (it reads the `pull_requests` table via `.all()`).
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
5
6
|
import type { AppApi } from "@nanobpm/urban";
|
|
6
7
|
import handler from "./listActivePrs.ts";
|
|
7
8
|
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
9
|
function memApp(rows: any[]): AppApi {
|
|
10
10
|
const tbl = {
|
|
11
|
-
// deno-lint-ignore require-await
|
|
12
11
|
async all() {
|
|
13
12
|
return rows;
|
|
14
13
|
},
|
|
15
14
|
};
|
|
16
|
-
// deno-lint-ignore no-explicit-any
|
|
17
15
|
return { data: { table: () => tbl } } as any as AppApi;
|
|
18
16
|
}
|
|
19
17
|
|
|
@@ -25,7 +23,6 @@ function input(headers: Record<string, string> = {}) {
|
|
|
25
23
|
query: new URLSearchParams(),
|
|
26
24
|
headers: new Headers(headers),
|
|
27
25
|
text: async () => "",
|
|
28
|
-
// deno-lint-ignore no-explicit-any
|
|
29
26
|
} as any,
|
|
30
27
|
params: {},
|
|
31
28
|
query: {},
|
|
@@ -33,13 +30,12 @@ function input(headers: Record<string, string> = {}) {
|
|
|
33
30
|
};
|
|
34
31
|
}
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
test("returns 200 with a count + projected active PRs", async () => {
|
|
37
34
|
const app = memApp([
|
|
38
35
|
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", title: "t", status: "converging", current_round: 2, process_key: "9", updated_at: "2026-01-02" },
|
|
39
36
|
{ pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", title: null, status: "converged", current_round: 1, process_key: null, updated_at: "2026-01-01" },
|
|
40
37
|
]);
|
|
41
38
|
const res = await handler(input(), app);
|
|
42
|
-
// deno-lint-ignore no-explicit-any
|
|
43
39
|
const r = res as any;
|
|
44
40
|
assertEquals(r.status, 200);
|
|
45
41
|
// `converged` is terminal → filtered out, leaving one active PR.
|
|
@@ -49,22 +45,20 @@ Deno.test("returns 200 with a count + projected active PRs", async () => {
|
|
|
49
45
|
assertEquals(r.body.prs[0].processKey, "9");
|
|
50
46
|
});
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
const prev =
|
|
54
|
-
|
|
48
|
+
test("shared-secret guard rejects a missing secret when configured", async () => {
|
|
49
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
50
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
55
51
|
try {
|
|
56
52
|
const mod = await import(`./listActivePrs.ts?guard=${Date.now()}`);
|
|
57
53
|
const guarded = mod.default as typeof handler;
|
|
58
54
|
const app = memApp([]);
|
|
59
|
-
// deno-lint-ignore no-explicit-any
|
|
60
55
|
const bad = (await guarded(input(), app)) as any;
|
|
61
56
|
assertEquals(bad.status, 401);
|
|
62
|
-
// deno-lint-ignore no-explicit-any
|
|
63
57
|
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
64
58
|
assertEquals(ok.status, 200);
|
|
65
59
|
assert("count" in ok.body);
|
|
66
60
|
} finally {
|
|
67
|
-
if (prev === undefined)
|
|
68
|
-
else
|
|
61
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
62
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
69
63
|
}
|
|
70
64
|
});
|
|
@@ -10,9 +10,8 @@ import { defineOperation } from "@nanobpm/urban";
|
|
|
10
10
|
import { type ActivePr, activePrs } from "../app/service.ts";
|
|
11
11
|
import { envVar } from "../app/version.ts";
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
|
|
13
|
+
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
14
|
+
// the x-hook-secret header. Captured once, at module load.
|
|
16
15
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
17
16
|
|
|
18
17
|
type Res = { count: number; prs: ActivePr[] } | { error: string };
|
|
@@ -2,19 +2,17 @@
|
|
|
2
2
|
// These cover the app-logic guards the JSON schema can't express (reference parsing, message-name
|
|
3
3
|
// dispatch); the runtime's schema validation (required `variables`/`name`) is exercised by urban's
|
|
4
4
|
// own api runtime tests.
|
|
5
|
-
import {
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
6
7
|
import type { AppApi } from "@nanobpm/urban";
|
|
7
8
|
import startConvergenceLoop from "./startConvergenceLoop.ts";
|
|
8
9
|
import startPlanFanout from "./startPlanFanout.ts";
|
|
9
10
|
import postMessage from "./postMessage.ts";
|
|
10
11
|
|
|
11
|
-
// deno-lint-ignore no-explicit-any
|
|
12
12
|
const app = {} as any as AppApi;
|
|
13
13
|
|
|
14
|
-
// deno-lint-ignore no-explicit-any
|
|
15
14
|
function input(body: any) {
|
|
16
15
|
return {
|
|
17
|
-
// deno-lint-ignore no-explicit-any
|
|
18
16
|
req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
|
|
19
17
|
params: {},
|
|
20
18
|
query: {},
|
|
@@ -22,33 +20,29 @@ function input(body: any) {
|
|
|
22
20
|
};
|
|
23
21
|
}
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
|
|
26
24
|
const res = await startConvergenceLoop(input({ variables: { pr: "not a pr" } }), app);
|
|
27
|
-
// deno-lint-ignore no-explicit-any
|
|
28
25
|
const r = res as any;
|
|
29
26
|
assertEquals(r.status, 400);
|
|
30
27
|
assertEquals(typeof r.body.error, "string");
|
|
31
28
|
});
|
|
32
29
|
|
|
33
|
-
|
|
30
|
+
test("startPlanFanout → 400 on an unparseable issue reference", async () => {
|
|
34
31
|
const res = await startPlanFanout(input({ variables: { issue: "" } }), app);
|
|
35
|
-
// deno-lint-ignore no-explicit-any
|
|
36
32
|
const r = res as any;
|
|
37
33
|
assertEquals(r.status, 400);
|
|
38
34
|
assertEquals(typeof r.body.error, "string");
|
|
39
35
|
});
|
|
40
36
|
|
|
41
|
-
|
|
37
|
+
test("postMessage → 400 when name is blank", async () => {
|
|
42
38
|
const res = await postMessage(input({ name: "" }), app);
|
|
43
|
-
// deno-lint-ignore no-explicit-any
|
|
44
39
|
const r = res as any;
|
|
45
40
|
assertEquals(r.status, 400);
|
|
46
41
|
assertEquals(r.body.error, "name is required");
|
|
47
42
|
});
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
test("postMessage → 400 when escalation-answered lacks a correlationKey", async () => {
|
|
50
45
|
const res = await postMessage(input({ name: "escalation-answered", variables: { answer: "yes" } }), app);
|
|
51
|
-
// deno-lint-ignore no-explicit-any
|
|
52
46
|
const r = res as any;
|
|
53
47
|
assertEquals(r.status, 400);
|
|
54
48
|
assertEquals(r.body.error, "correlationKey is required");
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
7
|
+
"imports": {
|
|
8
|
+
"#test-assert": "./test/assert.ts"
|
|
9
|
+
},
|
|
7
10
|
"engines": {
|
|
8
11
|
"node": ">=22.6"
|
|
9
12
|
},
|
|
@@ -30,14 +33,13 @@
|
|
|
30
33
|
"upgrade": "node --experimental-strip-types scripts/upgrade-from-pack.ts",
|
|
31
34
|
"check": "urban check",
|
|
32
35
|
"typecheck": "tsc --noEmit",
|
|
33
|
-
"check:prompts": "
|
|
36
|
+
"check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
|
|
34
37
|
"gen": "urban gen",
|
|
35
38
|
"gen:check": "urban gen --check",
|
|
36
39
|
"layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
|
|
37
40
|
"layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
|
|
38
41
|
"dev": "urban dev",
|
|
39
|
-
"
|
|
40
|
-
"test": "deno test -A",
|
|
42
|
+
"test": "node --experimental-strip-types --test",
|
|
41
43
|
"lint": "biome check app operations actions workers pages components scripts main.ts",
|
|
42
44
|
"lint:fix": "biome check --write app operations actions workers pages components scripts main.ts"
|
|
43
45
|
},
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
// blank agent-prompt header — ships an effectively prompt-less agent (the root of the empty
|
|
5
5
|
// "(no question provided)" escalations on Magikcraft/nano-bpm #597/#599). These cases assert it
|
|
6
6
|
// fails on each of those shapes and passes on a well-formed app.
|
|
7
|
-
import {
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
8
12
|
import { checkAgentPrompts } from "./check-agent-prompts.ts";
|
|
9
13
|
|
|
10
14
|
const MANIFEST = JSON.stringify({
|
|
@@ -16,17 +20,17 @@ function header(value: string): string {
|
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
// Build a throwaway app tree and return its root. Each entry maps a repo-relative path to content.
|
|
19
|
-
|
|
20
|
-
const root =
|
|
23
|
+
function fixture(files: Record<string, string>): string {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), "agent-prompts-"));
|
|
21
25
|
for (const [rel, content] of Object.entries(files)) {
|
|
22
|
-
const abs =
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
const abs = join(root, rel);
|
|
27
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
28
|
+
writeFileSync(abs, content);
|
|
25
29
|
}
|
|
26
30
|
return root;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
test("passes when every {{token}} resolves to a non-blank template", async () => {
|
|
30
34
|
const root = await fixture({
|
|
31
35
|
"nano.app.json": MANIFEST,
|
|
32
36
|
"resources/processes/loop.bpmn": header("{{review-round}}"),
|
|
@@ -38,7 +42,7 @@ Deno.test("passes when every {{token}} resolves to a non-blank template", async
|
|
|
38
42
|
assertEquals(res.resolved, ["review-round"]);
|
|
39
43
|
});
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
test("fails when a header references an undeclared template", async () => {
|
|
42
46
|
const root = await fixture({
|
|
43
47
|
"nano.app.json": MANIFEST,
|
|
44
48
|
"resources/processes/loop.bpmn": header("{{does-not-exist}}"),
|
|
@@ -49,7 +53,7 @@ Deno.test("fails when a header references an undeclared template", async () => {
|
|
|
49
53
|
assert(res.errors.some((e) => e.includes("{{does-not-exist}}") && e.includes("no such template")));
|
|
50
54
|
});
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
test("fails when the referenced template file is blank (would substitute to nothing)", async () => {
|
|
53
57
|
const root = await fixture({
|
|
54
58
|
"nano.app.json": MANIFEST,
|
|
55
59
|
"resources/processes/loop.bpmn": header("{{review-round}}"),
|
|
@@ -60,7 +64,7 @@ Deno.test("fails when the referenced template file is blank (would substitute to
|
|
|
60
64
|
assert(res.errors.some((e) => e.includes("empty") && e.includes("review-round")));
|
|
61
65
|
});
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
test("fails when a reserved agent-prompt header is blank", async () => {
|
|
64
68
|
const root = await fixture({
|
|
65
69
|
"nano.app.json": MANIFEST,
|
|
66
70
|
"resources/processes/loop.bpmn": header(""),
|
|
@@ -71,7 +75,7 @@ Deno.test("fails when a reserved agent-prompt header is blank", async () => {
|
|
|
71
75
|
assert(res.errors.some((e) => e.includes("is empty")));
|
|
72
76
|
});
|
|
73
77
|
|
|
74
|
-
|
|
78
|
+
test("checks the real repo: all committed agent prompts resolve", () => {
|
|
75
79
|
// The guard must be green against the actual app it protects — this is the case CI relies on.
|
|
76
80
|
const repoRoot = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
77
81
|
const res = checkAgentPrompts(repoRoot);
|
package/scripts/layout-bpmn.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// npm run layout <file.bpmn ...>
|
|
1
|
+
// npm run layout <file.bpmn ...> — (re)generate the
|
|
2
2
|
// bpmndi:BPMNDiagram for one or more BPMN models using the urban toolkit's `layoutBpmn`
|
|
3
3
|
// (bpmn-auto-layout). The semantic model stays authoritative: author the process elements
|
|
4
4
|
// (tasks, gateways, flows, zeebe extensions) and run this to derive an auto-laid-out diagram,
|
|
@@ -8,39 +8,18 @@
|
|
|
8
8
|
// `--check` (npm run layout:check) regenerates the DI in memory and fails with a non-zero exit
|
|
9
9
|
// if any committed diagram is stale, WITHOUT rewriting files — the CI freshness gate that stops
|
|
10
10
|
// a BPMN flow change from merging with an un-regenerated diagram.
|
|
11
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
11
12
|
import { layoutBpmn } from "@nanobpm/urban";
|
|
12
13
|
|
|
13
|
-
// Host-agnostic file I/O: Deno inside a compiled binary, else node:fs under Node — mirrors
|
|
14
|
-
// app/plan.ts's readAsset seam so this runs the same under `npm run` and `deno task`.
|
|
15
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
16
|
-
const g = globalThis as {
|
|
17
|
-
Deno?: {
|
|
18
|
-
args: string[];
|
|
19
|
-
exit(c: number): never;
|
|
20
|
-
readDir(p: string): AsyncIterable<{ name: string; isFile: boolean }>;
|
|
21
|
-
readTextFile(p: string): Promise<string>;
|
|
22
|
-
writeTextFile(p: string, s: string): Promise<void>;
|
|
23
|
-
};
|
|
24
|
-
};
|
|
25
|
-
|
|
26
14
|
async function readText(path: string): Promise<string> {
|
|
27
|
-
return
|
|
28
|
-
? await g.Deno.readTextFile(path)
|
|
29
|
-
: await (await import("node:fs/promises")).readFile(path, "utf8");
|
|
15
|
+
return await readFile(path, "utf8");
|
|
30
16
|
}
|
|
31
17
|
async function writeText(path: string, text: string): Promise<void> {
|
|
32
|
-
|
|
33
|
-
await (await import("node:fs/promises")).writeFile(path, text, "utf8");
|
|
18
|
+
await writeFile(path, text, "utf8");
|
|
34
19
|
}
|
|
35
20
|
async function defaultProcessFiles(): Promise<string[]> {
|
|
36
21
|
const dir = "resources/processes";
|
|
37
|
-
|
|
38
|
-
const files: string[] = [];
|
|
39
|
-
for await (const e of g.Deno.readDir(dir)) if (e.isFile && e.name.endsWith(".bpmn")) files.push(`${dir}/${e.name}`);
|
|
40
|
-
return files.sort();
|
|
41
|
-
}
|
|
42
|
-
const fs = await import("node:fs/promises");
|
|
43
|
-
return (await fs.readdir(dir, { withFileTypes: true }))
|
|
22
|
+
return (await readdir(dir, { withFileTypes: true }))
|
|
44
23
|
.filter((e) => e.isFile() && e.name.endsWith(".bpmn"))
|
|
45
24
|
.map((e) => `${dir}/${e.name}`)
|
|
46
25
|
.sort();
|
|
@@ -54,12 +33,11 @@ const countDi = (xml: string) => ({
|
|
|
54
33
|
});
|
|
55
34
|
|
|
56
35
|
function exit(code: number): never {
|
|
57
|
-
if (g.Deno) return g.Deno.exit(code);
|
|
58
36
|
process.exit(code);
|
|
59
37
|
}
|
|
60
38
|
|
|
61
39
|
async function main() {
|
|
62
|
-
const argv =
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
63
41
|
// `--check` mode: regenerate the DI in memory and fail (non-zero) if it differs from what's
|
|
64
42
|
// committed, WITHOUT rewriting any file. This is the CI freshness gate — it catches a BPMN
|
|
65
43
|
// flow change whose author forgot to re-run `npm run layout`, so a stale diagram can't merge.
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// It also pins the issue #87 surfaces: the plan-review audit log (`plan_reviews`) — which is
|
|
11
11
|
// persisted but was surfaced on no page — must appear on the epic page (flat grid) and inside the
|
|
12
12
|
// home page's plan detail (child grid). Feature coverage so the trace can't silently regress out.
|
|
13
|
-
import {
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert } from "#test-assert";
|
|
15
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
14
16
|
|
|
15
17
|
// Percent-decode the pathname: `new URL(..).pathname` can contain encoded characters (e.g. a space
|
|
16
18
|
// as `%20`), which `Deno.readDir`/`readTextFile` would fail to resolve. Matches the repo convention
|
|
@@ -79,22 +81,21 @@ function splitTopLevel(body: string): string[] {
|
|
|
79
81
|
return out;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
function loadSchema(): Map<string, Set<string>> {
|
|
83
85
|
const schema = new Map<string, Set<string>>();
|
|
84
86
|
const files: string[] = [];
|
|
85
|
-
for
|
|
86
|
-
if (e.isFile && e.name.endsWith(".sql")) files.push(e.name);
|
|
87
|
+
for (const e of readdirSync(`${ROOT}db/migrations`, { withFileTypes: true })) {
|
|
88
|
+
if (e.isFile() && e.name.endsWith(".sql")) files.push(e.name);
|
|
87
89
|
}
|
|
88
90
|
files.sort(); // migration order doesn't matter for the union, but keep it deterministic
|
|
89
91
|
for (const f of files) {
|
|
90
|
-
parseSchema(
|
|
92
|
+
parseSchema(readFileSync(`${ROOT}db/migrations/${f}`, "utf8"), schema);
|
|
91
93
|
}
|
|
92
94
|
return schema;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
// ---- pages -> datasource references -------------------------------------------------------------
|
|
96
98
|
|
|
97
|
-
// deno-lint-ignore no-explicit-any
|
|
98
99
|
type Json = any;
|
|
99
100
|
|
|
100
101
|
interface Ref {
|
|
@@ -164,11 +165,11 @@ function collectRefs(page: string, node: Json, out: Ref[]): void {
|
|
|
164
165
|
for (const v of Object.values(node)) collectRefs(page, v, out);
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
function loadRefs(): Ref[] {
|
|
168
169
|
const refs: Ref[] = [];
|
|
169
|
-
for
|
|
170
|
-
if (!e.isFile || !e.name.endsWith(".page.json")) continue;
|
|
171
|
-
const page = JSON.parse(
|
|
170
|
+
for (const e of readdirSync(`${ROOT}pages`, { withFileTypes: true })) {
|
|
171
|
+
if (!e.isFile() || !e.name.endsWith(".page.json")) continue;
|
|
172
|
+
const page = JSON.parse(readFileSync(`${ROOT}pages/${e.name}`, "utf8"));
|
|
172
173
|
collectRefs(e.name, page, refs);
|
|
173
174
|
}
|
|
174
175
|
return refs;
|
|
@@ -176,7 +177,7 @@ async function loadRefs(): Promise<Ref[]> {
|
|
|
176
177
|
|
|
177
178
|
// ---- guards -------------------------------------------------------------------------------------
|
|
178
179
|
|
|
179
|
-
|
|
180
|
+
test("every page datasource table exists in the migrations", async () => {
|
|
180
181
|
const schema = await loadSchema();
|
|
181
182
|
const refs = await loadRefs();
|
|
182
183
|
assert(refs.length > 0, "no datasource references found — collector or pages are broken");
|
|
@@ -190,7 +191,7 @@ Deno.test("every page datasource table exists in the migrations", async () => {
|
|
|
190
191
|
}
|
|
191
192
|
});
|
|
192
193
|
|
|
193
|
-
|
|
194
|
+
test("every page datasource column exists on its table", async () => {
|
|
194
195
|
const schema = await loadSchema();
|
|
195
196
|
const refs = await loadRefs();
|
|
196
197
|
for (const r of refs) {
|
|
@@ -206,7 +207,7 @@ Deno.test("every page datasource column exists on its table", async () => {
|
|
|
206
207
|
}
|
|
207
208
|
});
|
|
208
209
|
|
|
209
|
-
|
|
210
|
+
test("issue #87: plan_reviews is surfaced on the epic and home pages", async () => {
|
|
210
211
|
const refs = await loadRefs();
|
|
211
212
|
const onEpic = refs.some((r) => r.page === "epic.page.json" && r.table === "plan_reviews");
|
|
212
213
|
const onHome = refs.some((r) => r.page === "home.page.json" && r.table === "plan_reviews");
|
package/scripts/purge-db.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// npm run purge
|
|
1
|
+
// npm run purge — wipe the app's sqlite datasource so `npm start`
|
|
2
2
|
// comes up against a fresh schema (the runtime re-applies db/migrations on boot). Deletes the
|
|
3
3
|
// sqlite file and its WAL/SHM sidecars for the `app` source declared in nano.app.json.
|
|
4
4
|
import { rmSync } from "node:fs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// npm run upgrade
|
|
1
|
+
// npm run upgrade — refresh THIS app's source from a newer
|
|
2
2
|
// published pack of @nanobpm/nano-workforce, WITHOUT touching your data.
|
|
3
3
|
//
|
|
4
4
|
// Why this exists: a Console project stamped from the example pack is a one-time
|
package/test/assert.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Node-native test assertions, presenting the small `@std/assert` surface this repo's suite used
|
|
2
|
+
// under Deno so the 35 ported `*.test.ts` files keep their call sites unchanged (only their import
|
|
3
|
+
// line moved to `#test-assert`). Backed by `node:assert/strict`. Semantics intentionally mirror
|
|
4
|
+
// Deno's std/assert (deep structural equality; the throw helpers return the caught error).
|
|
5
|
+
import nodeAssert from "node:assert/strict";
|
|
6
|
+
|
|
7
|
+
/** Deep structural equality (Deno `assertEquals`). */
|
|
8
|
+
export function assertEquals<T>(actual: T, expected: T, msg?: string): void {
|
|
9
|
+
nodeAssert.deepStrictEqual(actual, expected, msg);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Deep structural inequality (Deno `assertNotEquals`). */
|
|
13
|
+
export function assertNotEquals<T>(actual: T, expected: T, msg?: string): void {
|
|
14
|
+
nodeAssert.notDeepStrictEqual(actual, expected, msg);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Truthiness (Deno `assert`). */
|
|
18
|
+
export function assert(expr: unknown, msg?: string): asserts expr {
|
|
19
|
+
nodeAssert.ok(expr, msg);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Substring containment (Deno `assertStringIncludes`). */
|
|
23
|
+
export function assertStringIncludes(actual: string, expected: string, msg?: string): void {
|
|
24
|
+
nodeAssert.ok(
|
|
25
|
+
actual.includes(expected),
|
|
26
|
+
msg ?? `expected string to contain "${expected}" but got "${actual}"`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The constructor rest is `any[]` so error subclasses with required args (e.g. WaveError(message))
|
|
31
|
+
// satisfy the type — param contravariance rejects a narrower `unknown[]`/`never[]` here.
|
|
32
|
+
type ErrorClass = new (...args: any[]) => Error;
|
|
33
|
+
|
|
34
|
+
function checkError(error: unknown, ErrorClass?: ErrorClass, msgIncludes?: string): Error {
|
|
35
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
36
|
+
const gotName = err.constructor.name;
|
|
37
|
+
if (ErrorClass && !(err instanceof ErrorClass)) {
|
|
38
|
+
nodeAssert.fail(`expected error to be instance of ${ErrorClass.name}, got ${gotName}`);
|
|
39
|
+
}
|
|
40
|
+
if (msgIncludes && !err.message.includes(msgIncludes)) {
|
|
41
|
+
nodeAssert.fail(`expected error message to include "${msgIncludes}", got "${err.message}"`);
|
|
42
|
+
}
|
|
43
|
+
return err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Assert a sync fn throws; optionally check the error type/message. Returns the caught error
|
|
47
|
+
* (Deno `assertThrows`). */
|
|
48
|
+
export function assertThrows(
|
|
49
|
+
fn: () => unknown,
|
|
50
|
+
ErrorClass?: ErrorClass,
|
|
51
|
+
msgIncludes?: string,
|
|
52
|
+
): Error {
|
|
53
|
+
try {
|
|
54
|
+
fn();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return checkError(error, ErrorClass, msgIncludes);
|
|
57
|
+
}
|
|
58
|
+
nodeAssert.fail("expected function to throw, but it did not");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Assert an async fn rejects; optionally check the error type/message. Returns the caught error
|
|
62
|
+
* (Deno `assertRejects`). */
|
|
63
|
+
export async function assertRejects(
|
|
64
|
+
fn: () => Promise<unknown>,
|
|
65
|
+
ErrorClass?: ErrorClass,
|
|
66
|
+
msgIncludes?: string,
|
|
67
|
+
): Promise<Error> {
|
|
68
|
+
try {
|
|
69
|
+
await fn();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return checkError(error, ErrorClass, msgIncludes);
|
|
72
|
+
}
|
|
73
|
+
nodeAssert.fail("expected promise to reject, but it resolved");
|
|
74
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -37,11 +37,14 @@
|
|
|
37
37
|
},
|
|
38
38
|
"include": [
|
|
39
39
|
"main.ts",
|
|
40
|
+
"app/**/*.ts",
|
|
41
|
+
"operations/**/*.ts",
|
|
40
42
|
"workers/**/*.ts",
|
|
41
43
|
"lib/**/*.ts",
|
|
42
44
|
"src/**/*.ts",
|
|
43
45
|
"scripts/**/*.ts",
|
|
44
|
-
"actions/**/*.ts"
|
|
46
|
+
"actions/**/*.ts",
|
|
47
|
+
"test/**/*.ts"
|
|
45
48
|
],
|
|
46
49
|
"exclude": [
|
|
47
50
|
"node_modules",
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// plan was empty (e.g. the planner agent couldn't persist its result) — completed the whole epic
|
|
6
6
|
// GREEN having done nothing (instance 21). We now HARD-FAIL: the terminal, unapproved round raises
|
|
7
7
|
// a non-retryable `PLAN_REJECTED` BpmnError (→ incident), so an un-approved plan never dispatches.
|
|
8
|
-
import {
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
9
10
|
import { BpmnError } from "@nanobpm/urban";
|
|
10
11
|
import handler from "./worker.ts";
|
|
11
12
|
import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
|
|
@@ -18,9 +19,7 @@ function fakeApp(existing: PlanReview[] = []) {
|
|
|
18
19
|
data: {
|
|
19
20
|
table() {
|
|
20
21
|
return {
|
|
21
|
-
// deno-lint-ignore no-explicit-any
|
|
22
22
|
findOne: (q: any) => Promise.resolve(rows.find((r) => match(r, q)) ?? null),
|
|
23
|
-
// deno-lint-ignore no-explicit-any
|
|
24
23
|
count: (q: any) => Promise.resolve(rows.filter((r) => match(r, q)).length),
|
|
25
24
|
insert: (row: PlanReview) => {
|
|
26
25
|
rows.push(row);
|
|
@@ -31,7 +30,6 @@ function fakeApp(existing: PlanReview[] = []) {
|
|
|
31
30
|
},
|
|
32
31
|
log: () => {},
|
|
33
32
|
_rows: rows,
|
|
34
|
-
// deno-lint-ignore no-explicit-any
|
|
35
33
|
} as any;
|
|
36
34
|
}
|
|
37
35
|
|
|
@@ -48,16 +46,15 @@ function priorRounds(planKey: string, n: number): PlanReview[] {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
const call = async (app: unknown, vars: Record<string, unknown>, jobKey = "j-new") =>
|
|
51
|
-
// deno-lint-ignore no-explicit-any
|
|
52
49
|
await handler({ variables: vars, jobKey } as any, app as any);
|
|
53
50
|
|
|
54
|
-
|
|
51
|
+
test("approved round proceeds (planApproved=true, no throw)", async () => {
|
|
55
52
|
const app = fakeApp(priorRounds("o/r#1", 0));
|
|
56
53
|
const out = await call(app, { planKey: "o/r#1", approved: true });
|
|
57
54
|
assertEquals((out as { planApproved: boolean }).planApproved, true);
|
|
58
55
|
});
|
|
59
56
|
|
|
60
|
-
|
|
57
|
+
test("unapproved, non-final round revises (planApproved=false, no throw)", async () => {
|
|
61
58
|
// First round of a 3-round cap: not final, so revise.
|
|
62
59
|
const app = fakeApp(priorRounds("o/r#2", 0));
|
|
63
60
|
const out = await call(app, { planKey: "o/r#2", approved: false, findings: "fix X" });
|
|
@@ -65,7 +62,7 @@ Deno.test("unapproved, non-final round revises (planApproved=false, no throw)",
|
|
|
65
62
|
assertEquals((out as { planFindings: string }).planFindings, "fix X");
|
|
66
63
|
});
|
|
67
64
|
|
|
68
|
-
|
|
65
|
+
test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async () => {
|
|
69
66
|
// Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ must throw.
|
|
70
67
|
const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
|
|
71
68
|
const err = await assertRejects(
|
|
@@ -75,7 +72,7 @@ Deno.test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async
|
|
|
75
72
|
assertEquals((err as BpmnError).errorCode, "PLAN_REJECTED");
|
|
76
73
|
});
|
|
77
74
|
|
|
78
|
-
|
|
75
|
+
test("approved on the FINAL round still proceeds (no throw)", async () => {
|
|
79
76
|
const app = fakeApp(priorRounds("o/r#4", MAX_PLAN_REVIEW_ROUNDS - 1));
|
|
80
77
|
const out = await call(app, { planKey: "o/r#4", approved: true });
|
|
81
78
|
assertEquals((out as { planApproved: boolean }).planApproved, true);
|