@nanobpm/nano-workforce 0.35.2 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.github/workflows/ci.yml +10 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -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/actions/blackboard.ts +1 -0
  8. package/actions/feature-answer-hook.ts +1 -0
  9. package/actions/plan-hook.ts +2 -1
  10. package/actions/webhook-submit.ts +2 -1
  11. package/app/abandon.test.ts +10 -15
  12. package/app/baseGuard.test.ts +7 -6
  13. package/app/blackboard.test.ts +22 -32
  14. package/app/blackboard.ts +7 -6
  15. package/app/ensure-pr.test.ts +10 -12
  16. package/app/github.test.ts +9 -8
  17. package/app/github.ts +24 -22
  18. package/app/instance-tracking.test.ts +8 -6
  19. package/app/mergeExclusion.test.ts +11 -20
  20. package/app/mergeExclusion.ts +8 -3
  21. package/app/mergeProtocol.test.ts +14 -13
  22. package/app/mergeProtocol.ts +1 -0
  23. package/app/mergeRebaseArm.test.ts +8 -6
  24. package/app/mergeTrain.test.ts +10 -9
  25. package/app/persist-escalation.test.ts +9 -30
  26. package/app/persist-round.test.ts +6 -19
  27. package/app/plan.test.ts +19 -42
  28. package/app/plan.ts +1 -1
  29. package/app/record-plan-review.test.ts +8 -7
  30. package/app/retro.test.ts +24 -48
  31. package/app/retro.ts +4 -2
  32. package/app/reviewWait.test.ts +12 -11
  33. package/app/rounds.test.ts +13 -12
  34. package/app/service.test.ts +14 -27
  35. package/app/service.ts +8 -4
  36. package/app/taskDelta.test.ts +8 -17
  37. package/app/taskDelta.ts +1 -0
  38. package/app/trialMerge.test.ts +4 -3
  39. package/app/version.ts +9 -21
  40. package/app/waves.test.ts +17 -16
  41. package/biome.json +143 -0
  42. package/operations/getVersion.test.ts +8 -12
  43. package/operations/getVersion.ts +2 -3
  44. package/operations/listActivePrs.test.ts +8 -14
  45. package/operations/listActivePrs.ts +2 -3
  46. package/operations/postMessage.ts +3 -3
  47. package/operations/startAndMessage.test.ts +6 -12
  48. package/package.json +9 -4
  49. package/plugins/no-unsafe-type-assertion.grit +12 -0
  50. package/scripts/check-agent-prompts.test.ts +15 -11
  51. package/scripts/check-agent-prompts.ts +4 -2
  52. package/scripts/layout-bpmn.ts +6 -26
  53. package/scripts/pages-contract.test.ts +14 -13
  54. package/scripts/purge-db.ts +3 -2
  55. package/scripts/upgrade-from-pack.ts +9 -5
  56. package/test/assert.ts +74 -0
  57. package/tsconfig.json +4 -1
  58. package/workers/finalize/worker.ts +2 -1
  59. package/workers/merge/worker.ts +3 -3
  60. package/workers/persist-escalation/worker.ts +2 -1
  61. package/workers/persist-round/worker.ts +2 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-plan-review/worker.ts +2 -1
  64. package/workers/record-results/worker.test.ts +5 -8
  65. package/workers/record-results/worker.ts +2 -1
  66. package/workers/record-trial-merge/worker.test.ts +7 -18
  67. package/workers/record-trial-merge/worker.ts +6 -6
  68. package/workers/record-wave/worker.test.ts +13 -20
  69. package/workers/record-wave/worker.ts +7 -8
  70. package/workers/retro-gather/worker.test.ts +4 -17
  71. package/workers/retro-record/worker.test.ts +8 -27
  72. package/workers/retro-record/worker.ts +3 -3
  73. package/workers/select-wave/worker.test.ts +5 -8
  74. package/deno.json +0 -24
  75. package/deno.lock +0 -1776
