@nanobpm/nano-workforce 0.35.2 → 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 +10 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/actions/blackboard.ts +1 -0
- package/actions/feature-answer-hook.ts +1 -0
- package/actions/plan-hook.ts +2 -1
- package/actions/webhook-submit.ts +2 -1
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- package/app/blackboard.ts +7 -6
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +24 -22
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeExclusion.ts +8 -3
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeProtocol.ts +1 -0
- 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/plan.ts +1 -1
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/retro.ts +4 -2
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +8 -4
- package/app/taskDelta.test.ts +8 -17
- package/app/taskDelta.ts +1 -0
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +9 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +143 -0
- 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/postMessage.ts +3 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +9 -4
- package/plugins/no-unsafe-type-assertion.grit +12 -0
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/check-agent-prompts.ts +4 -2
- package/scripts/layout-bpmn.ts +6 -26
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +3 -2
- package/scripts/upgrade-from-pack.ts +9 -5
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/finalize/worker.ts +2 -1
- package/workers/merge/worker.ts +3 -3
- package/workers/persist-escalation/worker.ts +2 -1
- package/workers/persist-round/worker.ts +2 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-plan-review/worker.ts +2 -1
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-results/worker.ts +2 -1
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-trial-merge/worker.ts +6 -6
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/record-wave/worker.ts +7 -8
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/retro-record/worker.ts +3 -3
- package/workers/select-wave/worker.test.ts +5 -8
- package/deno.json +0 -24
- package/deno.lock +0 -1776
|
@@ -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 };
|
|
@@ -9,8 +9,8 @@
|
|
|
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";
|
|
12
|
-
import { answerEscalation } from "../app/service.ts";
|
|
13
12
|
import { answerTaskEscalation, FEATURE_ESCALATION_MESSAGE } from "../app/plan.ts";
|
|
13
|
+
import { answerEscalation } from "../app/service.ts";
|
|
14
14
|
|
|
15
15
|
interface Body {
|
|
16
16
|
name?: unknown;
|
|
@@ -28,7 +28,7 @@ export default defineOperation<
|
|
|
28
28
|
|
|
29
29
|
if (name === "escalation-answered") {
|
|
30
30
|
const prKey = String(b.correlationKey ?? "");
|
|
31
|
-
const answer = String(
|
|
31
|
+
const answer = String(b.variables?.answer ?? "").trim();
|
|
32
32
|
if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
33
33
|
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
34
34
|
const r = await answerEscalation(app.data, app.engine, prKey, answer);
|
|
@@ -40,7 +40,7 @@ export default defineOperation<
|
|
|
40
40
|
// `<plan_key>:<task_id>`; record the answer, resume the parked child, and re-surface the next
|
|
41
41
|
// open escalation.
|
|
42
42
|
const corrKey = String(b.correlationKey ?? "");
|
|
43
|
-
const answer = String(
|
|
43
|
+
const answer = String(b.variables?.answer ?? "").trim();
|
|
44
44
|
if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
45
45
|
if (!answer) return { status: 400, body: { error: "answer is required" } };
|
|
46
46
|
const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
|
|
@@ -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,19 +33,21 @@
|
|
|
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
|
-
"
|
|
42
|
+
"test": "node --experimental-strip-types --test",
|
|
43
|
+
"lint": "biome check app operations actions workers pages components scripts main.ts",
|
|
44
|
+
"lint:fix": "biome check --write app operations actions workers pages components scripts main.ts"
|
|
41
45
|
},
|
|
42
46
|
"dependencies": {
|
|
43
47
|
"@nanobpm/urban": "^0.33.0"
|
|
44
48
|
},
|
|
45
49
|
"devDependencies": {
|
|
50
|
+
"@biomejs/biome": "^2.4.11",
|
|
46
51
|
"@semantic-release/changelog": "^6.0.3",
|
|
47
52
|
"@semantic-release/git": "^10.0.1",
|
|
48
53
|
"@semantic-release/npm": "^13.1.5",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript)
|
|
3
|
+
|
|
4
|
+
`$expr as $type` as $assertion where {
|
|
5
|
+
$type <: not r"^const$",
|
|
6
|
+
$assertion <: not within JsImport(),
|
|
7
|
+
register_diagnostic(
|
|
8
|
+
span = $assertion,
|
|
9
|
+
message = "Type assertions (`as T`) bypass the type system. Use a type guard or `satisfies` instead. If unavoidable, add `// biome-ignore lint/plugin: <reason>` above.",
|
|
10
|
+
severity = "error"
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -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);
|
|
@@ -64,9 +64,10 @@ function templateMap(root: string, patterns: string[]): Record<string, string> {
|
|
|
64
64
|
// signal can't see (an empty value carries no `{{token}}` to be unresolved).
|
|
65
65
|
function hasBlankAgentPromptHeader(bpmn: string): boolean {
|
|
66
66
|
const re = /<zeebe:header\s+key="([^"]*)"\s+value="([^"]*)"\s*\/?>/g;
|
|
67
|
-
let m
|
|
68
|
-
while (
|
|
67
|
+
let m = re.exec(bpmn);
|
|
68
|
+
while (m !== null) {
|
|
69
69
|
if (m[1] === AGENT_PROMPT_HEADER && m[2].trim() === "") return true;
|
|
70
|
+
m = re.exec(bpmn);
|
|
70
71
|
}
|
|
71
72
|
return false;
|
|
72
73
|
}
|
|
@@ -86,6 +87,7 @@ export function checkAgentPrompts(root: string): CheckResult {
|
|
|
86
87
|
if (!existsSync(manifestPath)) {
|
|
87
88
|
return { ok: false, errors: [`nano.app.json not found under ${root}`], resolved: [] };
|
|
88
89
|
}
|
|
90
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
89
91
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AppManifest;
|
|
90
92
|
const models = manifest.models ?? {};
|
|
91
93
|
const templates = templateMap(root, models.templates ?? []);
|
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,37 +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
|
-
const g = globalThis as {
|
|
16
|
-
Deno?: {
|
|
17
|
-
args: string[];
|
|
18
|
-
readDir(p: string): AsyncIterable<{ name: string; isFile: boolean }>;
|
|
19
|
-
readTextFile(p: string): Promise<string>;
|
|
20
|
-
writeTextFile(p: string, s: string): Promise<void>;
|
|
21
|
-
};
|
|
22
|
-
};
|
|
23
|
-
|
|
24
14
|
async function readText(path: string): Promise<string> {
|
|
25
|
-
return
|
|
26
|
-
? await g.Deno.readTextFile(path)
|
|
27
|
-
: await (await import("node:fs/promises")).readFile(path, "utf8");
|
|
15
|
+
return await readFile(path, "utf8");
|
|
28
16
|
}
|
|
29
17
|
async function writeText(path: string, text: string): Promise<void> {
|
|
30
|
-
|
|
31
|
-
await (await import("node:fs/promises")).writeFile(path, text, "utf8");
|
|
18
|
+
await writeFile(path, text, "utf8");
|
|
32
19
|
}
|
|
33
20
|
async function defaultProcessFiles(): Promise<string[]> {
|
|
34
21
|
const dir = "resources/processes";
|
|
35
|
-
|
|
36
|
-
const files: string[] = [];
|
|
37
|
-
for await (const e of g.Deno.readDir(dir)) if (e.isFile && e.name.endsWith(".bpmn")) files.push(`${dir}/${e.name}`);
|
|
38
|
-
return files.sort();
|
|
39
|
-
}
|
|
40
|
-
const fs = await import("node:fs/promises");
|
|
41
|
-
return (await fs.readdir(dir, { withFileTypes: true }))
|
|
22
|
+
return (await readdir(dir, { withFileTypes: true }))
|
|
42
23
|
.filter((e) => e.isFile() && e.name.endsWith(".bpmn"))
|
|
43
24
|
.map((e) => `${dir}/${e.name}`)
|
|
44
25
|
.sort();
|
|
@@ -52,12 +33,11 @@ const countDi = (xml: string) => ({
|
|
|
52
33
|
});
|
|
53
34
|
|
|
54
35
|
function exit(code: number): never {
|
|
55
|
-
if (g.Deno) return (globalThis as { Deno?: { exit(c: number): never } }).Deno!.exit(code);
|
|
56
36
|
process.exit(code);
|
|
57
37
|
}
|
|
58
38
|
|
|
59
39
|
async function main() {
|
|
60
|
-
const argv =
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
61
41
|
// `--check` mode: regenerate the DI in memory and fail (non-zero) if it differs from what's
|
|
62
42
|
// committed, WITHOUT rewriting any file. This is the CI freshness gate — it catches a BPMN
|
|
63
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";
|
|
@@ -51,7 +51,8 @@ for (const suffix of ["", "-wal", "-shm"]) {
|
|
|
51
51
|
rmSync(path + suffix);
|
|
52
52
|
console.log(`removed ${path}${suffix}`);
|
|
53
53
|
} catch (err) {
|
|
54
|
-
|
|
54
|
+
const code = typeof err === "object" && err !== null ? Reflect.get(err, "code") : undefined;
|
|
55
|
+
if (code !== "ENOENT") throw err;
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
console.log("app db purged");
|
|
@@ -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
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
// (skips `npm pack`; --version/--package are ignored)
|
|
30
30
|
// --force overlay even if the cwd doesn't look like this app
|
|
31
31
|
// -h, --help show this help
|
|
32
|
+
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
32
34
|
import {
|
|
33
35
|
cpSync,
|
|
34
36
|
existsSync,
|
|
@@ -39,7 +41,6 @@ import {
|
|
|
39
41
|
rmSync,
|
|
40
42
|
statSync,
|
|
41
43
|
} from "node:fs";
|
|
42
|
-
import { execFileSync } from "node:child_process";
|
|
43
44
|
import { tmpdir } from "node:os";
|
|
44
45
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
45
46
|
|
|
@@ -199,10 +200,12 @@ function runTar(tarArgs: string[], tgz: string): string {
|
|
|
199
200
|
try {
|
|
200
201
|
return execFileSync("tar", tarArgs, { encoding: "utf8" });
|
|
201
202
|
} catch (e) {
|
|
202
|
-
|
|
203
|
+
const code = typeof e === "object" && e !== null ? Reflect.get(e, "code") : undefined;
|
|
204
|
+
if (code === "ENOENT") {
|
|
203
205
|
throw new Error(`'tar' not found on PATH — install it to extract ${tgz}`, { cause: e });
|
|
204
206
|
}
|
|
205
|
-
|
|
207
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
208
|
+
throw new Error(`tar failed on ${tgz}: ${message}`, { cause: e });
|
|
206
209
|
}
|
|
207
210
|
}
|
|
208
211
|
|
|
@@ -329,6 +332,7 @@ function report(label: string, files: string[]): void {
|
|
|
329
332
|
try {
|
|
330
333
|
main();
|
|
331
334
|
} catch (err) {
|
|
332
|
-
|
|
335
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
336
|
+
console.error(`upgrade failed: ${message}`);
|
|
333
337
|
process.exit(1);
|
|
334
338
|
}
|
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",
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// merge stage (start the `merge-loop` process and park the PR in `waiting_deps`) when auto-merge
|
|
3
3
|
// is on, or (b) close the PR out as `converged` (review-only mode).
|
|
4
4
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
5
|
-
import { AUTO_MERGE, ensurePr, startMerge } from "../../app/service.ts";
|
|
6
5
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
7
6
|
import { maybeStartRetro } from "../../app/retro.ts";
|
|
7
|
+
import { AUTO_MERGE, ensurePr, startMerge } from "../../app/service.ts";
|
|
8
8
|
|
|
9
9
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
10
10
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -22,6 +22,7 @@ interface In extends Record<string, unknown> {
|
|
|
22
22
|
|
|
23
23
|
const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
|
|
24
24
|
function transcriptOf(vars: Record<string, unknown>): string | null {
|
|
25
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
25
26
|
const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
|
|
26
27
|
return typeof env?.output === "string" ? env.output : null;
|
|
27
28
|
}
|