@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.
Files changed (51) hide show
  1. package/.github/workflows/ci.yml +7 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -21
  5. package/actions/abandon.test.ts +8 -16
  6. package/actions/blackboard.test.ts +13 -21
  7. package/app/abandon.test.ts +10 -15
  8. package/app/baseGuard.test.ts +7 -6
  9. package/app/blackboard.test.ts +22 -32
  10. package/app/ensure-pr.test.ts +10 -12
  11. package/app/github.test.ts +9 -8
  12. package/app/github.ts +1 -21
  13. package/app/instance-tracking.test.ts +8 -6
  14. package/app/mergeExclusion.test.ts +11 -20
  15. package/app/mergeProtocol.test.ts +14 -13
  16. package/app/mergeRebaseArm.test.ts +8 -6
  17. package/app/mergeTrain.test.ts +10 -9
  18. package/app/persist-escalation.test.ts +9 -30
  19. package/app/persist-round.test.ts +6 -19
  20. package/app/plan.test.ts +19 -42
  21. package/app/record-plan-review.test.ts +8 -7
  22. package/app/retro.test.ts +24 -48
  23. package/app/reviewWait.test.ts +12 -11
  24. package/app/rounds.test.ts +13 -12
  25. package/app/service.test.ts +14 -27
  26. package/app/taskDelta.test.ts +8 -17
  27. package/app/trialMerge.test.ts +4 -3
  28. package/app/version.ts +6 -21
  29. package/app/waves.test.ts +17 -16
  30. package/operations/getVersion.test.ts +8 -12
  31. package/operations/getVersion.ts +2 -3
  32. package/operations/listActivePrs.test.ts +8 -14
  33. package/operations/listActivePrs.ts +2 -3
  34. package/operations/startAndMessage.test.ts +6 -12
  35. package/package.json +6 -4
  36. package/scripts/check-agent-prompts.test.ts +15 -11
  37. package/scripts/layout-bpmn.ts +6 -28
  38. package/scripts/pages-contract.test.ts +14 -13
  39. package/scripts/purge-db.ts +1 -1
  40. package/scripts/upgrade-from-pack.ts +1 -1
  41. package/test/assert.ts +74 -0
  42. package/tsconfig.json +4 -1
  43. package/workers/record-plan-review/worker.test.ts +6 -9
  44. package/workers/record-results/worker.test.ts +5 -8
  45. package/workers/record-trial-merge/worker.test.ts +7 -18
  46. package/workers/record-wave/worker.test.ts +13 -20
  47. package/workers/retro-gather/worker.test.ts +4 -17
  48. package/workers/retro-record/worker.test.ts +8 -27
  49. package/workers/select-wave/worker.test.ts +5 -8
  50. package/deno.json +0 -24
  51. package/deno.lock +0 -1777
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
2
- import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
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
- Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
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
- Deno.test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
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
- Deno.test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
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
- Deno.test("blackboardUrl: capability token rides the query string", () => {
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
- Deno.test("normalizeKind: valid passes through, anything else becomes note", () => {
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
- Deno.test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
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
- Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
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
- Deno.test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
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
- Deno.test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
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
- Deno.test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
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
- Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
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
- Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
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
- Deno.test("appendEntry: a blank body is rejected", async () => {
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
- Deno.test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
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
- Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
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
- Deno.test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
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
- Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
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
- Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
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
- Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
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
- Deno.test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
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" })));
@@ -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 { assert, assertEquals } from "jsr:@std/assert@1";
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
- Deno.test("ensurePr is a no-op when the parent already exists", async () => {
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
- Deno.test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
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
- Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
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
- Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
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
- Deno.test("ensurePr mints a token when none is supplied", async () => {
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
- Deno.test("ensurePr prefers an explicit url over the canonical one", async () => {
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
- Deno.test("ensurePr swallows an insert race when the row appears anyway", async () => {
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
- Deno.test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
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 {
@@ -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 { assertEquals, assertRejects } from "jsr:@std/assert@1";
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 = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
29
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
29
30
  const prevFetch = globalThis.fetch;
30
- Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token");
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) Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
37
- else Deno.env.set("NANO_PR_GITHUB_TRANSPORT", prevMode);
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
- Deno.test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
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
- Deno.test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
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
- Deno.test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
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`) and Deno (`Deno.Command`).
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 { assert, assertEquals } from "jsr:@std/assert@1";
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(await Deno.readTextFile(new URL("../nano.app.json", import.meta.url)));
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
- Deno.test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
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
- Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
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
- Deno.test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
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
- Deno.test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
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 { assert, assertEquals } from "jsr:@std/assert@1";
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
- Deno.test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
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
- Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
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
- Deno.test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
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
- Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
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
- Deno.test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
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
- Deno.test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
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
- Deno.test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
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
- Deno.test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
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
- Deno.test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
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);