@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
package/app/service.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Data access goes through the record-oriented gateway (`data.table<T>(name, pk)` — the RAD
|
|
9
9
|
// `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
|
|
10
10
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
|
+
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
11
12
|
import {
|
|
12
13
|
classifyMergeability,
|
|
13
14
|
ensureFreshHeadRun,
|
|
@@ -18,13 +19,12 @@ import {
|
|
|
18
19
|
type MergeMethod,
|
|
19
20
|
requestCopilotReview,
|
|
20
21
|
} from "./github.ts";
|
|
21
|
-
import {
|
|
22
|
-
import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
22
|
+
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
23
23
|
import { freshHeadRunAction, loadMergeProtocol } from "./mergeProtocol.ts";
|
|
24
|
+
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
25
|
+
import { plans, planTaskDeps, planTasks } from "./plan.ts";
|
|
24
26
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
25
27
|
import { waveMergeTargets } from "./waves.ts";
|
|
26
|
-
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
27
|
-
import { planPrLane, type PrLaneDecision, taskDependencyDepths } from "./mergeTrain.ts";
|
|
28
28
|
|
|
29
29
|
/** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
|
|
30
30
|
export const PROCESS_ID = "convergence-loop";
|
|
@@ -34,10 +34,12 @@ export const MERGE_PROCESS_ID = "merge-loop";
|
|
|
34
34
|
* in convergence-loop.bpmn). Deliberately NOT hosted here — an external harness services it; the
|
|
35
35
|
* activation poll keys off it to tell "agent working" from "queued". */
|
|
36
36
|
const REVIEW_JOB_TYPE = "senior:pr-review";
|
|
37
|
+
|
|
37
38
|
/** Default round cap before the loop escalates to a human. A per-submit override (submit form /
|
|
38
39
|
* webhook / start action) takes precedence; this env var sets the fleet-wide default. The cap
|
|
39
40
|
* coercion + ceiling live in the pure `./rounds.ts` module (re-exported for callers). */
|
|
40
41
|
export { clampCiFixBudget, clampRounds, MAX_CI_FIX_CEILING, MAX_ROUNDS_CEILING } from "./rounds.ts";
|
|
42
|
+
|
|
41
43
|
import { clampCiFixBudget, clampRounds } from "./rounds.ts";
|
|
42
44
|
export const MAX_ROUNDS = clampRounds(process.env.NANO_PR_MAX_ROUNDS, 20);
|
|
43
45
|
|
|
@@ -830,6 +832,7 @@ async function pollJobActivation(
|
|
|
830
832
|
}),
|
|
831
833
|
});
|
|
832
834
|
if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
|
|
835
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
833
836
|
const body = (await res.json()) as { items?: JobSearchItem[] };
|
|
834
837
|
// An open job with a leasing worker means an agent has activated it. Prefer the one with
|
|
835
838
|
// the latest deadline if several are open (there is normally at most one).
|
|
@@ -929,6 +932,7 @@ export async function pollIncidentsImpl(
|
|
|
929
932
|
}),
|
|
930
933
|
});
|
|
931
934
|
if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
|
|
935
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
932
936
|
const body = (await res.json()) as { items?: IncidentSearchItem[] };
|
|
933
937
|
// Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
|
|
934
938
|
// instance somehow parks more than one). Re-filter on state defensively in case the wire
|
package/app/taskDelta.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the structured scope/impl-change report (D5, issue #55 / #49).
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals } from "#test-assert";
|
|
3
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
5
|
import {
|
|
5
6
|
aggregateEpicDeltas,
|
|
@@ -11,49 +12,39 @@ import {
|
|
|
11
12
|
|
|
12
13
|
// A tiny in-memory stand-in for the record gateway (insert/find/findOne/update/delete), mirroring
|
|
13
14
|
// the fake-app style used across the app tests (see app/blackboard.test.ts).
|
|
14
|
-
// deno-lint-ignore no-explicit-any
|
|
15
15
|
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
16
|
-
// deno-lint-ignore no-explicit-any
|
|
17
16
|
const stores: Record<string, any[]> = {};
|
|
18
17
|
const seq: Record<string, number> = {};
|
|
19
18
|
function tbl(name: string, pk = "id") {
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
19
|
const rows = (stores[name] ??= [] as any[]);
|
|
22
|
-
// deno-lint-ignore no-explicit-any
|
|
23
20
|
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
24
21
|
return {
|
|
25
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
26
22
|
async insert(row: any) {
|
|
27
23
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
28
24
|
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
29
25
|
return pk === "id" ? id : row[pk];
|
|
30
26
|
},
|
|
31
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
32
27
|
async find(where: any = {}) {
|
|
33
28
|
return rows.filter((r) => match(r, where));
|
|
34
29
|
},
|
|
35
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
36
30
|
async findOne(where: any = {}) {
|
|
37
31
|
return rows.find((r) => match(r, where));
|
|
38
32
|
},
|
|
39
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
40
33
|
async update(id: any, patch: any) {
|
|
41
34
|
const r = rows.find((row) => row[pk] === id);
|
|
42
35
|
if (r) Object.assign(r, patch);
|
|
43
36
|
},
|
|
44
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
45
37
|
async delete(id: any) {
|
|
46
38
|
const i = rows.findIndex((row) => row[pk] === id);
|
|
47
39
|
if (i >= 0) rows.splice(i, 1);
|
|
48
40
|
},
|
|
49
41
|
};
|
|
50
42
|
}
|
|
51
|
-
// deno-lint-ignore no-explicit-any
|
|
52
43
|
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
53
44
|
return { data, stores };
|
|
54
45
|
}
|
|
55
46
|
|
|
56
|
-
|
|
47
|
+
test("parseTaskDelta: trims, dedupes arrays, and drops empties", () => {
|
|
57
48
|
const d = parseTaskDelta({
|
|
58
49
|
contractChange: " new signature ",
|
|
59
50
|
newlyTouches: ["a.rs", " a.rs ", "", "b.rs"],
|
|
@@ -67,14 +58,14 @@ Deno.test("parseTaskDelta: trims, dedupes arrays, and drops empties", () => {
|
|
|
67
58
|
assertEquals(d.constraint, undefined, "a blank constraint is dropped");
|
|
68
59
|
});
|
|
69
60
|
|
|
70
|
-
|
|
61
|
+
test("parseTaskDelta: a delta with nothing actionable is null", () => {
|
|
71
62
|
assertEquals(parseTaskDelta(undefined), null);
|
|
72
63
|
assertEquals(parseTaskDelta({}), null);
|
|
73
64
|
assertEquals(parseTaskDelta({ newlyTouches: [], affectsTasks: [], contractChange: " " }), null);
|
|
74
65
|
assertEquals(parseTaskDelta("not an object"), null);
|
|
75
66
|
});
|
|
76
67
|
|
|
77
|
-
|
|
68
|
+
test("recordTaskDelta: upserts per (plan, task) — a resume overwrites, not duplicates", async () => {
|
|
78
69
|
const { data, stores } = memData();
|
|
79
70
|
const first = await recordTaskDelta(data, "o/r#1", "gap-2", {
|
|
80
71
|
newlyTouches: ["a.rs"],
|
|
@@ -99,7 +90,7 @@ Deno.test("recordTaskDelta: upserts per (plan, task) — a resume overwrites, no
|
|
|
99
90
|
assertEquals(entry.wave, 1);
|
|
100
91
|
});
|
|
101
92
|
|
|
102
|
-
|
|
93
|
+
test("readTaskDeltas: scoped to a plan, in write order, arrays decoded", async () => {
|
|
103
94
|
const { data } = memData();
|
|
104
95
|
await recordTaskDelta(data, "o/r#1", "gap-2", { newlyTouches: ["a.rs"], affectsTasks: [] });
|
|
105
96
|
await recordTaskDelta(data, "o/r#1", "gap-8", { newlyTouches: [], affectsTasks: ["gap-2"], constraint: "x" });
|
|
@@ -111,7 +102,7 @@ Deno.test("readTaskDeltas: scoped to a plan, in write order, arrays decoded", as
|
|
|
111
102
|
assertEquals(entries[1].affectsTasks, ["gap-2"]);
|
|
112
103
|
});
|
|
113
104
|
|
|
114
|
-
|
|
105
|
+
test("aggregateEpicDeltas: unions touched files + affected tasks, lists changes/constraints", async () => {
|
|
115
106
|
const { data } = memData();
|
|
116
107
|
await recordTaskDelta(data, "p", "gap-2", {
|
|
117
108
|
newlyTouches: ["engine/state.rs"],
|
|
@@ -132,7 +123,7 @@ Deno.test("aggregateEpicDeltas: unions touched files + affected tasks, lists cha
|
|
|
132
123
|
assertEquals(report.deltas.length, 2);
|
|
133
124
|
});
|
|
134
125
|
|
|
135
|
-
|
|
126
|
+
test("clearTaskDeltas: drops a plan's whole set (re-plan cleanup), leaving other plans intact", async () => {
|
|
136
127
|
const { data, stores } = memData();
|
|
137
128
|
await recordTaskDelta(data, "p", "gap-2", { newlyTouches: ["a.rs"], affectsTasks: [] });
|
|
138
129
|
await recordTaskDelta(data, "p", "gap-8", { newlyTouches: ["b.rs"], affectsTasks: [] });
|
package/app/taskDelta.ts
CHANGED
|
@@ -71,6 +71,7 @@ function decodeArray(raw: string | null): string[] {
|
|
|
71
71
|
* nothing actionable (so callers persist/broadcast only real deltas, never empty noise). */
|
|
72
72
|
export function parseTaskDelta(raw: unknown): TaskDelta | null {
|
|
73
73
|
if (!raw || typeof raw !== "object") return null;
|
|
74
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
74
75
|
const o = raw as Record<string, unknown>;
|
|
75
76
|
const delta: TaskDelta = {
|
|
76
77
|
contractChange: trimStr(o.contractChange),
|
package/app/trialMerge.test.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assertEquals } from "#test-assert";
|
|
2
3
|
import { shouldRunTrialMerge, trialMergeDecision } from "./trialMerge.ts";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
test("trialMergeDecision only escalates clean-merge suite failures", () => {
|
|
5
6
|
assertEquals(trialMergeDecision("clean"), "proceed");
|
|
6
7
|
assertEquals(trialMergeDecision("merge-conflict"), "proceed");
|
|
7
8
|
assertEquals(trialMergeDecision("suite-failed"), "escalate");
|
|
8
9
|
});
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
test("shouldRunTrialMerge skips lone heads and mergify queues", () => {
|
|
11
12
|
assertEquals(shouldRunTrialMerge(0, { land: { method: "gh-merge" } }), false);
|
|
12
13
|
assertEquals(shouldRunTrialMerge(1, { land: { method: "gh-merge" } }), false);
|
|
13
14
|
assertEquals(shouldRunTrialMerge(2, { land: { method: "mergify-queue" } }), false);
|
package/app/version.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
// inspecting the working tree at runtime. This module gathers that identity — the app's package
|
|
6
6
|
// version, the resolved `@nanobpm/urban` version, the git commit (read from `.git`, handling both
|
|
7
7
|
// an ordinary `.git` directory and the `gitdir:` file pointer used by worktrees/submodules, with
|
|
8
|
-
// an env override for detached deploys), plus the Node
|
|
8
|
+
// an env override for detached deploys), plus the Node runtime, pid and start time — so an operator
|
|
9
9
|
// debugging a stuck instance can confirm the process is on the code they think it is.
|
|
10
10
|
//
|
|
11
11
|
// Every probe is best-effort: a missing file or unavailable `.git` yields `null` for that field
|
|
12
12
|
// rather than throwing, so `/app/api/version` never fails just because one source is absent.
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { dirname, join, resolve, isAbsolute } from "node:path";
|
|
16
16
|
|
|
17
17
|
// Captured once, at module load — i.e. when the running process booted this code.
|
|
18
18
|
const STARTED_AT = new Date();
|
|
@@ -31,6 +31,7 @@ function readJson(path: string): Record<string, unknown> | null {
|
|
|
31
31
|
const text = readText(path);
|
|
32
32
|
if (text == null) return null;
|
|
33
33
|
try {
|
|
34
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
34
35
|
return JSON.parse(text) as Record<string, unknown>;
|
|
35
36
|
} catch {
|
|
36
37
|
return null;
|
|
@@ -38,19 +39,11 @@ function readJson(path: string): Record<string, unknown> | null {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
* Read an env var
|
|
42
|
-
* back to `Deno.env.get` (guarded — reading env can throw without `--allow-env`).
|
|
42
|
+
* Read an env var from `process.env`, trimmed; returns null when unset or blank.
|
|
43
43
|
*/
|
|
44
44
|
export function envVar(name: string): string | null {
|
|
45
|
-
const fromProcess =
|
|
45
|
+
const fromProcess = process.env[name];
|
|
46
46
|
if (typeof fromProcess === "string" && fromProcess.trim()) return fromProcess.trim();
|
|
47
|
-
const deno = (globalThis as { Deno?: { env?: { get?(k: string): string | undefined } } }).Deno;
|
|
48
|
-
try {
|
|
49
|
-
const fromDeno = deno?.env?.get?.(name);
|
|
50
|
-
if (typeof fromDeno === "string" && fromDeno.trim()) return fromDeno.trim();
|
|
51
|
-
} catch {
|
|
52
|
-
// Env access denied — treat as unset.
|
|
53
|
-
}
|
|
54
47
|
return null;
|
|
55
48
|
}
|
|
56
49
|
|
|
@@ -129,7 +122,7 @@ function gitSha(): string | null {
|
|
|
129
122
|
const refPath = ref.slice(4).trim(); // e.g. "refs/heads/main"
|
|
130
123
|
// A loose ref may live in the per-worktree dir or the common dir; check both.
|
|
131
124
|
const loose = readText(join(dirs.gitDir, refPath)) ?? readText(join(dirs.commonDir, refPath));
|
|
132
|
-
if (loose
|
|
125
|
+
if (loose?.trim()) return loose.trim();
|
|
133
126
|
|
|
134
127
|
// Packed refs fallback (always in the common dir): lines of "<sha> <refname>".
|
|
135
128
|
const packed = readText(join(dirs.commonDir, "packed-refs"));
|
|
@@ -157,12 +150,8 @@ function gitBranch(): string | null {
|
|
|
157
150
|
}
|
|
158
151
|
|
|
159
152
|
function runtime(): string {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const deno = (globalThis as { Deno?: { version?: { deno?: string } } }).Deno;
|
|
163
|
-
if (deno?.version?.deno) return `deno ${deno.version.deno}`;
|
|
164
|
-
if (proc?.version) return `node ${proc.version}`;
|
|
165
|
-
return "unknown";
|
|
153
|
+
// Node exposes `process.version` (e.g. "v24.15.0").
|
|
154
|
+
return process.version ? `node ${process.version}` : "unknown";
|
|
166
155
|
}
|
|
167
156
|
|
|
168
157
|
export interface VersionInfo {
|
|
@@ -182,7 +171,6 @@ export interface VersionInfo {
|
|
|
182
171
|
* tree ONCE at module load rather than re-reading files (`.git`, package.jsons) on every request.
|
|
183
172
|
*/
|
|
184
173
|
const STATIC: Omit<VersionInfo, "uptimeSeconds"> = (() => {
|
|
185
|
-
const proc = globalThis.process;
|
|
186
174
|
const pkg = appPackage();
|
|
187
175
|
return Object.freeze({
|
|
188
176
|
name: appName(pkg),
|
|
@@ -191,7 +179,7 @@ const STATIC: Omit<VersionInfo, "uptimeSeconds"> = (() => {
|
|
|
191
179
|
gitSha: gitSha(),
|
|
192
180
|
gitBranch: gitBranch(),
|
|
193
181
|
runtime: runtime(),
|
|
194
|
-
pid: typeof
|
|
182
|
+
pid: typeof process.pid === "number" ? process.pid : null,
|
|
195
183
|
startedAt: STARTED_AT.toISOString(),
|
|
196
184
|
});
|
|
197
185
|
})();
|
package/app/waves.test.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
// Red/green regression for the plan levelizer (issue #20). Run with `
|
|
1
|
+
// Red/green regression for the plan levelizer (issue #20). Run with `node --test`.
|
|
2
2
|
//
|
|
3
|
-
// One `
|
|
3
|
+
// One `test` = one named property of computeWaves. These encode the wave
|
|
4
4
|
// contract: independent tasks share wave 0 (all-parallel), a chain steps 0,1,2…
|
|
5
5
|
// (all-sequential), a diamond re-converges, and a malformed graph is rejected
|
|
6
6
|
// rather than silently mis-levelized.
|
|
7
|
-
import {
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assertEquals, assertThrows } from "#test-assert";
|
|
8
9
|
import { computeWaves, WaveError, type WaveGateTask, type WaveTask, waveMergeTargets } from "./waves.ts";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
test("no dependencies → every task in wave 0 (fully parallel)", () => {
|
|
11
12
|
const tasks: WaveTask[] = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
12
13
|
const { waves, waveCount } = computeWaves(tasks);
|
|
13
14
|
assertEquals(waveCount, 1);
|
|
14
15
|
assertEquals(waves, [["a", "b", "c"]]);
|
|
15
16
|
});
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
test("linear chain → one task per wave (fully sequential)", () => {
|
|
18
19
|
const tasks: WaveTask[] = [
|
|
19
20
|
{ id: "a" },
|
|
20
21
|
{ id: "b", dependsOn: ["a"] },
|
|
@@ -26,7 +27,7 @@ Deno.test("linear chain → one task per wave (fully sequential)", () => {
|
|
|
26
27
|
assertEquals([waveOf.get("a"), waveOf.get("b"), waveOf.get("c")], [0, 1, 2]);
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
test("diamond → longest-path level; join waits for both arms", () => {
|
|
30
31
|
const tasks: WaveTask[] = [
|
|
31
32
|
{ id: "a" },
|
|
32
33
|
{ id: "b", dependsOn: ["a"] },
|
|
@@ -38,7 +39,7 @@ Deno.test("diamond → longest-path level; join waits for both arms", () => {
|
|
|
38
39
|
assertEquals(waves, [["a"], ["b", "c"], ["d"]]);
|
|
39
40
|
});
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
test("mixed graph → level is 1 + max(dep level), not 1 + min", () => {
|
|
42
43
|
// e depends on a (wave 0) and d (wave 2) → must land in wave 3, behind the deeper dep.
|
|
43
44
|
const tasks: WaveTask[] = [
|
|
44
45
|
{ id: "a" },
|
|
@@ -52,20 +53,20 @@ Deno.test("mixed graph → level is 1 + max(dep level), not 1 + min", () => {
|
|
|
52
53
|
assertEquals(waveOf.get("e"), 4);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
test("empty plan → zero waves", () => {
|
|
56
57
|
const { waves, waveCount } = computeWaves([]);
|
|
57
58
|
assertEquals(waveCount, 0);
|
|
58
59
|
assertEquals(waves, []);
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
test("blank / whitespace dependsOn entries are ignored", () => {
|
|
62
63
|
const tasks: WaveTask[] = [{ id: "a", dependsOn: ["", " "] }];
|
|
63
64
|
const { waves, waveCount } = computeWaves(tasks);
|
|
64
65
|
assertEquals(waveCount, 1);
|
|
65
66
|
assertEquals(waves, [["a"]]);
|
|
66
67
|
});
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
test("dependency cycle → WaveError", () => {
|
|
69
70
|
const tasks: WaveTask[] = [
|
|
70
71
|
{ id: "a", dependsOn: ["b"] },
|
|
71
72
|
{ id: "b", dependsOn: ["a"] },
|
|
@@ -73,11 +74,11 @@ Deno.test("dependency cycle → WaveError", () => {
|
|
|
73
74
|
assertThrows(() => computeWaves(tasks), WaveError, "cycle");
|
|
74
75
|
});
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
test("self-dependency → WaveError", () => {
|
|
77
78
|
assertThrows(() => computeWaves([{ id: "a", dependsOn: ["a"] }]), WaveError, "itself");
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
test("unknown dependency id → WaveError", () => {
|
|
81
82
|
assertThrows(
|
|
82
83
|
() => computeWaves([{ id: "a", dependsOn: ["ghost"] }]),
|
|
83
84
|
WaveError,
|
|
@@ -85,7 +86,7 @@ Deno.test("unknown dependency id → WaveError", () => {
|
|
|
85
86
|
);
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
test("duplicate task id → WaveError", () => {
|
|
89
90
|
assertThrows(
|
|
90
91
|
() => computeWaves([{ id: "a" }, { id: "a" }]),
|
|
91
92
|
WaveError,
|
|
@@ -98,7 +99,7 @@ Deno.test("duplicate task id → WaveError", () => {
|
|
|
98
99
|
// opened/waiting-with-a-PR tasks of the gate wave, ignore other waves, and treat blocked/skipped
|
|
99
100
|
// and keyless tasks as nothing-to-wait-on so no non-mergeable PR state can wedge the barrier.
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
test("waveMergeTargets → only opened PRs of the gate wave", () => {
|
|
102
103
|
const tasks: WaveGateTask[] = [
|
|
103
104
|
{ wave: 0, status: "opened", pr_key: "o/r#1" },
|
|
104
105
|
{ wave: 0, status: "opened", pr_key: "o/r#2" },
|
|
@@ -107,7 +108,7 @@ Deno.test("waveMergeTargets → only opened PRs of the gate wave", () => {
|
|
|
107
108
|
assertEquals(waveMergeTargets(tasks, 0), ["o/r#1", "o/r#2"]);
|
|
108
109
|
});
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
test("waveMergeTargets → mergeable waiting tasks are waited on; failed/keyless tasks are not", () => {
|
|
111
112
|
const tasks: WaveGateTask[] = [
|
|
112
113
|
{ wave: 0, status: "opened", pr_key: "o/r#1" },
|
|
113
114
|
{ wave: 0, status: "blocked", pr_key: null },
|
|
@@ -119,7 +120,7 @@ Deno.test("waveMergeTargets → mergeable waiting tasks are waited on; failed/ke
|
|
|
119
120
|
assertEquals(waveMergeTargets(tasks, 0), ["o/r#1", "o/r#2"]);
|
|
120
121
|
});
|
|
121
122
|
|
|
122
|
-
|
|
123
|
+
test("waveMergeTargets → a wave with no opened PRs clears vacuously (empty)", () => {
|
|
123
124
|
const tasks: WaveGateTask[] = [
|
|
124
125
|
{ wave: 0, status: "blocked", pr_key: null },
|
|
125
126
|
{ wave: 0, status: "skipped", pr_key: null },
|
package/biome.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
|
3
|
+
"files": {
|
|
4
|
+
"includes": [
|
|
5
|
+
"app/**/*.ts",
|
|
6
|
+
"operations/**/*.ts",
|
|
7
|
+
"actions/**/*.ts",
|
|
8
|
+
"workers/**/*.ts",
|
|
9
|
+
"pages/**/*.ts",
|
|
10
|
+
"components/**/*.ts",
|
|
11
|
+
"scripts/**/*.ts",
|
|
12
|
+
"main.ts",
|
|
13
|
+
"!**/*.test.ts",
|
|
14
|
+
"!nano-generated/**",
|
|
15
|
+
"!node_modules/**"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"linter": {
|
|
19
|
+
"enabled": true,
|
|
20
|
+
"includes": [
|
|
21
|
+
"app/**/*.ts",
|
|
22
|
+
"operations/**/*.ts",
|
|
23
|
+
"actions/**/*.ts",
|
|
24
|
+
"workers/**/*.ts",
|
|
25
|
+
"pages/**/*.ts",
|
|
26
|
+
"components/**/*.ts",
|
|
27
|
+
"scripts/**/*.ts",
|
|
28
|
+
"main.ts",
|
|
29
|
+
"!**/*.test.ts",
|
|
30
|
+
"!nano-generated/**",
|
|
31
|
+
"!node_modules/**"
|
|
32
|
+
],
|
|
33
|
+
"rules": {
|
|
34
|
+
"recommended": true,
|
|
35
|
+
"complexity": {
|
|
36
|
+
"noAdjacentSpacesInRegex": "error",
|
|
37
|
+
"noArguments": "error",
|
|
38
|
+
"noBannedTypes": "error",
|
|
39
|
+
"noCommaOperator": "error",
|
|
40
|
+
"noEmptyTypeParameters": "error",
|
|
41
|
+
"noExtraBooleanCast": "error",
|
|
42
|
+
"noFlatMapIdentity": "error",
|
|
43
|
+
"noImportantStyles": "error",
|
|
44
|
+
"noStaticOnlyClass": "error",
|
|
45
|
+
"noThisInStatic": "error",
|
|
46
|
+
"noUselessCatch": "error",
|
|
47
|
+
"noUselessConstructor": "error",
|
|
48
|
+
"noUselessContinue": "error",
|
|
49
|
+
"noUselessEmptyExport": "error",
|
|
50
|
+
"noUselessEscapeInRegex": "error",
|
|
51
|
+
"noUselessFragments": "error",
|
|
52
|
+
"noUselessLabel": "error",
|
|
53
|
+
"noUselessLoneBlockStatements": "error",
|
|
54
|
+
"noUselessRename": "error",
|
|
55
|
+
"noUselessStringRaw": "error",
|
|
56
|
+
"noUselessSwitchCase": "error",
|
|
57
|
+
"noUselessTernary": "error",
|
|
58
|
+
"noUselessThisAlias": "error",
|
|
59
|
+
"noUselessTypeConstraint": "error",
|
|
60
|
+
"noUselessUndefinedInitialization": "error",
|
|
61
|
+
"useArrowFunction": "error",
|
|
62
|
+
"useDateNow": "error",
|
|
63
|
+
"useFlatMap": "error",
|
|
64
|
+
"useIndexOf": "error",
|
|
65
|
+
"useLiteralKeys": "error",
|
|
66
|
+
"useNumericLiterals": "error",
|
|
67
|
+
"useOptionalChain": "error",
|
|
68
|
+
"useRegexLiterals": "error",
|
|
69
|
+
"useSimpleNumberKeys": "error"
|
|
70
|
+
},
|
|
71
|
+
"correctness": {
|
|
72
|
+
"noUnusedFunctionParameters": "error",
|
|
73
|
+
"noUnusedImports": "error",
|
|
74
|
+
"noUnusedLabels": "error",
|
|
75
|
+
"noUnusedPrivateClassMembers": "error",
|
|
76
|
+
"noUnusedVariables": "error",
|
|
77
|
+
"useParseIntRadix": "error"
|
|
78
|
+
},
|
|
79
|
+
"performance": {
|
|
80
|
+
"noAccumulatingSpread": "error",
|
|
81
|
+
"noDynamicNamespaceImportAccess": "error"
|
|
82
|
+
},
|
|
83
|
+
"style": {
|
|
84
|
+
"noDescendingSpecificity": "error",
|
|
85
|
+
"noNonNullAssertion": "error",
|
|
86
|
+
"useArrayLiterals": "error",
|
|
87
|
+
"useConst": "error",
|
|
88
|
+
"useDeprecatedReason": "error",
|
|
89
|
+
"useExponentiationOperator": "error",
|
|
90
|
+
"useExportType": "error",
|
|
91
|
+
"useImportType": "error",
|
|
92
|
+
"useLiteralEnumMembers": "error",
|
|
93
|
+
"useNodejsImportProtocol": "error",
|
|
94
|
+
"useShorthandFunctionType": "error",
|
|
95
|
+
"useTemplate": "error"
|
|
96
|
+
},
|
|
97
|
+
"suspicious": {
|
|
98
|
+
"noApproximativeNumericConstant": "error",
|
|
99
|
+
"noCatchAssign": "error",
|
|
100
|
+
"noConfusingLabels": "error",
|
|
101
|
+
"noConfusingVoidType": "error",
|
|
102
|
+
"noConstEnum": "error",
|
|
103
|
+
"noDocumentCookie": "error",
|
|
104
|
+
"noDuplicateFields": "error",
|
|
105
|
+
"noEmptyBlock": "error",
|
|
106
|
+
"noEvolvingTypes": "error",
|
|
107
|
+
"noExplicitAny": "error",
|
|
108
|
+
"noExtraNonNullAssertion": "error",
|
|
109
|
+
"noGlobalIsFinite": "error",
|
|
110
|
+
"noGlobalIsNan": "error",
|
|
111
|
+
"noImplicitAnyLet": "error",
|
|
112
|
+
"noIrregularWhitespace": "error",
|
|
113
|
+
"noOctalEscape": "error",
|
|
114
|
+
"noPrototypeBuiltins": "error",
|
|
115
|
+
"noQuickfixBiome": "error",
|
|
116
|
+
"noRedundantUseStrict": "error",
|
|
117
|
+
"noSuspiciousSemicolonInJsx": "error",
|
|
118
|
+
"noTemplateCurlyInString": "error",
|
|
119
|
+
"noTsIgnore": "error",
|
|
120
|
+
"noUselessEscapeInString": "error",
|
|
121
|
+
"noUselessRegexBackrefs": "error",
|
|
122
|
+
"useAdjacentOverloadSignatures": "error",
|
|
123
|
+
"useBiomeIgnoreFolder": "error",
|
|
124
|
+
"useDefaultSwitchClauseLast": "error",
|
|
125
|
+
"useGoogleFontDisplay": "error",
|
|
126
|
+
"useIsArray": "error"
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"domains": {
|
|
130
|
+
"react": "none",
|
|
131
|
+
"solid": "none",
|
|
132
|
+
"next": "none",
|
|
133
|
+
"qwik": "none",
|
|
134
|
+
"vue": "none"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
"plugins": [
|
|
138
|
+
"./plugins/no-unsafe-type-assertion.grit"
|
|
139
|
+
],
|
|
140
|
+
"formatter": {
|
|
141
|
+
"enabled": false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Tests for GET /app/api/version → operation `getVersion` (ADR 0058 OpenAPI surface).
|
|
2
2
|
// Ported from the previous actions/version.test.ts. Method handling now belongs to the router
|
|
3
3
|
// (only GET is routed here), so there is no 405 case to test at the delegate level.
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
5
6
|
import type { AppApi } from "@nanobpm/urban";
|
|
6
7
|
import handler from "./getVersion.ts";
|
|
7
8
|
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
9
|
const app = {} as any as AppApi;
|
|
10
10
|
|
|
11
11
|
function input(headers: Record<string, string> = {}) {
|
|
@@ -16,7 +16,6 @@ function input(headers: Record<string, string> = {}) {
|
|
|
16
16
|
query: new URLSearchParams(),
|
|
17
17
|
headers: new Headers(headers),
|
|
18
18
|
text: async () => "",
|
|
19
|
-
// deno-lint-ignore no-explicit-any
|
|
20
19
|
} as any,
|
|
21
20
|
params: {},
|
|
22
21
|
query: {},
|
|
@@ -24,9 +23,8 @@ function input(headers: Record<string, string> = {}) {
|
|
|
24
23
|
};
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
test("returns 200 with the app identity", async () => {
|
|
28
27
|
const res = await handler(input(), app);
|
|
29
|
-
// deno-lint-ignore no-explicit-any
|
|
30
28
|
const r = res as any;
|
|
31
29
|
assertEquals(r.status, 200);
|
|
32
30
|
assertEquals(r.body.name, "nano-workforce");
|
|
@@ -40,21 +38,19 @@ Deno.test("returns 200 with the app identity", async () => {
|
|
|
40
38
|
assert(typeof r.body.uptimeSeconds === "number");
|
|
41
39
|
});
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
const prev =
|
|
45
|
-
|
|
41
|
+
test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
|
|
42
|
+
const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
43
|
+
process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
|
|
46
44
|
try {
|
|
47
45
|
// SECRET is bound at import time, so import a cache-busted copy to observe the guard.
|
|
48
46
|
const mod = await import(`./getVersion.ts?guard=${Date.now()}`);
|
|
49
47
|
const guarded = mod.default as typeof handler;
|
|
50
|
-
// deno-lint-ignore no-explicit-any
|
|
51
48
|
const bad = (await guarded(input(), app)) as any;
|
|
52
49
|
assertEquals(bad.status, 401);
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
50
|
const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
|
|
55
51
|
assertEquals(ok.status, 200);
|
|
56
52
|
} finally {
|
|
57
|
-
if (prev === undefined)
|
|
58
|
-
else
|
|
53
|
+
if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
|
|
54
|
+
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
59
55
|
}
|
|
60
56
|
});
|
package/operations/getVersion.ts
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
import { defineOperation } from "@nanobpm/urban";
|
|
10
10
|
import { buildVersionInfo, envVar, type VersionInfo } from "../app/version.ts";
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
|
|
12
|
+
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
13
|
+
// the x-hook-secret header. Captured once, at module load.
|
|
15
14
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
16
15
|
|
|
17
16
|
export default defineOperation<
|