@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.
Files changed (51) hide show
  1. package/.github/workflows/ci.yml +7 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -21
  5. package/actions/abandon.test.ts +8 -16
  6. package/actions/blackboard.test.ts +13 -21
  7. package/app/abandon.test.ts +10 -15
  8. package/app/baseGuard.test.ts +7 -6
  9. package/app/blackboard.test.ts +22 -32
  10. package/app/ensure-pr.test.ts +10 -12
  11. package/app/github.test.ts +9 -8
  12. package/app/github.ts +1 -21
  13. package/app/instance-tracking.test.ts +8 -6
  14. package/app/mergeExclusion.test.ts +11 -20
  15. package/app/mergeProtocol.test.ts +14 -13
  16. package/app/mergeRebaseArm.test.ts +8 -6
  17. package/app/mergeTrain.test.ts +10 -9
  18. package/app/persist-escalation.test.ts +9 -30
  19. package/app/persist-round.test.ts +6 -19
  20. package/app/plan.test.ts +19 -42
  21. package/app/record-plan-review.test.ts +8 -7
  22. package/app/retro.test.ts +24 -48
  23. package/app/reviewWait.test.ts +12 -11
  24. package/app/rounds.test.ts +13 -12
  25. package/app/service.test.ts +14 -27
  26. package/app/taskDelta.test.ts +8 -17
  27. package/app/trialMerge.test.ts +4 -3
  28. package/app/version.ts +6 -21
  29. package/app/waves.test.ts +17 -16
  30. package/operations/getVersion.test.ts +8 -12
  31. package/operations/getVersion.ts +2 -3
  32. package/operations/listActivePrs.test.ts +8 -14
  33. package/operations/listActivePrs.ts +2 -3
  34. package/operations/startAndMessage.test.ts +6 -12
  35. package/package.json +6 -4
  36. package/scripts/check-agent-prompts.test.ts +15 -11
  37. package/scripts/layout-bpmn.ts +6 -28
  38. package/scripts/pages-contract.test.ts +14 -13
  39. package/scripts/purge-db.ts +1 -1
  40. package/scripts/upgrade-from-pack.ts +1 -1
  41. package/test/assert.ts +74 -0
  42. package/tsconfig.json +4 -1
  43. package/workers/record-plan-review/worker.test.ts +6 -9
  44. package/workers/record-results/worker.test.ts +5 -8
  45. package/workers/record-trial-merge/worker.test.ts +7 -18
  46. package/workers/record-wave/worker.test.ts +13 -20
  47. package/workers/retro-gather/worker.test.ts +4 -17
  48. package/workers/retro-record/worker.test.ts +8 -27
  49. package/workers/select-wave/worker.test.ts +5 -8
  50. package/deno.json +0 -24
  51. 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 { assert, assertEquals } from "jsr:@std/assert@1";
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
- Deno.test("returns 200 with the app identity", async () => {
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
- Deno.test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
44
- const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
45
- Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
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) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
58
- else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
53
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
54
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
59
55
  }
60
56
  });
@@ -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
- // Cross-runtime env read (Node `process.env` OR Deno `Deno.env`): reading via `process.env` alone
13
- // would silently disable the guard under Deno/compiled runtimes where `process` is absent, leaving
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 { assert, assertEquals } from "jsr:@std/assert@1";
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
- Deno.test("returns 200 with a count + projected active PRs", async () => {
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
- Deno.test("shared-secret guard rejects a missing secret when configured", async () => {
53
- const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
54
- Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
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) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
68
- else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
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
- // Cross-runtime env read (Node `process.env` OR Deno `Deno.env`): reading via `process.env` alone
14
- // would silently disable the guard under Deno/compiled runtimes where `process` is absent, leaving
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 { assertEquals } from "jsr:@std/assert@1";
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
- Deno.test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
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
- Deno.test("startPlanFanout → 400 on an unparseable issue reference", async () => {
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
- Deno.test("postMessage → 400 when name is blank", async () => {
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
- Deno.test("postMessage → 400 when escalation-answered lacks a correlationKey", async () => {
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.36.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": "deno run -A scripts/check-agent-prompts.ts",
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
- "compile": "deno task compile",
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 { assert, assertEquals } from "jsr:@std/assert@1";
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
- async function fixture(files: Record<string, string>): Promise<string> {
20
- const root = await Deno.makeTempDir({ prefix: "agent-prompts-" });
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 = `${root}/${rel}`;
23
- await Deno.mkdir(abs.slice(0, abs.lastIndexOf("/")), { recursive: true });
24
- await Deno.writeTextFile(abs, content);
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
- Deno.test("passes when every {{token}} resolves to a non-blank template", async () => {
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
- Deno.test("fails when a header references an undeclared template", async () => {
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
- Deno.test("fails when the referenced template file is blank (would substitute to nothing)", async () => {
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
- Deno.test("fails when a reserved agent-prompt header is blank", async () => {
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
- Deno.test("checks the real repo: all committed agent prompts resolve", () => {
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);
@@ -1,4 +1,4 @@
1
- // npm run layout <file.bpmn ...> (or `deno task layout <file.bpmn ...>`) — (re)generate the
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 g.Deno?.readTextFile
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
- if (g.Deno?.writeTextFile) return await g.Deno.writeTextFile(path, text);
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
- if (g.Deno?.readDir) {
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 = g.Deno?.args ?? process.argv.slice(2);
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 { assert } from "jsr:@std/assert@1";
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
- async function loadSchema(): Promise<Map<string, Set<string>>> {
84
+ function loadSchema(): Map<string, Set<string>> {
83
85
  const schema = new Map<string, Set<string>>();
84
86
  const files: string[] = [];
85
- for await (const e of Deno.readDir(`${ROOT}db/migrations`)) {
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(await Deno.readTextFile(`${ROOT}db/migrations/${f}`), schema);
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
- async function loadRefs(): Promise<Ref[]> {
168
+ function loadRefs(): Ref[] {
168
169
  const refs: Ref[] = [];
169
- for await (const e of Deno.readDir(`${ROOT}pages`)) {
170
- if (!e.isFile || !e.name.endsWith(".page.json")) continue;
171
- const page = JSON.parse(await Deno.readTextFile(`${ROOT}pages/${e.name}`));
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
- Deno.test("every page datasource table exists in the migrations", async () => {
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
- Deno.test("every page datasource column exists on its table", async () => {
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
- Deno.test("issue #87: plan_reviews is surfaced on the epic and home pages", async () => {
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");
@@ -1,4 +1,4 @@
1
- // npm run purge (or `deno task purge`) — wipe the app's sqlite datasource so `npm start`
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 (or `deno task upgrade`) — refresh THIS app's source from a newer
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 { assertEquals, assertRejects } from "jsr:@std/assert@1";
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
- Deno.test("approved round proceeds (planApproved=true, no throw)", async () => {
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
- Deno.test("unapproved, non-final round revises (planApproved=false, no throw)", async () => {
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
- Deno.test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async () => {
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
- Deno.test("approved on the FINAL round still proceeds (no throw)", async () => {
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);