@nanobpm/nano-workforce 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.github/workflows/ci.yml +7 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +6 -27
  5. package/SPEC.md +15 -13
  6. package/app/abandon.test.ts +14 -19
  7. package/app/abandon.ts +2 -2
  8. package/app/baseGuard.test.ts +7 -6
  9. package/app/blackboard.test.ts +25 -35
  10. package/app/blackboard.ts +2 -2
  11. package/app/ensure-pr.test.ts +10 -12
  12. package/app/github.test.ts +9 -8
  13. package/app/github.ts +1 -21
  14. package/app/instance-tracking.test.ts +8 -6
  15. package/app/mergeExclusion.test.ts +11 -20
  16. package/app/mergeProtocol.test.ts +14 -13
  17. package/app/mergeRebaseArm.test.ts +8 -6
  18. package/app/mergeTrain.test.ts +10 -9
  19. package/app/persist-escalation.test.ts +10 -31
  20. package/app/persist-round.test.ts +7 -20
  21. package/app/plan.test.ts +19 -42
  22. package/app/record-plan-review.test.ts +8 -7
  23. package/app/retro.test.ts +24 -48
  24. package/app/reviewWait.test.ts +12 -11
  25. package/app/rounds.test.ts +13 -12
  26. package/app/service.test.ts +14 -27
  27. package/app/service.ts +1 -1
  28. package/app/taskDelta.test.ts +8 -17
  29. package/app/trialMerge.test.ts +4 -3
  30. package/app/version.ts +6 -21
  31. package/app/waves.test.ts +17 -16
  32. package/biome.json +0 -2
  33. package/main.ts +3 -3
  34. package/nano.app.json +2 -25
  35. package/openapi.yaml +649 -0
  36. package/operations/answerFeatureEscalation.test.ts +100 -0
  37. package/operations/answerFeatureEscalation.ts +49 -0
  38. package/operations/appendBlackboard.ts +61 -0
  39. package/{actions → operations}/blackboard.test.ts +28 -30
  40. package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
  41. package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
  42. package/operations/getVersion.test.ts +8 -12
  43. package/operations/getVersion.ts +2 -3
  44. package/operations/listActivePrs.test.ts +8 -14
  45. package/operations/listActivePrs.ts +3 -4
  46. package/operations/postMessage.ts +1 -1
  47. package/operations/readBlackboard.ts +29 -0
  48. package/operations/startAndMessage.test.ts +11 -15
  49. package/operations/startConvergenceLoop.ts +17 -11
  50. package/operations/startPlanFanout.ts +13 -7
  51. package/package.json +9 -7
  52. package/pages/epic.page.json +1 -1
  53. package/pages/home.page.json +1 -1
  54. package/scripts/check-agent-prompts.test.ts +15 -11
  55. package/scripts/layout-bpmn.ts +6 -28
  56. package/scripts/pages-contract.test.ts +14 -13
  57. package/scripts/purge-db.ts +1 -1
  58. package/scripts/upgrade-from-pack.ts +1 -1
  59. package/test/assert.ts +74 -0
  60. package/tsconfig.json +4 -1
  61. package/workers/persist-task-escalation/worker.ts +1 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-results/worker.test.ts +5 -8
  64. package/workers/record-trial-merge/worker.test.ts +7 -18
  65. package/workers/record-wave/worker.test.ts +13 -20
  66. package/workers/retro-gather/worker.test.ts +4 -17
  67. package/workers/retro-record/worker.test.ts +8 -27
  68. package/workers/select-wave/worker.test.ts +5 -8
  69. package/actions/blackboard.ts +0 -77
  70. package/actions/feature-answer-hook.ts +0 -45
  71. package/actions/plan-hook.ts +0 -20
  72. package/actions/webhook-submit.ts +0 -22
  73. package/deno.json +0 -24
  74. package/deno.lock +0 -1777
  75. package/openapi.json +0 -248
@@ -3,16 +3,15 @@
3
3
  // external automation harness can see active work — and grab a `processKey` to cancel — without
4
4
  // opening the DB or the UI. Read-only projection over the datasource.
5
5
  //
6
- // The runtime validates the (empty) request against openapi.json; the optional shared-secret guard
6
+ // The runtime validates the (empty) request against openapi.yaml; the optional shared-secret guard
7
7
  // stays HERE (the runtime does not enforce OpenAPI `security`): when NANO_PR_WEBHOOK_SECRET is set,