package/app/retro.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the epic retrospective stage (app/retro.ts, 016_plan_retro.sql).
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, EngineClient } from "@nanobpm/urban";
4
5
  import { appendEntry } from "./blackboard.ts";
5
6
  import { recordTaskDelta } from "./taskDelta.ts";
@@ -15,18 +16,13 @@ import {
15
16
  } from "./retro.ts";
16
17
 
17
18
  // In-memory record gateway matching the Table<T> subset retro.ts uses: insert/find/findOne/get/update.
18
- // deno-lint-ignore no-explicit-any
19
19
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
20
- // deno-lint-ignore no-explicit-any
21
20
  const stores: Record<string, any[]> = {};
22
21
  const seq: Record<string, number> = {};
23
22
  function tbl(name: string, pk = "id") {
24
- // deno-lint-ignore no-explicit-any
25
23
  const rows = (stores[name] ??= [] as any[]);
26
- // deno-lint-ignore no-explicit-any
27
24
  const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
28
25
  return {
29
- // deno-lint-ignore no-explicit-any require-await
30
26
  async insert(row: any) {
31
27
  if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
32
28
  throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
@@ -35,26 +31,21 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
35
31
  rows.push(pk === "id" ? { id, ...row } : { ...row });
36
32
  return pk === "id" ? id : row[pk];
37
33
  },
38
- // deno-lint-ignore no-explicit-any require-await
39
34
  async find(where: any = {}) {
40
35
  return rows.filter((r) => match(r, where));
41
36
  },
42
- // deno-lint-ignore no-explicit-any require-await
43
37
  async findOne(where: any = {}) {
44
38
  return rows.find((r) => match(r, where));
45
39
  },
46
- // deno-lint-ignore no-explicit-any require-await
47
40
  async get(id: any) {
48
41
  return rows.find((row) => row[pk] === id);
49
42
  },
50
- // deno-lint-ignore no-explicit-any require-await
51
43
  async update(id: any, patch: any) {
52
44
  const r = rows.find((row) => row[pk] === id);
53
45
  if (r) Object.assign(r, patch);
54
46
  },
55
47
  };
56
48
  }
57
- // deno-lint-ignore no-explicit-any
58
49
  const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
59
50
  return { data, stores };
60
51
  }
@@ -62,21 +53,17 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
62
53
  // A fake engine recording createInstance calls.
63
54
  function fakeEngine(): { engine: EngineClient; started: { processDefinitionId: string; variables: Record<string, unknown> }[] } {
64
55
  const started: { processDefinitionId: string; variables: Record<string, unknown> }[] = [];
65
- // deno-lint-ignore no-explicit-any
66
56
  const engine = {
67
- // deno-lint-ignore no-explicit-any require-await
68
57
  async createInstance(req: any) {
69
58
  started.push({ processDefinitionId: req.processDefinitionId, variables: req.variables });
70
59
  return { processInstanceKey: `PI-${started.length}` };
71
60
  },
72
- // deno-lint-ignore no-explicit-any require-await
73
61
  } as any as EngineClient;
74
62
  return { engine, started };
75
63
  }
76
64
 
77
65
  const PLAN = "acme/widgets#7";
78
66
 
79
- // deno-lint-ignore no-explicit-any
80
67
  function seedPlan(stores: Record<string, any[]>, over: Record<string, unknown> = {}) {
81
68
  stores["plans"] = [{
82
69
  plan_key: PLAN,
@@ -89,20 +76,17 @@ function seedPlan(stores: Record<string, any[]>, over: Record<string, unknown> =
89
76
  }];
90
77
  }
91
78
 
92
- // deno-lint-ignore no-explicit-any
93
79
  function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
94
80
  (stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
95
81
  }
96
- // deno-lint-ignore no-explicit-any
97
82
  function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
98
83
  (stores["pull_requests"] ??= []).push({ pr_key, status });
99
84
  }
100
- // deno-lint-ignore no-explicit-any
101
85
  function seedReview(stores: Record<string, any[]>, round: number, approved: number, findings: string | null) {
102
86
  (stores["plan_reviews"] ??= []).push({ plan_key: PLAN, round, approved, findings, created_at: `t${round}`, job_key: null });
103
87
  }
