@nanobpm/nano-workforce 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +7 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +7 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +1 -21
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +9 -30
- package/app/persist-round.test.ts +6 -19
- package/app/plan.test.ts +19 -42
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/taskDelta.test.ts +8 -17
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +6 -21
- package/app/waves.test.ts +17 -16
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +2 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +6 -4
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/layout-bpmn.ts +6 -28
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +1 -1
- package/scripts/upgrade-from-pack.ts +1 -1
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/select-wave/worker.test.ts +5 -8
- package/deno.json +0 -24
- package/deno.lock +0 -1777
package/app/reviewWait.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Contract for the review-wait liveness policy — the ISO-8601 timeout validation handed to the
|
|
2
2
|
// process's timer catch, and the Copilot re-request nudge cooldown. Both are env-driven, so a
|
|
3
3
|
// blank/malformed operator value must fall back to a sane default rather than deploy an
|
|
4
|
-
// uninterpretable timer or a runaway nudge interval. Run with `
|
|
5
|
-
import {
|
|
4
|
+
// uninterpretable timer or a runaway nudge interval. Run with `node --test`.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
6
7
|
import {
|
|
7
8
|
clampNudgeMinutes,
|
|
8
9
|
DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
reviewWaitTimeout,
|
|
12
13
|
} from "./reviewWait.ts";
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
test("reviewWaitTimeout: blank / absent / malformed → default", () => {
|
|
15
16
|
assertEquals(reviewWaitTimeout(undefined), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
16
17
|
assertEquals(reviewWaitTimeout(""), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
17
18
|
assertEquals(reviewWaitTimeout(" "), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
@@ -21,7 +22,7 @@ Deno.test("reviewWaitTimeout: blank / absent / malformed → default", () => {
|
|
|
21
22
|
assertEquals(reviewWaitTimeout("garbage"), DEFAULT_REVIEW_WAIT_TIMEOUT);
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
test("reviewWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
25
26
|
assertEquals(reviewWaitTimeout("PT30M"), "PT30M");
|
|
26
27
|
assertEquals(reviewWaitTimeout("pt30m"), "PT30M");
|
|
27
28
|
assertEquals(reviewWaitTimeout(" PT1H30M "), "PT1H30M");
|
|
@@ -30,12 +31,12 @@ Deno.test("reviewWaitTimeout: a valid ISO-8601 duration is honoured and upper-ca
|
|
|
30
31
|
assertEquals(reviewWaitTimeout("P1DT2H"), "P1DT2H");
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
test("reviewWaitTimeout: a custom fallback is used when the value is invalid", () => {
|
|
34
35
|
assertEquals(reviewWaitTimeout("nope", "PT10M"), "PT10M");
|
|
35
36
|
assertEquals(reviewWaitTimeout(undefined, "PT10M"), "PT10M");
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () => {
|
|
39
40
|
assertEquals(clampNudgeMinutes(""), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
40
41
|
assertEquals(clampNudgeMinutes(" "), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
41
42
|
assertEquals(clampNudgeMinutes(undefined), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
@@ -44,27 +45,27 @@ Deno.test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () =>
|
|
|
44
45
|
assertEquals(clampNudgeMinutes(NaN), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
test("clampNudgeMinutes: a valid positive value (string or number) is honoured", () => {
|
|
48
49
|
assertEquals(clampNudgeMinutes("10"), 10);
|
|
49
50
|
assertEquals(clampNudgeMinutes(15), 15);
|
|
50
51
|
assertEquals(clampNudgeMinutes(" 7 "), 7);
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
test("clampNudgeMinutes: fractional values are truncated", () => {
|
|
54
55
|
assertEquals(clampNudgeMinutes("5.9"), 5);
|
|
55
56
|
assertEquals(clampNudgeMinutes(3.2), 3);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
test("clampNudgeMinutes: zero and negatives fall back (a 0 cooldown would hammer the API)", () => {
|
|
59
60
|
assertEquals(clampNudgeMinutes(0), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
60
61
|
assertEquals(clampNudgeMinutes("-4"), DEFAULT_REVIEW_NUDGE_MINUTES);
|
|
61
62
|
});
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
test("clampNudgeMinutes: above the ceiling is clamped, not rejected", () => {
|
|
64
65
|
assertEquals(clampNudgeMinutes(100_000), MAX_REVIEW_NUDGE_MINUTES);
|
|
65
66
|
assertEquals(clampNudgeMinutes(String(MAX_REVIEW_NUDGE_MINUTES + 1)), MAX_REVIEW_NUDGE_MINUTES);
|
|
66
67
|
});
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
test("clampNudgeMinutes: an oversized fallback is itself clamped to the ceiling", () => {
|
|
69
70
|
assertEquals(clampNudgeMinutes("", MAX_REVIEW_NUDGE_MINUTES + 50), MAX_REVIEW_NUDGE_MINUTES);
|
|
70
71
|
});
|
package/app/rounds.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Contract for clampRounds — the per-submit round-cap coercion. The submit form renders every
|
|
2
2
|
// field as a text input (the runtime hardcodes type="text"), so the override arrives as a string
|
|
3
|
-
// and a blank field must fall back to the fleet default. Run with `
|
|
4
|
-
import {
|
|
3
|
+
// and a blank field must fall back to the fleet default. Run with `node --test`.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
5
6
|
import { clampCiFixBudget, clampRounds, MAX_CI_FIX_CEILING, MAX_ROUNDS_CEILING } from "./rounds.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
test("blank / absent / non-numeric → fallback", () => {
|
|
8
9
|
assertEquals(clampRounds("", 20), 20);
|
|
9
10
|
assertEquals(clampRounds(" ", 20), 20);
|
|
10
11
|
assertEquals(clampRounds(undefined, 20), 20);
|
|
@@ -13,28 +14,28 @@ Deno.test("blank / absent / non-numeric → fallback", () => {
|
|
|
13
14
|
assertEquals(clampRounds(NaN, 20), 20);
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
test("a valid positive value (string or number) is honoured", () => {
|
|
17
18
|
assertEquals(clampRounds("5", 20), 5);
|
|
18
19
|
assertEquals(clampRounds(30, 20), 30);
|
|
19
20
|
assertEquals(clampRounds(" 12 ", 20), 12);
|
|
20
21
|
});
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
test("fractional values are truncated to a whole round", () => {
|
|
23
24
|
assertEquals(clampRounds("7.9", 20), 7);
|
|
24
25
|
assertEquals(clampRounds(3.2, 20), 3);
|
|
25
26
|
});
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
test("zero and negatives fall back (a cap < 1 would escalate before round 1)", () => {
|
|
28
29
|
assertEquals(clampRounds(0, 20), 20);
|
|
29
30
|
assertEquals(clampRounds("-4", 20), 20);
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
test("above the ceiling is clamped, not rejected", () => {
|
|
33
34
|
assertEquals(clampRounds(1000, 20), MAX_ROUNDS_CEILING);
|
|
34
35
|
assertEquals(clampRounds(String(MAX_ROUNDS_CEILING + 1), 20), MAX_ROUNDS_CEILING);
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
test("an oversized fallback is itself clamped to the ceiling", () => {
|
|
38
39
|
// A blank/invalid input falls back — but the fallback must not bypass the ceiling.
|
|
39
40
|
assertEquals(clampRounds("", MAX_ROUNDS_CEILING + 50), MAX_ROUNDS_CEILING);
|
|
40
41
|
assertEquals(clampRounds(" ", MAX_ROUNDS_CEILING + 1), MAX_ROUNDS_CEILING);
|
|
@@ -45,7 +46,7 @@ Deno.test("an oversized fallback is itself clamped to the ceiling", () => {
|
|
|
45
46
|
// clampCiFixBudget — the merge stage's CI-fix attempt budget (NANO_PR_MAX_CI_FIX_ROUNDS). Unlike
|
|
46
47
|
// clampRounds it ALLOWS 0, because 0 is a meaningful setting: disable auto-fix so a blocked PR
|
|
47
48
|
// escalates to a human immediately.
|
|
48
|
-
|
|
49
|
+
test("ci-fix budget: blank / non-numeric / negative → fallback", () => {
|
|
49
50
|
assertEquals(clampCiFixBudget("", 3), 3);
|
|
50
51
|
assertEquals(clampCiFixBudget(" ", 3), 3);
|
|
51
52
|
assertEquals(clampCiFixBudget(undefined, 3), 3);
|
|
@@ -55,20 +56,20 @@ Deno.test("ci-fix budget: blank / non-numeric / negative → fallback", () => {
|
|
|
55
56
|
assertEquals(clampCiFixBudget("-1", 3), 3);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
test("ci-fix budget: 0 is honoured (disables auto-fix), unlike clampRounds", () => {
|
|
59
60
|
assertEquals(clampCiFixBudget(0, 3), 0);
|
|
60
61
|
assertEquals(clampCiFixBudget("0", 3), 0);
|
|
61
62
|
// Contrast: the round cap treats 0 as invalid and falls back.
|
|
62
63
|
assertEquals(clampRounds(0, 3), 3);
|
|
63
64
|
});
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
test("ci-fix budget: a valid positive value is honoured and truncated", () => {
|
|
66
67
|
assertEquals(clampCiFixBudget("5", 3), 5);
|
|
67
68
|
assertEquals(clampCiFixBudget(2, 3), 2);
|
|
68
69
|
assertEquals(clampCiFixBudget("4.8", 3), 4);
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
test("ci-fix budget: above the ceiling is clamped; oversized fallback too", () => {
|
|
72
73
|
assertEquals(clampCiFixBudget(1000, 3), MAX_CI_FIX_CEILING);
|
|
73
74
|
assertEquals(clampCiFixBudget("", MAX_CI_FIX_CEILING + 5), MAX_CI_FIX_CEILING);
|
|
74
75
|
});
|
package/app/service.test.ts
CHANGED
|
@@ -5,30 +5,25 @@
|
|
|
5
5
|
// dead "(no question provided)" question on the re-opened PR (the same stale-row class the plan
|
|
6
6
|
// loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
|
-
import {
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals } from "#test-assert";
|
|
9
10
|
import { pollIncidentsImpl, submitPr } from "./service.ts";
|
|
10
11
|
|
|
11
|
-
// deno-lint-ignore no-explicit-any
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
14
|
-
// deno-lint-ignore no-explicit-any
|
|
15
14
|
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
16
15
|
all: () => Promise.resolve([...rows]),
|
|
17
|
-
// deno-lint-ignore no-explicit-any
|
|
18
16
|
find: (q: any) =>
|
|
19
17
|
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
18
|
insert: (r: any) => {
|
|
22
19
|
rows.push(r);
|
|
23
20
|
return Promise.resolve(r);
|
|
24
21
|
},
|
|
25
|
-
// deno-lint-ignore no-explicit-any
|
|
26
22
|
update: (k: any, patch: any) => {
|
|
27
23
|
const r = rows.find((x) => x[key] === k);
|
|
28
24
|
if (r) Object.assign(r, patch);
|
|
29
25
|
return Promise.resolve(r);
|
|
30
26
|
},
|
|
31
|
-
// deno-lint-ignore no-explicit-any
|
|
32
27
|
delete: (k: any) => {
|
|
33
28
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
34
29
|
return Promise.resolve();
|
|
@@ -37,18 +32,18 @@ function memTable(rows: any[], key: string) {
|
|
|
37
32
|
}
|
|
38
33
|
|
|
39
34
|
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
40
|
-
const prevMode =
|
|
41
|
-
const prevTok =
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
36
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
37
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token"; // no token below -> fetchPrMeta returns null
|
|
38
|
+
delete process.env["GITHUB_TOKEN"];
|
|
44
39
|
return run().finally(() => {
|
|
45
|
-
if (prevMode !== undefined)
|
|
46
|
-
else
|
|
47
|
-
if (prevTok !== undefined)
|
|
40
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
41
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
42
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
48
43
|
});
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
|
|
46
|
+
test("re-submit of a cancelled PR clears stale open escalations + the denormalised pointer", async () => {
|
|
52
47
|
await withGithubOff(async () => {
|
|
53
48
|
const PR_KEY = "owner/repo#42";
|
|
54
49
|
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
@@ -74,11 +69,9 @@ Deno.test("re-submit of a cancelled PR clears stale open escalations + the denor
|
|
|
74
69
|
};
|
|
75
70
|
const data = {
|
|
76
71
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
77
|
-
// deno-lint-ignore no-explicit-any
|
|
78
72
|
} as any;
|
|
79
73
|
const engine = {
|
|
80
74
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
81
|
-
// deno-lint-ignore no-explicit-any
|
|
82
75
|
} as any;
|
|
83
76
|
|
|
84
77
|
await submitPr(data, engine, {
|
|
@@ -125,7 +118,7 @@ function incidentFetch(byInstance: Record<string, unknown[]>) {
|
|
|
125
118
|
};
|
|
126
119
|
}
|
|
127
120
|
|
|
128
|
-
|
|
121
|
+
test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it, leaving status untouched", async () => {
|
|
129
122
|
const row = {
|
|
130
123
|
pr_key: "owner/repo#7",
|
|
131
124
|
repo: "owner/repo",
|
|
@@ -141,7 +134,6 @@ Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears
|
|
|
141
134
|
};
|
|
142
135
|
const data = {
|
|
143
136
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
144
|
-
// deno-lint-ignore no-explicit-any
|
|
145
137
|
} as any;
|
|
146
138
|
const headers = { "content-type": "application/json" };
|
|
147
139
|
|
|
@@ -173,7 +165,7 @@ Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears
|
|
|
173
165
|
assertEquals(row.status, "converging");
|
|
174
166
|
});
|
|
175
167
|
|
|
176
|
-
|
|
168
|
+
test("pollIncidents never queries a PR with no live instance and clears any stale incident", async () => {
|
|
177
169
|
const noKey = {
|
|
178
170
|
pr_key: "owner/repo#8",
|
|
179
171
|
status: "converging",
|
|
@@ -195,7 +187,6 @@ Deno.test("pollIncidents never queries a PR with no live instance and clears any
|
|
|
195
187
|
};
|
|
196
188
|
const data = {
|
|
197
189
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
198
|
-
// deno-lint-ignore no-explicit-any
|
|
199
190
|
} as any;
|
|
200
191
|
const headers = { "content-type": "application/json" };
|
|
201
192
|
|
|
@@ -215,7 +206,7 @@ Deno.test("pollIncidents never queries a PR with no live instance and clears any
|
|
|
215
206
|
assertEquals(terminal.incident_message, null);
|
|
216
207
|
});
|
|
217
208
|
|
|
218
|
-
|
|
209
|
+
test("pollIncidents picks the oldest incident by creationTime, sorting a missing timestamp last", async () => {
|
|
219
210
|
const row = {
|
|
220
211
|
pr_key: "owner/repo#11",
|
|
221
212
|
status: "converging",
|
|
@@ -229,7 +220,6 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
|
|
|
229
220
|
};
|
|
230
221
|
const data = {
|
|
231
222
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
232
|
-
// deno-lint-ignore no-explicit-any
|
|
233
223
|
} as any;
|
|
234
224
|
const headers = { "content-type": "application/json" };
|
|
235
225
|
|
|
@@ -259,7 +249,7 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
|
|
|
259
249
|
// source also keeps the returned key aligned with the DB-persisted `String(...)` value and dodges
|
|
260
250
|
// JS 53-bit precision limits for large 64-bit keys (which is why keys travel as strings in
|
|
261
251
|
// practice). `submitPr` must stringify it both in the returned body and the persisted row.
|
|
262
|
-
|
|
252
|
+
test("submitPr stringifies a numeric processInstanceKey (contract: string | null)", async () => {
|
|
263
253
|
await withGithubOff(async () => {
|
|
264
254
|
const PR_KEY = "owner/repo#7";
|
|
265
255
|
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
@@ -269,14 +259,12 @@ Deno.test("submitPr stringifies a numeric processInstanceKey (contract: string |
|
|
|
269
259
|
};
|
|
270
260
|
const data = {
|
|
271
261
|
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
272
|
-
// deno-lint-ignore no-explicit-any
|
|
273
262
|
} as any;
|
|
274
263
|
const engine = {
|
|
275
264
|
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
276
265
|
// (number vs the `string | null` contract). Kept within MAX_SAFE_INTEGER so the fixture
|
|
277
266
|
// itself is exact; true 64-bit keys travel as strings for the same precision reason.
|
|
278
267
|
createInstance: () => Promise.resolve({ processInstanceKey: 2251799813685249 }),
|
|
279
|
-
// deno-lint-ignore no-explicit-any
|
|
280
268
|
} as any;
|
|
281
269
|
|
|
282
270
|
const res = await submitPr(data, engine, {
|
|
@@ -286,7 +274,6 @@ Deno.test("submitPr stringifies a numeric processInstanceKey (contract: string |
|
|
|
286
274
|
prKey: PR_KEY,
|
|
287
275
|
});
|
|
288
276
|
|
|
289
|
-
// deno-lint-ignore no-explicit-any
|
|
290
277
|
const processKey = (res as any).processKey;
|
|
291
278
|
assertEquals(typeof processKey, "string");
|
|
292
279
|
assertEquals(processKey, "2251799813685249");
|
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/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,7 +5,7 @@
|
|
|
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
|
|
@@ -39,20 +39,11 @@ function readJson(path: string): Record<string, unknown> | null {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Read an env var
|
|
43
|
-
* 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.
|
|
44
43
|
*/
|
|
45
44
|
export function envVar(name: string): string | null {
|
|
46
|
-
const fromProcess =
|
|
45
|
+
const fromProcess = process.env[name];
|
|
47
46
|
if (typeof fromProcess === "string" && fromProcess.trim()) return fromProcess.trim();
|
|
48
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
49
|
-
const deno = (globalThis as { Deno?: { env?: { get?(k: string): string | undefined } } }).Deno;
|
|
50
|
-
try {
|
|
51
|
-
const fromDeno = deno?.env?.get?.(name);
|
|
52
|
-
if (typeof fromDeno === "string" && fromDeno.trim()) return fromDeno.trim();
|
|
53
|
-
} catch {
|
|
54
|
-
// Env access denied — treat as unset.
|
|
55
|
-
}
|
|
56
47
|
return null;
|
|
57
48
|
}
|
|
58
49
|
|
|
@@ -159,13 +150,8 @@ function gitBranch(): string | null {
|
|
|
159
150
|
}
|
|
160
151
|
|
|
161
152
|
function runtime(): string {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
165
|
-
const deno = (globalThis as { Deno?: { version?: { deno?: string } } }).Deno;
|
|
166
|
-
if (deno?.version?.deno) return `deno ${deno.version.deno}`;
|
|
167
|
-
if (proc?.version) return `node ${proc.version}`;
|
|
168
|
-
return "unknown";
|
|
153
|
+
// Node exposes `process.version` (e.g. "v24.15.0").
|
|
154
|
+
return process.version ? `node ${process.version}` : "unknown";
|
|
169
155
|
}
|
|
170
156
|
|
|
171
157
|
export interface VersionInfo {
|
|
@@ -185,7 +171,6 @@ export interface VersionInfo {
|
|
|
185
171
|
* tree ONCE at module load rather than re-reading files (`.git`, package.jsons) on every request.
|
|
186
172
|
*/
|
|
187
173
|
const STATIC: Omit<VersionInfo, "uptimeSeconds"> = (() => {
|
|
188
|
-
const proc = globalThis.process;
|
|
189
174
|
const pkg = appPackage();
|
|
190
175
|
return Object.freeze({
|
|
191
176
|
name: appName(pkg),
|
|
@@ -194,7 +179,7 @@ const STATIC: Omit<VersionInfo, "uptimeSeconds"> = (() => {
|
|
|
194
179
|
gitSha: gitSha(),
|
|
195
180
|
gitBranch: gitBranch(),
|
|
196
181
|
runtime: runtime(),
|
|
197
|
-
pid: typeof
|
|
182
|
+
pid: typeof process.pid === "number" ? process.pid : null,
|
|
198
183
|
startedAt: STARTED_AT.toISOString(),
|
|
199
184
|
});
|
|
200
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 },
|