8
8
  // callers must present it via the x-hook-secret header. Unset → open (unchanged default).
9
9
  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 };
@@ -5,7 +5,7 @@
5
5
  // token, then re-surface the next open escalation. Any other message falls back to a plain
6
6
  // publishMessage.
7
7
  //
8
- // The runtime validates the body against openapi.json (`name` is required, so a missing name is a 400
8
+ // The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
9
9
  // for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
10
10
  // is app logic, not something the JSON schema can express.
11
11
  import { defineOperation } from "@nanobpm/urban";
@@ -0,0 +1,29 @@
1
+ // GET /app/api/hooks/blackboard?token=<capabilityToken> → operationId `readBlackboard` (ADR 0059
2
+ // webhook operation; was the GET half of the `/hooks/blackboard` action; Tier 1, issues #51 / #49 D4).
3
+ //
4
+ // A DIRECT side-channel for agents, distinct from the c8ctl-nano activation/completion channel. The
5
+ // per-plan capability token (query string) IS the credential: it scopes every read to exactly one
6
+ // plan, so no shared secret is needed — the agent curls the exact URL it was handed in its prompt.
7
+ // An unknown token is a 404 (never leaks which plans exist).
8
+ //
9
+ // GET → { planKey, entries: [ { id, author_task, kind, files, body, wave, created_at } ], cursor }
10
+ // optional ?since=<id> returns only entries with id > since (incremental poll). `cursor` is
11
+ // the plan's current head id; pass it back as `since` on the next poll (Tier 2).
12
+ import { defineOperation } from "@nanobpm/urban";
13
+ import type { BlackboardPage } from "../app/blackboard.ts";
14
+ import { planKeyForToken, readBlackboardPage } from "../app/blackboard.ts";
15
+
16
+ export default defineOperation<
17
+ { params: Record<string, string>; query: { token?: string; since?: string }; body: unknown },
18
+ (BlackboardPage & { planKey: string }) | { error: string }
19
+ >("readBlackboard", async ({ req }, app) => {
20
+ const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
21
+ if (!token) return { status: 400, body: { error: "missing blackboard token" } };
22
+ const planKey = await planKeyForToken(app.data, token);
23
+ if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
24
+
25
+ const rawSince = req.query.get("since");
26
+ const since = rawSince != null && /^\d+$/.test(rawSince) ? Number(rawSince) : undefined;
27
+ const { entries, cursor } = await readBlackboardPage(app.data, planKey, { since });
28
+ return { status: 200, body: { planKey, entries, cursor } };
29
+ });
@@ -1,20 +1,20 @@
1
1
  // Tests for the start/message operation delegates (ADR 0058 OpenAPI surface).
2
2
  // These cover the app-logic guards the JSON schema can't express (reference parsing, message-name
3
- // dispatch); the runtime's schema validation (required `variables`/`name`) is exercised by urban's
3
+ // dispatch). The delegates reject an unparseable/blank `pr`/`issue` with a 400 (the schema itself
4
+ // marks neither required, since `StartVariables` is shared across convergence and planning); the
5
+ // runtime's schema-level validation (e.g. `postMessage`'s required `name`) is exercised by urban's
4
6
  // own api runtime tests.
5
- import { assertEquals } from "jsr:@std/assert@1";
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
6
9
  import type { AppApi } from "@nanobpm/urban";
7
10
  import startConvergenceLoop from "./startConvergenceLoop.ts";
8
11
  import startPlanFanout from "./startPlanFanout.ts";
9
12
  import postMessage from "./postMessage.ts";
10
13
 
11
- // deno-lint-ignore no-explicit-any
12
14
  const app = {} as any as AppApi;
13
15
 
