@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/blackboard.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
|
|
2
|
-
import {
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
3
4
|
import type { DataLayer } from "@nanobpm/urban";
|
|
4
5
|
import {
|
|
5
6
|
appendEntry,
|
|
@@ -17,16 +18,12 @@ import {
|
|
|
17
18
|
|
|
18
19
|
// A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
|
|
19
20
|
// blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
|
|
20
|
-
// deno-lint-ignore no-explicit-any
|
|
21
21
|
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
22
|
-
// deno-lint-ignore no-explicit-any
|
|
23
22
|
const stores: Record<string, any[]> = {};
|
|
24
23
|
const seq: Record<string, number> = {};
|
|
25
24
|
function tbl(name: string, pk = "id") {
|
|
26
|
-
// deno-lint-ignore no-explicit-any
|
|
27
25
|
const rows = (stores[name] ??= [] as any[]);
|
|
28
26
|
return {
|
|
29
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
30
27
|
async insert(row: any) {
|
|
31
28
|
if (pk === "id") {
|
|
32
29
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
@@ -36,22 +33,19 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
36
33
|
rows.push({ ...row });
|
|
37
34
|
return row[pk];
|
|
38
35
|
},
|
|
39
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
40
36
|
async find(where: any = {}) {
|
|
41
37
|
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
42
38
|
},
|
|
43
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
44
39
|
async findOne(where: any = {}) {
|
|
45
40
|
return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
46
41
|
},
|
|
47
42
|
};
|
|
48
43
|
}
|
|
49
|
-
// deno-lint-ignore no-explicit-any
|
|
50
44
|
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
51
45
|
return { data, stores };
|
|
52
46
|
}
|
|
53
47
|
|
|
54
|
-
|
|
48
|
+
test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
|
|
55
49
|
const a = mintBlackboardToken();
|
|
56
50
|
const b = mintBlackboardToken();
|
|
57
51
|
assert(a !== b, "two mints must differ");
|
|
@@ -59,12 +53,12 @@ Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
|
|
|
59
53
|
assert(a.length >= 32, "token should carry enough entropy");
|
|
60
54
|
});
|
|
61
55
|
|
|
62
|
-
|
|
56
|
+
test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
|
|
63
57
|
assertEquals(publicBaseUrl("https://pr.example.com/"), "https://pr.example.com");
|
|
64
58
|
assertEquals(publicBaseUrl("https://pr.example.com///"), "https://pr.example.com");
|
|
65
59
|
});
|
|
66
60
|
|
|
67
|
-
|
|
61
|
+
test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
|
|
68
62
|
const prev = process.env.NANO_PR_BASE_URL;
|
|
69
63
|
delete process.env.NANO_PR_BASE_URL;
|
|
70
64
|
try {
|
|
@@ -77,14 +71,14 @@ Deno.test("publicBaseUrl: a blank/whitespace override falls back instead of yiel
|
|
|
77
71
|
}
|
|
78
72
|
});
|
|
79
73
|
|
|
80
|
-
|
|
74
|
+
test("blackboardUrl: capability token rides the query string", () => {
|
|
81
75
|
assertEquals(
|
|
82
76
|
blackboardUrl("tok+en/x", "https://h"),
|
|
83
77
|
"https://h/hooks/blackboard?token=tok%2Ben%2Fx",
|
|
84
78
|
);
|
|
85
79
|
});
|
|
86
80
|
|
|
87
|
-
|
|
81
|
+
test("normalizeKind: valid passes through, anything else becomes note", () => {
|
|
88
82
|
assertEquals(normalizeKind("file-claim"), "file-claim");
|
|
89
83
|
assertEquals(normalizeKind("constraint-change"), "constraint-change");
|
|
90
84
|
assertEquals(normalizeKind("learning"), "learning");
|
|
@@ -92,7 +86,7 @@ Deno.test("normalizeKind: valid passes through, anything else becomes note", ()
|
|
|
92
86
|
assertEquals(normalizeKind(undefined), "note");
|
|
93
87
|
});
|
|
94
88
|
|
|
95
|
-
|
|
89
|
+
test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
|
|
96
90
|
const url = "https://h/hooks/blackboard?token=abc";
|
|
97
91
|
const brief = renderCoordinationBrief(url);
|
|
98
92
|
assert(brief.startsWith("\n\n---"), "must own a leading separator (appendPrompt adds none)");
|
|
@@ -112,7 +106,7 @@ Deno.test("renderCoordinationBrief: leads with a separator and teaches the proto
|
|
|
112
106
|
assertStringIncludes(brief, "Share what you learn");
|
|
113
107
|
});
|
|
114
108
|
|
|
115
|
-
|
|
109
|
+
test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
|
|
116
110
|
const { data } = memData();
|
|
117
111
|
await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
|
|
118
112
|
assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
|
|
@@ -120,7 +114,7 @@ Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise",
|
|
|
120
114
|
assertEquals(await planKeyForToken(data, ""), undefined);
|
|
121
115
|
});
|
|
122
116
|
|
|
123
|
-
|
|
117
|
+
test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
|
|
124
118
|
const { data } = memData();
|
|
125
119
|
await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
|
|
126
120
|
await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
|
|
@@ -133,14 +127,14 @@ Deno.test("appendEntry + readBlackboard: append, encode files, read back in writ
|
|
|
133
127
|
assertEquals(entries[1].author_task, "gap-8");
|
|
134
128
|
});
|
|
135
129
|
|
|
136
|
-
|
|
130
|
+
test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
|
|
137
131
|
const { data } = memData();
|
|
138
132
|
await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
|
|
139
133
|
const [e] = await readBlackboard(data, "p");
|
|
140
134
|
assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
|
|
141
135
|
});
|
|
142
136
|
|
|
143
|
-
|
|
137
|
+
test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
|
|
144
138
|
const { data } = memData();
|
|
145
139
|
await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
|
|
146
140
|
const [e] = await readBlackboard(data, "p");
|
|
@@ -148,7 +142,7 @@ Deno.test("appendEntry: a missing author defaults to 'system' and kind is normal
|
|
|
148
142
|
assertEquals(e.kind, "note");
|
|
149
143
|
});
|
|
150
144
|
|
|
151
|
-
|
|
145
|
+
test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
|
|
152
146
|
const { data, stores } = memData();
|
|
153
147
|
const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
154
148
|
const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
@@ -158,15 +152,13 @@ Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op
|
|
|
158
152
|
assertEquals(stores["plan_blackboard"].length, 1, "exactly one row persisted");
|
|
159
153
|
});
|
|
160
154
|
|
|
161
|
-
|
|
155
|
+
test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
|
|
162
156
|
// Simulate the concurrency window: two POSTs share a dedupe_key, both miss the findOne
|
|
163
157
|
// pre-check, then insert loses the race on the UNIQUE (plan_key, dedupe_key) index. The
|
|
164
158
|
// catch branch must re-read the winner's row and return it rather than propagate the throw.
|
|
165
159
|
const winner = { id: 42, plan_key: "p", dedupe_key: "t:claim:1", author_task: "t", body: "claim" };
|
|
166
160
|
let preCheckDone = false;
|
|
167
|
-
// deno-lint-ignore no-explicit-any
|
|
168
161
|
const table: any = {
|
|
169
|
-
// deno-lint-ignore require-await
|
|
170
162
|
async findOne() {
|
|
171
163
|
// Pre-check misses (row not yet visible); the recovery read after the collision hits.
|
|
172
164
|
if (!preCheckDone) {
|
|
@@ -175,21 +167,19 @@ Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500
|
|
|
175
167
|
}
|
|
176
168
|
return winner;
|
|
177
169
|
},
|
|
178
|
-
// deno-lint-ignore require-await
|
|
179
170
|
async insert() {
|
|
180
171
|
throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
|
|
181
172
|
code: "SQLITE_CONSTRAINT_UNIQUE",
|
|
182
173
|
});
|
|
183
174
|
},
|
|
184
175
|
};
|
|
185
|
-
// deno-lint-ignore no-explicit-any
|
|
186
176
|
const data = { table: () => table } as any as DataLayer;
|
|
187
177
|
const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
|
|
188
178
|
assertEquals(res.inserted, false, "a lost race is not a fresh insert");
|
|
189
179
|
assertEquals(res.id, 42, "returns the winning row's id");
|
|
190
180
|
});
|
|
191
181
|
|
|
192
|
-
|
|
182
|
+
test("appendEntry: a blank body is rejected", async () => {
|
|
193
183
|
const { data } = memData();
|
|
194
184
|
let threw = false;
|
|
195
185
|
try {
|
|
@@ -200,7 +190,7 @@ Deno.test("appendEntry: a blank body is rejected", async () => {
|
|
|
200
190
|
assert(threw, "blank body must throw");
|
|
201
191
|
});
|
|
202
192
|
|
|
203
|
-
|
|
193
|
+
test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
|
|
204
194
|
const { data } = memData();
|
|
205
195
|
await appendEntry(data, "p", { body: "one" });
|
|
206
196
|
await appendEntry(data, "p", { body: "two" });
|
|
@@ -210,7 +200,7 @@ Deno.test("readBlackboard: since returns only newer entries (incremental poll)",
|
|
|
210
200
|
assertEquals(tail.map((e) => e.body), ["two", "three"]);
|
|
211
201
|
});
|
|
212
202
|
|
|
213
|
-
|
|
203
|
+
test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
|
|
214
204
|
const { data } = memData();
|
|
215
205
|
await appendEntry(data, "p", { body: "one" });
|
|
216
206
|
await appendEntry(data, "p", { body: "two" });
|
|
@@ -231,14 +221,14 @@ Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to
|
|
|
231
221
|
assertEquals(next.cursor, next.entries[0].id);
|
|
232
222
|
});
|
|
233
223
|
|
|
234
|
-
|
|
224
|
+
test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
|
|
235
225
|
const { data } = memData();
|
|
236
226
|
const page = await readBlackboardPage(data, "empty");
|
|
237
227
|
assertEquals(page.entries, []);
|
|
238
228
|
assertEquals(page.cursor, 0);
|
|
239
229
|
});
|
|
240
230
|
|
|
241
|
-
|
|
231
|
+
test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
|
|
242
232
|
const { data } = memData();
|
|
243
233
|
await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
|
|
244
234
|
|
|
@@ -251,7 +241,7 @@ Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is
|
|
|
251
241
|
assertEquals(conflicts[0].author_task, "gap-2", "reports the first (winning) claimer");
|
|
252
242
|
});
|
|
253
243
|
|
|
254
|
-
|
|
244
|
+
test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
|
|
255
245
|
const { data } = memData();
|
|
256
246
|
await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
|
|
257
247
|
await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
|
|
@@ -265,7 +255,7 @@ Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim ent
|
|
|
265
255
|
assertEquals(await detectFileClaimConflicts(data, "p", { author_task: "gap-9", files: [] }), []);
|
|
266
256
|
});
|
|
267
257
|
|
|
268
|
-
|
|
258
|
+
test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
|
|
269
259
|
const { data } = memData();
|
|
270
260
|
const prior = await appendEntry(data, "p", {
|
|
271
261
|
author_task: "gap-2",
|
|
@@ -299,7 +289,7 @@ Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims
|
|
|
299
289
|
assert(Number(later.id) > Number(mine.id));
|
|
300
290
|
});
|
|
301
291
|
|
|
302
|
-
|
|
292
|
+
test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
|
|
303
293
|
// Extended SQLite codes.
|
|
304
294
|
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_UNIQUE" })));
|
|
305
295
|
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_PRIMARYKEY" })));
|
package/app/ensure-pr.test.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// `pull_requests` FK parent before a child (`rounds`/`escalations`/`merges`) insert, so an
|
|
3
3
|
// engine/app.db store desync never parks an opaque `FOREIGN KEY constraint failed` incident
|
|
4
4
|
// (observed on convergence-loop instance 94).
|
|
5
|
-
import {
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
6
7
|
import type { DataLayer } from "@nanobpm/urban";
|
|
7
8
|
import { canonicalPrUrl, ensurePr } from "./service.ts";
|
|
8
9
|
|
|
@@ -18,11 +19,9 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
|
|
|
18
19
|
let insertCalls = 0;
|
|
19
20
|
function tbl(name: string, key: string) {
|
|
20
21
|
return {
|
|
21
|
-
// deno-lint-ignore require-await
|
|
22
22
|
async get(id: string) {
|
|
23
23
|
return rows.get(id);
|
|
24
24
|
},
|
|
25
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
26
25
|
async insert(row: any) {
|
|
27
26
|
insertCalls++;
|
|
28
27
|
if (opts.throwOnInsert) {
|
|
@@ -34,14 +33,13 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
|
|
|
34
33
|
},
|
|
35
34
|
};
|
|
36
35
|
}
|
|
37
|
-
// deno-lint-ignore no-explicit-any
|
|
38
36
|
const data = { table: (n: string, k: string) => tbl(n, k) } as any as DataLayer;
|
|
39
37
|
return { data, rows, get insertCalls() {
|
|
40
38
|
return insertCalls;
|
|
41
39
|
} };
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
test("ensurePr is a no-op when the parent already exists", async () => {
|
|
45
43
|
const mem = memData();
|
|
46
44
|
const { data, rows } = mem;
|
|
47
45
|
rows.set("o/r#1", { pr_key: "o/r#1", status: "converging" });
|
|
@@ -52,7 +50,7 @@ Deno.test("ensurePr is a no-op when the parent already exists", async () => {
|
|
|
52
50
|
assertEquals(mem.insertCalls, 0, "insert is never attempted — the no-write guarantee holds");
|
|
53
51
|
});
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
|
|
56
54
|
const { data, rows } = memData();
|
|
57
55
|
await ensurePr(data, { prKey: "o/r#2", repo: "o/r", number: 2, round: 3 });
|
|
58
56
|
const row = rows.get("o/r#2")!;
|
|
@@ -65,7 +63,7 @@ Deno.test("ensurePr reconstructs a minimal converging row when the parent is abs
|
|
|
65
63
|
assert(typeof row.abandon_token === "string" && (row.abandon_token as string).length > 0);
|
|
66
64
|
});
|
|
67
65
|
|
|
68
|
-
|
|
66
|
+
test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
|
|
69
67
|
const { data, rows } = memData();
|
|
70
68
|
await ensurePr(data, { prKey: "o/r#5", repo: "o/r", number: 5 });
|
|
71
69
|
assertEquals(
|
|
@@ -75,7 +73,7 @@ Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none i
|
|
|
75
73
|
);
|
|
76
74
|
});
|
|
77
75
|
|
|
78
|
-
|
|
76
|
+
test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
|
|
79
77
|
const { data, rows } = memData();
|
|
80
78
|
await ensurePr(data, { prKey: "o/r#7", repo: "o/r", number: 7, abandonToken: "TOK-en_123" });
|
|
81
79
|
assertEquals(
|
|
@@ -85,27 +83,27 @@ Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one
|
|
|
85
83
|
);
|
|
86
84
|
});
|
|
87
85
|
|
|
88
|
-
|
|
86
|
+
test("ensurePr mints a token when none is supplied", async () => {
|
|
89
87
|
const { data, rows } = memData();
|
|
90
88
|
await ensurePr(data, { prKey: "o/r#8", repo: "o/r", number: 8 });
|
|
91
89
|
const tok = rows.get("o/r#8")!.abandon_token;
|
|
92
90
|
assert(typeof tok === "string" && (tok as string).length > 0, "a fresh token is minted as a fallback");
|
|
93
91
|
});
|
|
94
92
|
|
|
95
|
-
|
|
93
|
+
test("ensurePr prefers an explicit url over the canonical one", async () => {
|
|
96
94
|
const { data, rows } = memData();
|
|
97
95
|
const url = "https://github.com/o/r/pull/9";
|
|
98
96
|
await ensurePr(data, { prKey: "o/r#9", repo: "o/r", number: 9, url });
|
|
99
97
|
assertEquals(rows.get("o/r#9")!.url, url);
|
|
100
98
|
});
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
test("ensurePr swallows an insert race when the row appears anyway", async () => {
|
|
103
101
|
// insert throws (unique-violation / concurrent writer) but the row is now present → healed.
|
|
104
102
|
const { data } = memData({ throwOnInsert: true, seedOnThrow: true });
|
|
105
103
|
await ensurePr(data, { prKey: "o/r#3", repo: "o/r", number: 3 });
|
|
106
104
|
});
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
|
|
109
107
|
const { data } = memData({ throwOnInsert: true, seedOnThrow: false });
|
|
110
108
|
let threw = false;
|
|
111
109
|
try {
|
package/app/github.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Unit tests for `fetchPrFiles` token-transport paging (issue #58): the D2 conflict-scan must get
|
|
2
2
|
// a COMPLETE file list or a thrown error — never a silently truncated one that under-approximates
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
5
6
|
import { fetchPrFiles } from "./github.ts";
|
|
6
7
|
|
|
7
8
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -25,31 +26,31 @@ function stubFetch(pages: number[]) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
|
|
28
|
-
const prevMode =
|
|
29
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
29
30
|
const prevFetch = globalThis.fetch;
|
|
30
|
-
|
|
31
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
31
32
|
globalThis.fetch = stubFetch(pages) as typeof fetch;
|
|
32
33
|
try {
|
|
33
34
|
return await fn();
|
|
34
35
|
} finally {
|
|
35
36
|
globalThis.fetch = prevFetch;
|
|
36
|
-
if (prevMode === undefined)
|
|
37
|
-
else
|
|
37
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
38
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
|
|
42
43
|
const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
|
|
43
44
|
assertEquals(files?.length, 142);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
|
|
47
48
|
// 5 full pages, but no `rel="next"` on the last → the list is exactly complete at the cap.
|
|
48
49
|
const files = await withTokenTransport([100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 2, "tok"));
|
|
49
50
|
assertEquals(files?.length, 500);
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
|
|
53
54
|
// 6 pages available but only 5 fetched → the 5th page still advertises `rel="next"`.
|
|
54
55
|
await assertRejects(
|
|
55
56
|
() => withTokenTransport([100, 100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 3, "tok")),
|
package/app/github.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// • auto — prefer `gh` when the binary is present; otherwise fall back to `token`.
|
|
10
10
|
//
|
|
11
11
|
// The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
|
|
12
|
-
// Cross-runtime: runs under Node (`node:child_process`)
|
|
12
|
+
// Cross-runtime: runs under Node (`node:child_process`).
|
|
13
13
|
|
|
14
14
|
/** A GitHub pull-request review, narrowed to the fields the poller needs. */
|
|
15
15
|
export interface GhReview {
|
|
@@ -26,30 +26,10 @@ export function githubTransport(): GithubTransport {
|
|
|
26
26
|
return t === "gh" || t === "token" ? t : "auto";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
interface DenoCommandCtor {
|
|
30
|
-
new (
|
|
31
|
-
command: string,
|
|
32
|
-
options: { args: string[]; stdout: "piped"; stderr: "piped" },
|
|
33
|
-
): { output(): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
29
|
/** Run the host `gh` CLI with the given args (no shell — args are passed as a vector, so a
|
|
37
30
|
* `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a
|
|
38
31
|
* non-zero exit with stderr as the message. */
|
|
39
32
|
async function runGh(args: string[]): Promise<string> {
|
|
40
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
41
|
-
const g = globalThis as { Deno?: { Command?: DenoCommandCtor } };
|
|
42
|
-
if (g.Deno?.Command) {
|
|
43
|
-
const { code, stdout, stderr } = await new g.Deno.Command("gh", {
|
|
44
|
-
args,
|
|
45
|
-
stdout: "piped",
|
|
46
|
-
stderr: "piped",
|
|
47
|
-
}).output();
|
|
48
|
-
if (code !== 0) {
|
|
49
|
-
throw new Error(new TextDecoder().decode(stderr).trim() || `gh exited ${code}`);
|
|
50
|
-
}
|
|
51
|
-
return new TextDecoder().decode(stdout);
|
|
52
|
-
}
|
|
53
33
|
const { execFile } = await import("node:child_process");
|
|
54
34
|
return await new Promise<string>((resolve, reject) => {
|
|
55
35
|
execFile(
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// crashed) run stuck "active" in the UI — the exact drift Copilot flagged on #96. This ties the
|
|
5
5
|
// manifest to the code's single source of truth for "done" (TERMINAL_STATUSES / PLAN_TERMINAL_
|
|
6
6
|
// STATUSES) so the two can't diverge silently.
|
|
7
|
-
import {
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
8
10
|
import { TERMINAL_STATUSES } from "./service.ts";
|
|
9
11
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
10
12
|
|
|
@@ -16,7 +18,7 @@ interface Binding {
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
async function bindings(): Promise<Binding[]> {
|
|
19
|
-
const manifest = JSON.parse(
|
|
21
|
+
const manifest = JSON.parse(readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"));
|
|
20
22
|
return manifest.instanceTracking as Binding[];
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -26,7 +28,7 @@ function bindingFor(all: Binding[], table: string): Binding {
|
|
|
26
28
|
return b;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
|
|
30
32
|
const b = bindingFor(await bindings(), "pull_requests");
|
|
31
33
|
for (const terminal of TERMINAL_STATUSES) {
|
|
32
34
|
assert(
|
|
@@ -40,7 +42,7 @@ Deno.test("instanceTracking: pull_requests activeStatuses excludes every termina
|
|
|
40
42
|
// pull_requests row can hold while a live engine instance still backs it (see app/service.ts merge
|
|
41
43
|
// poller: converging/waiting_review/escalated + the merge-stage waiting_deps/waiting_merge/
|
|
42
44
|
// waiting_lane/queued/merging). If a new one is added to the flow, add it here AND to the manifest.
|
|
43
|
-
|
|
45
|
+
test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
|
|
44
46
|
const inFlight = [
|
|
45
47
|
"converging",
|
|
46
48
|
"waiting_review",
|
|
@@ -59,14 +61,14 @@ Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight
|
|
|
59
61
|
for (const s of inFlight) assert(!TERMINAL_STATUSES.includes(s));
|
|
60
62
|
});
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
|
|
63
65
|
const b = bindingFor(await bindings(), "plans");
|
|
64
66
|
for (const terminal of PLAN_TERMINAL_STATUSES) {
|
|
65
67
|
assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
|
|
66
68
|
}
|
|
67
69
|
});
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
|
|
70
72
|
const inFlight = ["planning", "dispatched"];
|
|
71
73
|
const b = bindingFor(await bindings(), "plans");
|
|
72
74
|
assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Unit tests for the merge-exclusion graph + conflict-scan (D1/D2, issues #57 #58 / #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
|
clearExclusions,
|
|
@@ -13,58 +14,48 @@ import {
|
|
|
13
14
|
import { computeWaves } from "./waves.ts";
|
|
14
15
|
|
|
15
16
|
// In-memory record-gateway fake (insert/find/findOne/update/delete), mirroring the app tests.
|
|
16
|
-
// deno-lint-ignore no-explicit-any
|
|
17
17
|
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
18
|
-
// deno-lint-ignore no-explicit-any
|
|
19
18
|
const stores: Record<string, any[]> = {};
|
|
20
19
|
const seq: Record<string, number> = {};
|
|
21
20
|
function tbl(name: string, pk = "id") {
|
|
22
|
-
// deno-lint-ignore no-explicit-any
|
|
23
21
|
const rows = (stores[name] ??= [] as any[]);
|
|
24
|
-
// deno-lint-ignore no-explicit-any
|
|
25
22
|
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
26
23
|
return {
|
|
27
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
28
24
|
async insert(row: any) {
|
|
29
25
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
30
26
|
rows.push({ id, ...row });
|
|
31
27
|
return id;
|
|
32
28
|
},
|
|
33
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
34
29
|
async find(where: any = {}) {
|
|
35
30
|
return rows.filter((r) => match(r, where));
|
|
36
31
|
},
|
|
37
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
38
32
|
async findOne(where: any = {}) {
|
|
39
33
|
return rows.find((r) => match(r, where));
|
|
40
34
|
},
|
|
41
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
42
35
|
async update(id: any, patch: any) {
|
|
43
36
|
const r = rows.find((row) => row.id === id);
|
|
44
37
|
if (r) Object.assign(r, patch);
|
|
45
38
|
},
|
|
46
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
47
39
|
async delete(id: any) {
|
|
48
40
|
const i = rows.findIndex((row) => row.id === id);
|
|
49
41
|
if (i >= 0) rows.splice(i, 1);
|
|
50
42
|
},
|
|
51
43
|
};
|
|
52
44
|
}
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
45
|
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
55
46
|
return { data, stores };
|
|
56
47
|
}
|
|
57
48
|
|
|
58
49
|
const files = (e: ExclusionEdge) => e.files;
|
|
59
50
|
|
|
60
|
-
|
|
51
|
+
test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
|
|
61
52
|
assertEquals(normalizePair("b", "a"), ["a", "b"]);
|
|
62
53
|
assertEquals(normalizePair("a", "b"), ["a", "b"]);
|
|
63
54
|
assertEquals(normalizePair("a", "a"), null, "a task never excludes itself");
|
|
64
55
|
assertEquals(normalizePair("", "a"), null);
|
|
65
56
|
});
|
|
66
57
|
|
|
67
|
-
|
|
58
|
+
test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
|
|
68
59
|
const edges = deriveExclusions(
|
|
69
60
|
new Map([
|
|
70
61
|
["gap-2", ["engine/tests.rs", "engine/state.rs"]],
|
|
@@ -83,14 +74,14 @@ Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sor
|
|
|
83
74
|
assert(!edges.some((e) => e.taskA === "gap-5" || e.taskB === "gap-5"), "gap-5 excluded (no overlap)");
|
|
84
75
|
});
|
|
85
76
|
|
|
86
|
-
|
|
77
|
+
test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
|
|
87
78
|
assertEquals(
|
|
88
79
|
deriveExclusions(new Map([["a", ["x.rs"]], ["b", ["y.rs"]], ["c", ["", " "]]])),
|
|
89
80
|
[],
|
|
90
81
|
);
|
|
91
82
|
});
|
|
92
83
|
|
|
93
|
-
|
|
84
|
+
test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
|
|
94
85
|
const { data, stores } = memData();
|
|
95
86
|
const first = await recordExclusions(data, "p", deriveExclusions(
|
|
96
87
|
new Map([["gap-2", ["a.rs"]], ["gap-8", ["a.rs"]]]),
|
|
@@ -109,7 +100,7 @@ Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes
|
|
|
109
100
|
assertEquals(edge.files, ["a.rs", "b.rs"], "files refreshed in place");
|
|
110
101
|
});
|
|
111
102
|
|
|
112
|
-
|
|
103
|
+
test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
|
|
113
104
|
const { data, stores } = memData();
|
|
114
105
|
// The same unordered pair appears twice in one call (second given in the other order + more files).
|
|
115
106
|
// The in-memory map must fold the newly inserted id back so the second occurrence updates in place.
|
|
@@ -123,7 +114,7 @@ Deno.test("recordExclusions: a duplicate pair within one batch folds into an upd
|
|
|
123
114
|
assertEquals(edge.files, ["x.rs", "y.rs"], "later occurrence refreshed files in place");
|
|
124
115
|
});
|
|
125
116
|
|
|
126
|
-
|
|
117
|
+
test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
|
|
127
118
|
const { data } = memData();
|
|
128
119
|
await recordExclusions(data, "p", deriveExclusions(new Map([["a", ["x"]], ["b", ["x"]]])));
|
|
129
120
|
await recordExclusions(data, "q", deriveExclusions(new Map([["c", ["y"]], ["d", ["y"]]])));
|
|
@@ -132,7 +123,7 @@ Deno.test("clearExclusions: drops one plan's graph, leaving others intact", asyn
|
|
|
132
123
|
assertEquals((await readExclusions(data, "q")).length, 1, "the other plan survives");
|
|
133
124
|
});
|
|
134
125
|
|
|
135
|
-
|
|
126
|
+
test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
|
|
136
127
|
// gap-2—gap-8—gap-9 form one chain (transitive shared surface); gap-5 stands alone.
|
|
137
128
|
const edges = deriveExclusions(
|
|
138
129
|
new Map([
|
|
@@ -146,11 +137,11 @@ Deno.test("mergeLanes: connected components are serial landing lanes; singletons
|
|
|
146
137
|
assertEquals(lanes, [["gap-2", "gap-8", "gap-9"], ["gap-5"]]);
|
|
147
138
|
});
|
|
148
139
|
|
|
149
|
-
|
|
140
|
+
test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
|
|
150
141
|
assertEquals(mergeLanes([], ["b", "a", "c"]), [["a"], ["b"], ["c"]]);
|
|
151
142
|
});
|
|
152
143
|
|
|
153
|
-
|
|
144
|
+
test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
|
|
154
145
|
// Two tasks that collide on a shared file but declare no build-on dependency.
|
|
155
146
|
const overlap = new Map([["gap-2", ["engine/tests.rs"]], ["gap-8", ["engine/tests.rs"]]]);
|
|
156
147
|
const edges = deriveExclusions(overlap);
|