104
88
 
105
- Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
89
+ test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
106
90
  const prev = process.env.NANO_AUTO_RETRO;
107
91
  try {
108
92
  delete process.env.NANO_AUTO_RETRO;
@@ -119,7 +103,7 @@ Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
119
103
  }
120
104
  });
121
105
 
122
- Deno.test("planKeyForPr: resolves the plan a PR's task belongs to; undefined when unlinked", async () => {
106
+ test("planKeyForPr: resolves the plan a PR's task belongs to; undefined when unlinked", async () => {
123
107
  const { data, stores } = memData();
124
108
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
125
109
  assertEquals(await planKeyForPr(data, "acme/widgets#10"), PLAN);
@@ -127,7 +111,7 @@ Deno.test("planKeyForPr: resolves the plan a PR's task belongs to; undefined whe
127
111
  assertEquals(await planKeyForPr(data, ""), undefined);
128
112
  });
129
113
 
130
- Deno.test("isPlanComplete: false while any task is still in flight", async () => {
114
+ test("isPlanComplete: false while any task is still in flight", async () => {
131
115
  const { data, stores } = memData();
132
116
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
133
117
  seedTask(stores, { id: "t2", status: "pending", pr_key: null });
@@ -136,14 +120,14 @@ Deno.test("isPlanComplete: false while any task is still in flight", async () =>
136
120
  assertEquals(await isPlanComplete(data, PLAN), false);
137
121
  });
138
122
 
139
- Deno.test("isPlanComplete: false when an opened task's PR is not yet terminal", async () => {
123
+ test("isPlanComplete: false when an opened task's PR is not yet terminal", async () => {
140
124
  const { data, stores } = memData();
141
125
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
142
126
  seedPr(stores, "acme/widgets#10", "waiting_deps"); // in the merge stage, not terminal
143
127
  assertEquals(await isPlanComplete(data, PLAN), false);
144
128
  });
145
129
 
146
- Deno.test("isPlanComplete: true when every task is settled (terminal PR or skipped/blocked)", async () => {
130
+ test("isPlanComplete: true when every task is settled (terminal PR or skipped/blocked)", async () => {
147
131
  const { data, stores } = memData();
148
132
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
149
133
  seedTask(stores, { id: "t2", status: "skipped", pr_key: null });
@@ -153,12 +137,12 @@ Deno.test("isPlanComplete: true when every task is settled (terminal PR or skipp
153
137
  assertEquals(await isPlanComplete(data, PLAN), true);
154
138
  });
155
139
 
156
- Deno.test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
140
+ test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
157
141
  const { data } = memData();
158
142
  assertEquals(await isPlanComplete(data, PLAN), false);
159
143
  });
160
144
 
161
- Deno.test("gatherRetro: separates learnings from notes and folds in deltas", async () => {
145
+ test("gatherRetro: separates learnings from notes and folds in deltas", async () => {
162
146
  const { data, stores } = memData();
163
147
  seedPlan(stores);
164
148
  await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen the API surface before building" });
@@ -181,7 +165,7 @@ Deno.test("gatherRetro: separates learnings from notes and folds in deltas", asy
181
165
  assertEquals(d.repo, "acme/widgets");
182
166
  });
183
167
 
184
- Deno.test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
168
+ test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
185
169
  const { data, stores } = memData();
186
170
  seedPlan(stores);
187
171
  seedTask(stores, { id: "t1", task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
@@ -201,7 +185,7 @@ Deno.test("gatherRetro: folds in the plan-review trace and task-outcome shape",
201
185
  assertEquals(d.taskOutcomes.byStatus, { opened: 2, skipped: 1 });
202
186
  });
203
187
 
204
- Deno.test("gatherRetro: no reviews → zero rounds, not approved, empty rejections", async () => {
188
+ test("gatherRetro: no reviews → zero rounds, not approved, empty rejections", async () => {
205
189
  const { data, stores } = memData();
206
190
  seedPlan(stores);
207
191
  const d = await gatherRetro(data, PLAN);
@@ -211,7 +195,7 @@ Deno.test("gatherRetro: no reviews → zero rounds, not approved, empty rejectio
211
195
  assertEquals(d.taskOutcomes, { total: 0, byStatus: {} });
212
196
  });
213
197
 
214
- Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
198
+ test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
215
199
  const empty = renderRetroBrief({
216
200
  planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
217
201
  learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [],
@@ -243,7 +227,7 @@ Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with
243
227
  assertStringIncludes(brief, "Task outcomes");
244
228
  });
245
229
 
246
- Deno.test("isDigestEmpty: empty only with no learnings/deltas/notes AND no review rejections", () => {
230
+ test("isDigestEmpty: empty only with no learnings/deltas/notes AND no review rejections", () => {
247
231
  const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [], reviewRounds: 0, reviewRejections: [], planApproved: false, taskOutcomes: { total: 0, byStatus: {} } };
248
232
  assert(isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 0 } }));
249
233
  assert(!isDigestEmpty({ ...base, counts: { learnings: 1, deltas: 0, notes: 0 } }));
@@ -255,7 +239,7 @@ Deno.test("isDigestEmpty: empty only with no learnings/deltas/notes AND no revie
255
239
  assert(isDigestEmpty({ ...base, reviewRounds: 1, planApproved: true, taskOutcomes: { total: 3, byStatus: { opened: 3 } }, counts: { learnings: 0, deltas: 0, notes: 0 } }));
256
240
  });
257
241
 
258
- Deno.test("recordRetro: inserts then updates the same plan_key row in place", async () => {
242
+ test("recordRetro: inserts then updates the same plan_key row in place", async () => {
259
243
  const { data, stores } = memData();
260
244
  await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20", learnings: 3, summary: "promoted 2" });
261
245
  assertEquals(stores["plan_retros"].length, 1);
@@ -268,25 +252,21 @@ Deno.test("recordRetro: inserts then updates the same plan_key row in place", as
268
252
  assertEquals(stores["plan_retros"][0].pr_key, null);
269
253
  });
270
254
 
271
- Deno.test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
255
+ test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
272
256
  // A FK failure (e.g. plan_key missing in plans) must NOT be treated as a benign duplicate and
273
257
  // fall through to a silent update — that would make the write look successful while doing nothing.
274
258
  let updated = false;
275
259
  const table = {
276
- // deno-lint-ignore require-await
277
260
  async insert() {
278
261
  throw new Error("FOREIGN KEY constraint failed");
279
262
  },
280
- // deno-lint-ignore require-await
281
263
  async update() {
282
264
  updated = true;
283
265
  },
284
- // deno-lint-ignore require-await
285
266
  async get() {
286
267
  return undefined;
287
268
  },
288
269
  };
289
- // deno-lint-ignore no-explicit-any
290
270
  const data = { table: () => table } as any as DataLayer;
291
271
  let threw = false;
292
272
  try {
@@ -299,7 +279,7 @@ Deno.test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error ins
299
279
  assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
300
280
  });
301
281
 
302
- Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands with material", async () => {
282
+ test("maybeStartRetro: starts the retro exactly once when the last PR lands with material", async () => {
303
283
  const { data, stores } = memData();
304
284
  seedPlan(stores);
305
285
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
@@ -325,13 +305,12 @@ Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands
325
305
  assertEquals(started.length, 1, "fire-once guard");
326
306
  });
327
307
 
328
- Deno.test("maybeStartRetro: a createInstance failure records a blocked retro (fire-once guard already consumed)", async () => {
308
+ test("maybeStartRetro: a createInstance failure records a blocked retro (fire-once guard already consumed)", async () => {
329
309
  const { data, stores } = memData();
330
310
  seedPlan(stores);
331
311
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
332
312
  seedPr(stores, "acme/widgets#10", "merged");
333
313
  await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
334
- // deno-lint-ignore no-explicit-any require-await
335
314
  const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
336
315
 
337
316
  const r = await maybeStartRetro(data, engine, "acme/widgets#10");
@@ -346,19 +325,16 @@ Deno.test("maybeStartRetro: a createInstance failure records a blocked retro (fi
346
325
  assertStringIncludes(String(stores["plan_retros"][0].summary), "gateway down");
347
326
  });
348
327
 
349
- Deno.test("maybeStartRetro: a secondary blocked-retro persistence failure still returns start-failed (not error)", async () => {
328
+ test("maybeStartRetro: a secondary blocked-retro persistence failure still returns start-failed (not error)", async () => {
350
329
  const { data, stores } = memData();
351
330
  seedPlan(stores);
352
331
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
353
332
  seedPr(stores, "acme/widgets#10", "merged");
354
333
  await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
355
- // deno-lint-ignore no-explicit-any require-await
356
334
  const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
357
335
  // recordRetro rethrows non-unique DB errors; simulate the blocked-retro insert hitting a
358
336
  // FOREIGN KEY failure so the persistence in the createInstance-failure handler throws.
359
- // deno-lint-ignore no-explicit-any
360
337
  const failingData = {
361
- // deno-lint-ignore no-explicit-any
362
338
  table: (name: string, pk?: string) => {
363
339
  const t = (data as any).table(name, pk);
364
340
  if (name !== "plan_retros") return t;
@@ -374,7 +350,7 @@ Deno.test("maybeStartRetro: a secondary blocked-retro persistence failure still
374
350
  assertEquals(stores["plan_retros"].length, 0);
375
351
  });
376
352
 
377
- Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
353
+ test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
378
354
  const { data, stores } = memData();
379
355
  seedPlan(stores);
380
356
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
@@ -389,7 +365,7 @@ Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate
389
365
  assertEquals(stores["plans"][0].retro_started_at, null);
390
366
  });
391
367
 
392
- Deno.test("maybeStartRetro: bails while the plan is incomplete", async () => {
368
+ test("maybeStartRetro: bails while the plan is incomplete", async () => {
393
369
  const { data, stores } = memData();
394
370
  seedPlan(stores);
395
371
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
@@ -404,7 +380,7 @@ Deno.test("maybeStartRetro: bails while the plan is incomplete", async () => {
404
380
  assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
405
381
  });
406
382
 
407
- Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does not start the process", async () => {
383
+ test("maybeStartRetro: complete but empty → records a skipped retro, does not start the process", async () => {
408
384
  const { data, stores } = memData();
409
385
  seedPlan(stores);
410
386
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
@@ -419,7 +395,7 @@ Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does
419
395
  assertEquals(stores["plan_retros"][0].status, "skipped");
420
396
  });
421
397
 
422
- Deno.test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
398
+ test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
423
399
  const { data, stores } = memData();
424
400
  seedPlan(stores);
425
401
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
@@ -436,7 +412,7 @@ Deno.test("maybeStartRetro: a rejected review round alone is enough to fire the
436
412
  assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
437
413
  });
438
414
 
439
- Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
415
+ test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
440
416
  const { data } = memData();
441
417
  const { engine, started } = fakeEngine();
442
418
  const r = await maybeStartRetro(data, engine, "acme/widgets#99");
@@ -445,7 +421,7 @@ Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
445
421
  assertEquals(started.length, 0);
446
422
  });
447
423
 
448
- Deno.test("maybeStartRetro: honours NANO_AUTO_RETRO=0", async () => {
424
+ test("maybeStartRetro: honours NANO_AUTO_RETRO=0", async () => {
449
425
  const prev = process.env.NANO_AUTO_RETRO;
450
426
  process.env.NANO_AUTO_RETRO = "0";
451
427
  try {
package/app/retro.ts CHANGED
@@ -14,16 +14,17 @@
14
14
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
15
15
  // app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
16
16
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
17
- import { planReviews, planTasks } from "./plan.ts";
18
17
  import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
19
- import { aggregateEpicDeltas } from "./taskDelta.ts";
18
+ import { planReviews, planTasks } from "./plan.ts";
20
19
  import { TERMINAL_STATUSES } from "./service.ts";
20
+ import { aggregateEpicDeltas } from "./taskDelta.ts";
21
21
 
22
22
  export const RETRO_PROCESS_ID = "retro";
23
23
 
24
24
  /** Opt-out env toggle. Retro runs by default; set NANO_AUTO_RETRO=0/false to disable (e.g. in a
25
25
  * review-only deployment that doesn't want the fleet opening promotion PRs). */
26
26
  export function autoRetroEnabled(): boolean {
27
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
27
28
  const v = (globalThis as { process?: { env?: Record<string, string | undefined> } })
28
29
  .process?.env?.NANO_AUTO_RETRO;
29
30
  if (v == null) return true;
@@ -128,6 +129,7 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
128
129
  .sort((a, b) => a.round - b.round);
129
130
  const reviewRejections = reviews
130
131
  .filter((r) => r.approved === 0 && (r.findings ?? "").trim() !== "")
132
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
131
133
  .map((r) => ({ round: r.round, findings: r.findings as string }));
132
134
  const planApproved = reviews.length > 0 && reviews[reviews.length - 1].approved === 1;
133
135
 
@@ -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 `deno test -A`.
5
- import { assertEquals } from "jsr:@std/assert@1";
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
- Deno.test("reviewWaitTimeout: blank / absent / malformed → default", () => {
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
- Deno.test("reviewWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
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
- Deno.test("reviewWaitTimeout: a custom fallback is used when the value is invalid", () => {
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
- Deno.test("clampNudgeMinutes: blank / absent / non-numeric → fallback", () => {
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
- Deno.test("clampNudgeMinutes: a valid positive value (string or number) is honoured", () => {
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
- Deno.test("clampNudgeMinutes: fractional values are truncated", () => {
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
- Deno.test("clampNudgeMinutes: zero and negatives fall back (a 0 cooldown would hammer the API)", () => {
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
- Deno.test("clampNudgeMinutes: above the ceiling is clamped, not rejected", () => {
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
- Deno.test("clampNudgeMinutes: an oversized fallback is itself clamped to the ceiling", () => {
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
  });
@@ -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 `deno test`.
4
- import { assertEquals } from "jsr:@std/assert@1";
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
- Deno.test("blank / absent / non-numeric → fallback", () => {
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
- Deno.test("a valid positive value (string or number) is honoured", () => {
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
- Deno.test("fractional values are truncated to a whole round", () => {
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
- Deno.test("zero and negatives fall back (a cap < 1 would escalate before round 1)", () => {
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
- Deno.test("above the ceiling is clamped, not rejected", () => {
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
- Deno.test("an oversized fallback is itself clamped to the ceiling", () => {
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
- Deno.test("ci-fix budget: blank / non-numeric / negative → fallback", () => {
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
- Deno.test("ci-fix budget: 0 is honoured (disables auto-fix), unlike clampRounds", () => {
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
- Deno.test("ci-fix budget: a valid positive value is honoured and truncated", () => {
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
- Deno.test("ci-fix budget: above the ceiling is clamped; oversized fallback too", () => {
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
  });
@@ -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 { assertEquals } from "jsr:@std/assert@1";
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 = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
41
- const prevTok = Deno.env.get("GITHUB_TOKEN");
42
- Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token"); // no token below -> fetchPrMeta returns null
43
- Deno.env.delete("GITHUB_TOKEN");
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) Deno.env.set("NANO_PR_GITHUB_TRANSPORT", prevMode);
46
- else Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
47
- if (prevTok !== undefined) Deno.env.set("GITHUB_TOKEN", prevTok);
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
- Deno.test("re-submit of a cancelled PR clears stale open escalations + the denormalised pointer", async () => {
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
- Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it, leaving status untouched", async () => {
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
- Deno.test("pollIncidents never queries a PR with no live instance and clears any stale incident", async () => {
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
- Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a missing timestamp last", async () => {
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
- Deno.test("submitPr stringifies a numeric processInstanceKey (contract: string | null)", async () => {
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");