14
- // deno-lint-ignore no-explicit-any
15
16
  function input(body: any) {
16
17
  return {
17
- // deno-lint-ignore no-explicit-any
18
18
  req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
19
19
  params: {},
20
20
  query: {},
@@ -22,33 +22,29 @@ function input(body: any) {
22
22
  };
23
23
  }
24
24
 
25
- Deno.test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
26
- const res = await startConvergenceLoop(input({ variables: { pr: "not a pr" } }), app);
27
- // deno-lint-ignore no-explicit-any
25
+ test("startConvergenceLoop → 400 on an unparseable PR reference", async () => {
26
+ const res = await startConvergenceLoop(input({ pr: "not a pr" }), app);
28
27
  const r = res as any;
29
28
  assertEquals(r.status, 400);
30
29
  assertEquals(typeof r.body.error, "string");
31
30
  });
32
31
 
33
- Deno.test("startPlanFanout → 400 on an unparseable issue reference", async () => {
34
- const res = await startPlanFanout(input({ variables: { issue: "" } }), app);
35
- // deno-lint-ignore no-explicit-any
32
+ test("startPlanFanout → 400 on an unparseable issue reference", async () => {
33
+ const res = await startPlanFanout(input({ issue: "" }), app);
36
34
  const r = res as any;
37
35
  assertEquals(r.status, 400);
38
36
  assertEquals(typeof r.body.error, "string");
39
37
  });
40
38
 
41
- Deno.test("postMessage → 400 when name is blank", async () => {
39
+ test("postMessage → 400 when name is blank", async () => {
42
40
  const res = await postMessage(input({ name: "" }), app);
43
- // deno-lint-ignore no-explicit-any
44
41
  const r = res as any;
45
42
  assertEquals(r.status, 400);
46
43
  assertEquals(r.body.error, "name is required");
47
44
  });
48
45
 
49
- Deno.test("postMessage → 400 when escalation-answered lacks a correlationKey", async () => {
46
+ test("postMessage → 400 when escalation-answered lacks a correlationKey", async () => {
50
47
  const res = await postMessage(input({ name: "escalation-answered", variables: { answer: "yes" } }), app);
51
- // deno-lint-ignore no-explicit-any
52
48
  const r = res as any;
53
49
  assertEquals(r.status, 400);
54
50
  assertEquals(r.body.error, "correlationKey is required");
@@ -1,28 +1,34 @@
1
- // POST /app/api/actions/start/convergence-loop → operationId `startConvergenceLoop` (ADR 0058, base /app/api).
2
- // Replaces the hand-rolled action that overrode the generic "start process" palette action: parse the
3
- // PR reference and register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
1
+ // POST /app/api/actions/start/convergence-loop → operationId `startConvergenceLoop` (ADR 0058/0059,
2
+ // base /app/api). The ONE door for starting a convergence loop — the page's "Start review" form, an
3
+ // external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
4
+ // register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
4
5
  //
5
- // The runtime validates the body against openapi.json (a `variables` object is required); this
6
- // delegate keeps the PR-parse guard because the reference format (owner/repo#123 or a URL) is app
7
- // logic, not something the JSON schema can express an unparseable reference is a 400.
6
+ // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds? }`), not wrapped in a `variables`
7
+ // envelope: this is a purpose-built operation, not a generic engine "start process" call, so it does
8
+ // not leak the engine's variable-map concept to callers. The runtime validates the body against
9
+ // openapi.yaml; this delegate keeps the PR-parse guard because the reference format (owner/repo#123
10
+ // or a URL) is app logic, not something the JSON schema can express — an unparseable reference is a 400.
8
11
  import { defineOperation } from "@nanobpm/urban";
9
12
  import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
10
13
 
11
14
  interface Body {
12
- variables?: { pr?: string; url?: string; dependsOn?: unknown; maxRounds?: unknown };
15
+ pr?: string;
16
+ url?: string;
17
+ dependsOn?: unknown;
18
+ maxRounds?: unknown;
13
19
  }
14
20
 
15
21
  export default defineOperation<
16
22
  { params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
17
23
  { prKey: string; alreadyRunning?: boolean; processKey?: string | null } | { error: string }
18
24
  >("startConvergenceLoop", async ({ body }, app) => {
19
- const vars = body?.variables ?? {};
20
- const raw = String(vars.pr ?? vars.url ?? "").trim();
25
+ const b = body ?? {};
26
+ const raw = String(b.pr ?? b.url ?? "").trim();
21
27
  const parsed = parsePr(raw);
22
28
  if (!parsed) {
23
29
  return { status: 400, body: { error: "could not parse PR (use owner/repo#123 or a PR URL)" } };
24
30
  }
25
- const dependsOn = Array.isArray(vars.dependsOn) ? vars.dependsOn.map((d) => String(d)) : [];
26
- const maxRounds = clampRounds(vars.maxRounds, MAX_ROUNDS);
31
+ const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
32
+ const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
27
33
  return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
28
34
  });
@@ -1,12 +1,18 @@
1
- // POST /app/api/actions/start/plan-fanout → operationId `startPlanFanout` (ADR 0058, base /app/api).
2
- // Replaces the hand-rolled action that overrode the generic "start process" palette action: parse the
3
- // issue reference and register/refresh the plan aggregate (idempotent on planKey) before starting the
4
- // planning fan-out. An unparseable reference is a 400; an already-running plan short-circuits.
1
+ // POST /app/api/actions/start/plan-fanout → operationId `startPlanFanout` (ADR 0058/0059, base
2
+ // /app/api). The ONE door for starting a planning fan-out — the epic page's "Plan & implement" form,
3
+ // an external webhook relay (a GitHub relay on issue open/label), a CI job, and Swagger all POST
4
+ // here. Parse the issue reference and register/refresh the plan aggregate (idempotent on planKey)
5
+ // before starting the planning fan-out. An unparseable reference is a 400; an already-running plan
6
+ // short-circuits.
7
+ //
8
+ // The request body is FLAT (`{ issue | url }`), not wrapped in a `variables` envelope — this is a
9
+ // purpose-built operation, not a generic engine "start process" call.
5
10
  import { defineOperation } from "@nanobpm/urban";
6
11
  import { parseIssue, startPlan } from "../app/plan.ts";
7
12
 
8
13
  interface Body {
9
- variables?: { issue?: string; url?: string };
14
+ issue?: string;
15
+ url?: string;
10
16
  }
11
17
 
12
18
  type Res =
@@ -17,8 +23,8 @@ export default defineOperation<
17
23
  { params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
18
24
  Res
19
25
  >("startPlanFanout", async ({ body }, app) => {
20
- const vars = body?.variables ?? {};
21
- const raw = String(vars.issue ?? vars.url ?? "").trim();
26
+ const b = body ?? {};
27
+ const raw = String(b.issue ?? b.url ?? "").trim();
22
28
  const parsed = parseIssue(raw);
23
29
  if (!parsed) {
24
30
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.36.0",
3
+ "version": "0.38.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,19 +33,18 @@
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",
41
- "lint": "biome check app operations actions workers pages components scripts main.ts",
42
- "lint:fix": "biome check --write app operations actions workers pages components scripts main.ts"
42
+ "test": "node --experimental-strip-types --test",
43
+ "lint": "biome check app operations workers pages components scripts main.ts",
44
+ "lint:fix": "biome check --write app operations workers pages components scripts main.ts"
43
45
  },
44
46
  "dependencies": {
45
- "@nanobpm/urban": "^0.33.0"
47
+ "@nanobpm/urban": "^0.35.0"
46
48
  },
47
49
  "devDependencies": {
48
50
  "@biomejs/biome": "^2.4.11",
@@ -33,7 +33,7 @@
33
33
  "props": {
34
34
  "title": "Hand an issue to the fleet",
35
35
  "submitLabel": "Plan & implement",
36
- "action": { "path": "/app/api/actions/start/plan-fanout" },
36
+ "action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
37
37
  "fields": [
38
38
  { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
39
39
  ]
@@ -33,7 +33,7 @@
33
33
  "props": {
34
34
  "title": "Submit a pull request",
35
35
  "submitLabel": "Start review",
36
- "action": { "path": "/app/api/actions/start/convergence-loop" },
36
+ "action": { "path": "/app/api/actions/start/convergence-loop", "body": "{{form}}" },
37
37
  "fields": [
38
38
  { "key": "pr", "label": "owner/repo#123 or a GitHub PR URL", "type": "text" },
39
39
  { "key": "maxRounds", "label": "Max review rounds (blank = fleet default)", "type": "number" }
@@ -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",
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // The process then parks the child at the `feature-escalation-answered` message
11
11
  // catch (correlationKey `<plan_key>:<task_id>`). Answering it (page form or
12
- // `/hooks/feature-answer`) resumes the child, which re-dispatches the SAME task.
12
+ // `/app/api/hooks/feature-answer`) resumes the child, which re-dispatches the SAME task.
13
13
  //
14
14
  // Retry-safe: if an open escalation already exists for this corr key (a worker
15
15
  // re-activation before the wait subscription opened), it is UPDATED, not