@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
@@ -9,11 +9,11 @@
9
9
  // calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
10
10
  // shapes the escalation payload on a block.
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
- import { enqueueViaComment, mergePr } from "../../app/github.ts";
13
- import { MERGE_ADMIN, MERGE_METHOD, ensurePr } from "../../app/service.ts";
14
12
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
15
- import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
13
  import { checkBaseTarget } from "../../app/baseGuard.ts";
14
+ import { enqueueViaComment, mergePr } from "../../app/github.ts";
15
+ import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
+ import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
17
17
 
18
18
  interface In extends Record<string, unknown> {
19
19
  prKey: string;
@@ -3,8 +3,8 @@
3
3
  // and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
4
4
  // `escalationId` for the UI.
5
5
  import type { AppJobHandler } from "@nanobpm/urban";
6
- import { ensurePr, parsePr } from "../../app/service.ts";
7
6
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
7
+ import { ensurePr, parsePr } from "../../app/service.ts";
8
8
 
9
9
  // Extends Record so the declared fields are typed while the job may still carry
10
10
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -39,6 +39,7 @@ function nonBlank(v: unknown): string | undefined {
39
39
 
40
40
  const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
41
41
  function transcriptOf(vars: Record<string, unknown>): string | null {
42
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
42
43
  const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
43
44
  return typeof env?.output === "string" ? env.output : null;
44
45
  }
@@ -5,8 +5,8 @@
5
5
  // Data access goes through the injected app datasource gateway (`app.data.table<T>`), the RAD
6
6
  // `Table<T>` surface — `rounds.insert(...)` / `pull_requests.update(...)`, not hand-written SQL.
7
7
  import type { AppJobHandler } from "@nanobpm/urban";
8
- import { ensurePr, parsePr } from "../../app/service.ts";
9
8
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
9
+ import { ensurePr, parsePr } from "../../app/service.ts";
10
10
 
11
11
  // Extends Record so the declared fields are typed while the job may still carry
12
12
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -29,6 +29,7 @@ interface In extends Record<string, unknown> {
29
29
  // for audit so a human can see what the agent did this round.
30
30
  const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
31
31
  function transcriptOf(vars: Record<string, unknown>): string | null {
32
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
32
33
  const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
33
34
  return typeof env?.output === "string" ? env.output : null;
34
35
  }
@@ -5,7 +5,8 @@
5
5
  // plan was empty (e.g. the planner agent couldn't persist its result) — completed the whole epic
6
6
  // GREEN having done nothing (instance 21). We now HARD-FAIL: the terminal, unapproved round raises
7
7
  // a non-retryable `PLAN_REJECTED` BpmnError (→ incident), so an un-approved plan never dispatches.
8
- import { assertEquals, assertRejects } from "jsr:@std/assert@1";
8
+ import { test } from "node:test";
9
+ import { assertEquals, assertRejects } from "#test-assert";
9
10
  import { BpmnError } from "@nanobpm/urban";
10
11
  import handler from "./worker.ts";
11
12
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
@@ -18,9 +19,7 @@ function fakeApp(existing: PlanReview[] = []) {
18
19
  data: {
19
20
  table() {
20
21
  return {
21
- // deno-lint-ignore no-explicit-any
22
22
  findOne: (q: any) => Promise.resolve(rows.find((r) => match(r, q)) ?? null),
23
- // deno-lint-ignore no-explicit-any
24
23
  count: (q: any) => Promise.resolve(rows.filter((r) => match(r, q)).length),
25
24
  insert: (row: PlanReview) => {
26
25
  rows.push(row);
@@ -31,7 +30,6 @@ function fakeApp(existing: PlanReview[] = []) {
31
30
  },
32
31
  log: () => {},
33
32
  _rows: rows,
34
- // deno-lint-ignore no-explicit-any
35
33
  } as any;
36
34
  }
37
35
 
@@ -48,16 +46,15 @@ function priorRounds(planKey: string, n: number): PlanReview[] {
48
46
  }
49
47
 
50
48
  const call = async (app: unknown, vars: Record<string, unknown>, jobKey = "j-new") =>
51
- // deno-lint-ignore no-explicit-any
52
49
  await handler({ variables: vars, jobKey } as any, app as any);
53
50
 
54
- Deno.test("approved round proceeds (planApproved=true, no throw)", async () => {
51
+ test("approved round proceeds (planApproved=true, no throw)", async () => {
55
52
  const app = fakeApp(priorRounds("o/r#1", 0));
56
53
  const out = await call(app, { planKey: "o/r#1", approved: true });
57
54
  assertEquals((out as { planApproved: boolean }).planApproved, true);
58
55
  });
59
56
 
60
- Deno.test("unapproved, non-final round revises (planApproved=false, no throw)", async () => {
57
+ test("unapproved, non-final round revises (planApproved=false, no throw)", async () => {
61
58
  // First round of a 3-round cap: not final, so revise.
62
59
  const app = fakeApp(priorRounds("o/r#2", 0));
63
60
  const out = await call(app, { planKey: "o/r#2", approved: false, findings: "fix X" });
@@ -65,7 +62,7 @@ Deno.test("unapproved, non-final round revises (planApproved=false, no throw)",
65
62
  assertEquals((out as { planFindings: string }).planFindings, "fix X");
66
63
  });
67
64
 
68
- Deno.test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async () => {
65
+ test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async () => {
69
66
  // Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ must throw.
70
67
  const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
71
68
  const err = await assertRejects(
@@ -75,7 +72,7 @@ Deno.test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async
75
72
  assertEquals((err as BpmnError).errorCode, "PLAN_REJECTED");
76
73
  });
77
74
 
78
- Deno.test("approved on the FINAL round still proceeds (no throw)", async () => {
75
+ test("approved on the FINAL round still proceeds (no throw)", async () => {
79
76
  const app = fakeApp(priorRounds("o/r#4", MAX_PLAN_REVIEW_ROUNDS - 1));
80
77
  const out = await call(app, { planKey: "o/r#4", approved: true });
81
78
  assertEquals((out as { planApproved: boolean }).planApproved, true);
@@ -16,8 +16,9 @@
16
16
  // the whole epic complete GREEN having done nothing. The cap still bounds the loop; it now bounds
17
17
  // it into an incident, not a silent proceed. A missing/ambiguous `approved` is treated as NOT
18
18
  // approved (revise until the cap).
19
- import { BpmnError } from "@nanobpm/urban";
19
+
20
20
  import type { AppJobHandler } from "@nanobpm/urban";
21
+ import { BpmnError } from "@nanobpm/urban";
21
22
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview, planReviews } from "../../app/plan.ts";
22
23
 
23
24
  interface In extends Record<string, unknown> {
@@ -6,7 +6,8 @@
6
6
  // non-retryable `NO_WORK_DISPATCHED` BpmnError (→ incident) when the epic finalizes with no opened
7
7
  // PR, recording a `failed` terminal status + outcome first, so "accomplished nothing" surfaces
8
8
  // instead of masquerading as a completed epic.
9
- import { assertEquals, assertRejects } from "jsr:@std/assert@1";
9
+ import { test } from "node:test";
10
+ import { assertEquals, assertRejects } from "#test-assert";
10
11
  import { BpmnError } from "@nanobpm/urban";
11
12
  import handler from "./worker.ts";
12
13
  import type { PlanTaskStatus } from "../../app/plan.ts";
@@ -25,7 +26,6 @@ function fakeApp(rows: Row[]) {
25
26
  table(name: string, key: string) {
26
27
  if (name === "plans") {
27
28
  return {
28
- // deno-lint-ignore no-explicit-any
29
29
  update: (k: any, patch: any) => {
30
30
  plans.push({ [key]: k, ...patch });
31
31
  return Promise.resolve(patch);
@@ -34,7 +34,6 @@ function fakeApp(rows: Row[]) {
34
34
  }
35
35
  // plan_tasks
36
36
  return {
37
- // deno-lint-ignore no-explicit-any
38
37
  find: (q: any) =>
39
38
  Promise.resolve(
40
39
  rows.filter((r) =>
@@ -48,15 +47,13 @@ function fakeApp(rows: Row[]) {
48
47
  },
49
48
  log: () => {},
50
49
  _plans: plans,
51
- // deno-lint-ignore no-explicit-any
52
50
  } as any;
53
51
  }
54
52
 
55
53
  const call = async (app: unknown, planKey = "o/r#1") =>
56
- // deno-lint-ignore no-explicit-any
57
54
  await handler({ variables: { planKey } } as any, app as any);
58
55
 
59
- Deno.test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () => {
56
+ test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () => {
60
57
  const app = fakeApp([]);
61
58
  const err = await assertRejects(() => call(app), BpmnError);
62
59
  assertEquals((err as BpmnError).errorCode, "NO_WORK_DISPATCHED");
@@ -67,7 +64,7 @@ Deno.test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async
67
64
  assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
68
65
  });
69
66
 
70
- Deno.test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
67
+ test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
71
68
  const app = fakeApp([
72
69
  { id: 1, plan_key: "o/r#1", task_id: "a", status: "skipped" },
73
70
  { id: 2, plan_key: "o/r#1", task_id: "b", status: "blocked" },
@@ -79,7 +76,7 @@ Deno.test("tasks present but none opened (all skipped/blocked) hard-fails", asyn
79
76
  assertEquals(plan.outcome, "no work dispatched — every task was blocked or skipped");
80
77
  });
81
78
 
82
- Deno.test("at least one opened PR finalizes cleanly (no throw)", async () => {
79
+ test("at least one opened PR finalizes cleanly (no throw)", async () => {
83
80
  const app = fakeApp([
84
81
  { id: 1, plan_key: "o/r#1", task_id: "a", status: "opened" },
85
82
  { id: 2, plan_key: "o/r#1", task_id: "b", status: "skipped" },
@@ -10,8 +10,9 @@
10
10
  // • zero PRs opened → record a `failed` outcome for observability, then throw the non-retryable
11
11
  // `NO_WORK_DISPATCHED` BpmnError so the engine parks the instance on an incident instead of
12
12
  // completing green (issue #86). The `failed` status is terminal, so `startPlan` can re-plan it.
13
- import { BpmnError } from "@nanobpm/urban";
13
+
14
14
  import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { BpmnError } from "@nanobpm/urban";
15
16
  import { planTasks } from "../../app/plan.ts";
16
17
 
17
18
  interface In extends Record<string, unknown> {
@@ -1,4 +1,5 @@
1
- import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
1
+ import { test } from "node:test";
2
+ import { assertEquals, assertStringIncludes } from "#test-assert";
2
3
  import handler, { parseResult } from "./worker.ts";
3
4
 
4
5
  function fakeApp() {
@@ -7,16 +8,13 @@ function fakeApp() {
7
8
  data: {
8
9
  table(name: string, _key: string) {
9
10
  return {
10
- // deno-lint-ignore require-await
11
11
  async find(_q: unknown) {
12
12
  return [];
13
13
  },
14
- // deno-lint-ignore require-await
15
14
  async insert(row: unknown) {
16
15
  (inserts[name] ??= []).push(row);
17
16
  return 7;
18
17
  },
19
- // deno-lint-ignore require-await
20
18
  async update(_key: unknown, _patch: unknown) {},
21
19
  };
22
20
  },
@@ -26,31 +24,27 @@ function fakeApp() {
26
24
  return { app, inserts };
27
25
  }
28
26
 
29
- Deno.test("record-trial-merge shapes clean results as proceed", async () => {
27
+ test("record-trial-merge shapes clean results as proceed", async () => {
30
28
  const { app, inserts } = fakeApp();
31
29
  const out = await handler(
32
- // deno-lint-ignore no-explicit-any
33
30
  { key: 11, variables: { planKey: "o/r#69", currentWave: 2, result: "clean", summary: "green" } } as any,
34
- // deno-lint-ignore no-explicit-any
35
31
  app as any,
36
32
  ) as Record<string, unknown>;
37
33
 
38
34
  assertEquals(out.trialMergeRed, false);
39
35
  assertEquals(inserts.plan_trial_merges.length, 1);
40
- // deno-lint-ignore no-explicit-any
41
36
  assertEquals((inserts.plan_trial_merges[0] as any).result, "clean");
42
37
  });
43
38
 
44
- Deno.test("parseResult trims valid agent result strings", () => {
39
+ test("parseResult trims valid agent result strings", () => {
45
40
  assertEquals(parseResult("clean\n"), "clean");
46
41
  assertEquals(parseResult(" merge-conflict "), "merge-conflict");
47
42
  assertEquals(parseResult("unknown"), "suite-failed");
48
43
  });
49
44
 
50
- Deno.test("record-trial-merge escalates only suite-failed results", async () => {
45
+ test("record-trial-merge escalates only suite-failed results", async () => {
51
46
  const { app } = fakeApp();
52
47
  const out = await handler(
53
- // deno-lint-ignore no-explicit-any
54
48
  {
55
49
  key: 12,
56
50
  variables: {
@@ -61,7 +55,6 @@ Deno.test("record-trial-merge escalates only suite-failed results", async () =>
61
55
  failing: ["integration suite"],
62
56
  },
63
57
  } as any,
64
- // deno-lint-ignore no-explicit-any
65
58
  app as any,
66
59
  ) as Record<string, unknown>;
67
60
 
@@ -71,10 +64,9 @@ Deno.test("record-trial-merge escalates only suite-failed results", async () =>
71
64
  assertStringIncludes(String(out.question ?? ""), "proceed");
72
65
  });
73
66
 
74
- Deno.test("record-trial-merge renders odd failing payloads without throwing", async () => {
67
+ test("record-trial-merge renders odd failing payloads without throwing", async () => {
75
68
  const { app } = fakeApp();
76
69
  const out = await handler(
77
- // deno-lint-ignore no-explicit-any
78
70
  {
79
71
  variables: {
80
72
  planKey: "o/r#69",
@@ -83,7 +75,6 @@ Deno.test("record-trial-merge renders odd failing payloads without throwing", as
83
75
  failing: [1n],
84
76
  },
85
77
  } as any,
86
- // deno-lint-ignore no-explicit-any
87
78
  app as any,
88
79
  ) as Record<string, unknown>;
89
80
 
@@ -91,12 +82,10 @@ Deno.test("record-trial-merge renders odd failing payloads without throwing", as
91
82
  assertStringIncludes(String(out.question ?? ""), "Failing:");
92
83
  });
93
84
 
94
- Deno.test("record-trial-merge treats textual conflicts as pass-through", async () => {
85
+ test("record-trial-merge treats textual conflicts as pass-through", async () => {
95
86
  const { app } = fakeApp();
96
87
  const out = await handler(
97
- // deno-lint-ignore no-explicit-any
98
88
  { variables: { planKey: "o/r#69", result: "merge-conflict", conflicts: [{ prs: [1, 2] }] } } as any,
99
- // deno-lint-ignore no-explicit-any
100
89
  app as any,
101
90
  ) as Record<string, unknown>;
102
91
 
@@ -4,9 +4,9 @@
4
4
  import type { AppJobHandler } from "@nanobpm/urban";
5
5
  import {
6
6
  recordTrialMergeAudit,
7
+ type TrialMergeResult,
7
8
  trialMergeDecision,
8
9
  trialMergeTaskId,
9
- type TrialMergeResult,
10
10
  } from "../../app/trialMerge.ts";
11
11
 
12
12
  interface In extends Record<string, unknown> {
@@ -26,7 +26,6 @@ interface Out extends Record<string, unknown> {
26
26
  summary?: string;
27
27
  }
28
28
 
29
- const RESULTS = new Set<TrialMergeResult>(["clean", "merge-conflict", "suite-failed"]);
30
29
  const str = (v: unknown): string | undefined =>
31
30
  typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
32
31
  const waveNo = (v: unknown): number => {
@@ -43,7 +42,8 @@ const safeJson = (v: unknown): string => {
43
42
 
44
43
  function parseResult(v: unknown): TrialMergeResult {
45
44
  const s = typeof v === "string" ? v.trim() : "";
46
- return RESULTS.has(s as TrialMergeResult) ? (s as TrialMergeResult) : "suite-failed";
45
+ if (s === "clean" || s === "merge-conflict" || s === "suite-failed") return s;
46
+ return "suite-failed";
47
47
  }
48
48
 
49
49
  const handler: AppJobHandler<In, Out> = async (job, app) => {
@@ -52,8 +52,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
52
52
  const result = parseResult(job.variables.result);
53
53
  const summary = str(job.variables.summary) ??
54
54
  (result === "suite-failed" ? "Trial merge suite failed or returned no machine-readable result" : result);
55
- const rawJobKey = (job as { key?: unknown }).key;
56
- const jobKey = rawJobKey == null ? null : String(rawJobKey);
55
+ const legacyJobKey = Reflect.get(job, "key");
56
+ const jobKey = job.jobKey ?? (legacyJobKey == null ? null : String(legacyJobKey));
57
57
 
58
58
  try {
59
59
  await recordTrialMergeAudit(app.data, {
@@ -80,7 +80,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
80
80
  trialMergeRed,
81
81
  summary,
82
82
  task: { id: trialMergeTaskId(wave), title: `Trial merge gate for wave ${wave}` },
83
- question: `${summary}${failing}\n\nD3 needs a human decision: either the PR heads merged cleanly and the combined suite failed, or the trial-merge agent did not return a valid machine-readable result. Decide the design/infrastructure fix, update the PR heads if needed, then answer to rerun the trial merge; answer exactly \"proceed\" only to override and continue without rerunning.`,
83
+ question: `${summary}${failing}\n\nD3 needs a human decision: either the PR heads merged cleanly and the combined suite failed, or the trial-merge agent did not return a valid machine-readable result. Decide the design/infrastructure fix, update the PR heads if needed, then answer to rerun the trial merge; answer exactly "proceed" only to override and continue without rerunning.`,
84
84
  };
85
85
  };
86
86
 
@@ -1,7 +1,8 @@
1
1
  // Red/green regression for retrying a wave when `select-wave` deliberately left pending work
2
2
  // behind a non-fatal wait (D7 / issue #63). Advancing past that wave would make `record-results`
3
3
  // finish the plan with a still-pending task.
4
- import { assertEquals } from "jsr:@std/assert@1";
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
5
6
  import handler from "./worker.ts";
6
7
  import type { PlanTaskStatus } from "../../app/plan.ts";
7
8
  import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
@@ -32,10 +33,8 @@ function fakeApp(rows: Row[]) {
32
33
  table(name: string, key: string) {
33
34
  const store = stores[name] ??= [];
34
35
  return {
35
- // deno-lint-ignore no-explicit-any
36
36
  get: (k: any) =>
37
37
  Promise.resolve(store.find((r) => ((r as unknown) as Record<string, unknown>)[key] === k)),
38
- // deno-lint-ignore no-explicit-any
39
38
  find: (q: any) =>
40
39
  Promise.resolve(
41
40
  store.filter((r) =>
@@ -44,12 +43,10 @@ function fakeApp(rows: Row[]) {
44
43
  )
45
44
  ),
46
45
  ),
47
- // deno-lint-ignore no-explicit-any
48
46
  insert: (row: any) => {
49
47
  store.push(row);
50
48
  return Promise.resolve(store.length);
51
49
  },
52
- // deno-lint-ignore no-explicit-any
53
50
  update: (k: any, patch: any) => {
54
51
  if (name === "plans") {
55
52
  planUpdates.push({ key: k, patch });
@@ -68,7 +65,6 @@ function fakeApp(rows: Row[]) {
68
65
  engine: {
69
66
  createInstance: () => Promise.resolve({ processInstanceKey: "pi" }),
70
67
  },
71
- // deno-lint-ignore no-explicit-any
72
68
  } as any,
73
69
  planUpdates,
74
70
  };
@@ -76,11 +72,11 @@ function fakeApp(rows: Row[]) {
76
72
 
77
73
  function installGithubStub(method: "gh-merge" | "mergify-queue") {
78
74
  _clearMergeProtocolCache();
79
- const oldTransport = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
80
- const oldToken = Deno.env.get("GITHUB_TOKEN");
75
+ const oldTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
76
+ const oldToken = process.env["GITHUB_TOKEN"];
81
77
  const oldFetch = globalThis.fetch;
82
- Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token");
83
- Deno.env.set("GITHUB_TOKEN", "test-token");
78
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
79
+ process.env["GITHUB_TOKEN"] = "test-token";
84
80
  globalThis.fetch = ((input: string | URL | Request) => {
85
81
  const url = String(input);
86
82
  if (url.includes("/contents/AGENTS.md")) {
@@ -104,16 +100,16 @@ function installGithubStub(method: "gh-merge" | "mergify-queue") {
104
100
  return Promise.resolve(new Response("not found", { status: 404 }));
105
101
  }) as typeof fetch;
106
102
  return () => {
107
- if (oldTransport == null) Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
108
- else Deno.env.set("NANO_PR_GITHUB_TRANSPORT", oldTransport);
109
- if (oldToken == null) Deno.env.delete("GITHUB_TOKEN");
110
- else Deno.env.set("GITHUB_TOKEN", oldToken);
103
+ if (oldTransport == null) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
104
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = oldTransport;
105
+ if (oldToken == null) delete process.env["GITHUB_TOKEN"];
106
+ else process.env["GITHUB_TOKEN"] = oldToken;
111
107
  globalThis.fetch = oldFetch;
112
108
  _clearMergeProtocolCache();
113
109
  };
114
110
  }
115
111
 
116
- Deno.test("record-wave retries the same wave when a task is still pending", async () => {
112
+ test("record-wave retries the same wave when a task is still pending", async () => {
117
113
  const rows: Row[] = [{
118
114
  id: 2,
119
115
  plan_key: "owner/repo#63",
@@ -124,7 +120,6 @@ Deno.test("record-wave retries the same wave when a task is still pending", asyn
124
120
  const { app, planUpdates } = fakeApp(rows);
125
121
 
126
122
  const out = await handler(
127
- // deno-lint-ignore no-explicit-any
128
123
  {
129
124
  variables: {
130
125
  planKey: "owner/repo#63",
@@ -148,7 +143,7 @@ Deno.test("record-wave retries the same wave when a task is still pending", asyn
148
143
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
149
144
  });
150
145
 
151
- Deno.test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
146
+ test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
152
147
  const restore = installGithubStub("mergify-queue");
153
148
  try {
154
149
  const rows: Row[] = [
@@ -157,7 +152,6 @@ Deno.test("record-wave skips trial merge for mergify-queue repos with 2+ heads",
157
152
  ];
158
153
  const { app } = fakeApp(rows);
159
154
  const out = await handler(
160
- // deno-lint-ignore no-explicit-any
161
155
  {
162
156
  variables: {
163
157
  planKey: "owner/repo#69",
@@ -184,7 +178,7 @@ Deno.test("record-wave skips trial merge for mergify-queue repos with 2+ heads",
184
178
  }
185
179
  });
186
180
 
187
- Deno.test("record-wave runs trial merge for non-queue repos with populated heads", async () => {
181
+ test("record-wave runs trial merge for non-queue repos with populated heads", async () => {
188
182
  const restore = installGithubStub("gh-merge");
189
183
  try {
190
184
  const rows: Row[] = [
@@ -193,7 +187,6 @@ Deno.test("record-wave runs trial merge for non-queue repos with populated heads
193
187
  ];
194
188
  const { app } = fakeApp(rows);
195
189
  const out = await handler(
196
- // deno-lint-ignore no-explicit-any
197
190
  {
198
191
  variables: {
199
192
  planKey: "owner/repo#69",
@@ -14,19 +14,19 @@
14
14
  // Enrollment lives here (not in the finalizer) so a PR is enrolled the moment its wave lands —
15
15
  // and, crucially, so a later wave's `dependsOn` can reference the PR keys earlier waves produced.
16
16
  import type { AppJobHandler } from "@nanobpm/urban";
17
+ import { appendEntry } from "../../app/blackboard.ts";
18
+ import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
19
+ import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
20
+ import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
17
21
  import {
18
22
  type PlanTask,
19
23
  type PlanTaskStatus,
24
+ plans,
20
25
  planTaskDeps,
21
26
  planTasks,
22
- plans,
23
27
  } from "../../app/plan.ts";
24
28
  import { parsePr, submitPr } from "../../app/service.ts";
25
29
  import { parseTaskDelta, readTaskDeltas, recordTaskDelta } from "../../app/taskDelta.ts";
26
- import { appendEntry } from "../../app/blackboard.ts";
27
- import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
28
- import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
29
- import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
30
30
  import { shouldRunTrialMerge, type TrialMergeHead } from "../../app/trialMerge.ts";
31
31
 
32
32
  interface Result {
@@ -61,9 +61,8 @@ const str = (v: unknown): string | undefined =>
61
61
  // including a missing status — is treated as `blocked`: we must not assume a PR was opened,
62
62
  // and we only hand off / persist a PR when the status is `opened`.
63
63
  type WaveResultStatus = Extract<PlanTaskStatus, "opened" | "blocked" | "skipped">;
64
- const ALLOWED_STATUSES = new Set<WaveResultStatus>(["opened", "blocked", "skipped"]);
65
64
  const isWaveResultStatus = (s: string): s is WaveResultStatus =>
66
- ALLOWED_STATUSES.has(s as WaveResultStatus);
65
+ s === "opened" || s === "blocked" || s === "skipped";
67
66
 
68
67
  // Coerce a wave index/count to a non-negative integer, falling back to 0. A NaN here would make
69
68
  // `nextWave < waveCount` mis-evaluate and end the loop early, leaving tasks `pending`.
@@ -103,7 +102,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
103
102
  const readyHeadsThisWave: { repo: string; number: number | string }[] = [];
104
103
 
105
104
  for (let i = 0; i < waveTasks.length; i++) {
106
- const taskId = str((waveTasks[i] ?? {}).id);
105
+ const taskId = str(waveTasks[i]?.id);
107
106
  if (!taskId) continue;
108
107
  const res = results[i] ?? {};
109
108
  const rawStatus = str(res.status);
@@ -1,47 +1,38 @@
1
- import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
1
+ import { test } from "node:test";
2
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
2
3
  import type { DataLayer } from "@nanobpm/urban";
3
4
  import { appendEntry } from "../../app/blackboard.ts";
4
5
  import handler from "./worker.ts";
5
6
 
6
- // deno-lint-ignore no-explicit-any
7
7
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
8
- // deno-lint-ignore no-explicit-any
9
8
  const stores: Record<string, any[]> = {};
10
9
  const seq: Record<string, number> = {};
11
10
  function tbl(name: string, pk = "id") {
12
- // deno-lint-ignore no-explicit-any
13
11
  const rows = (stores[name] ??= [] as any[]);
14
- // deno-lint-ignore no-explicit-any
15
12
  const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
16
13
  return {
17
- // deno-lint-ignore no-explicit-any require-await
18
14
  async insert(row: any) {
19
15
  const id = (seq[name] = (seq[name] ?? 0) + 1);
20
16
  rows.push(pk === "id" ? { id, ...row } : { ...row });
21
17
  return pk === "id" ? id : row[pk];
22
18
  },
23
- // deno-lint-ignore no-explicit-any require-await
24
19
  async find(where: any = {}) {
25
20
  return rows.filter((r) => match(r, where));
26
21
  },
27
- // deno-lint-ignore no-explicit-any require-await
28
22
  async findOne(where: any = {}) {
29
23
  return rows.find((r) => match(r, where));
30
24
  },
31
- // deno-lint-ignore no-explicit-any require-await
32
25
  async get(id: any) {
33
26
  return rows.find((row) => row[pk] === id);
34
27
  },
35
- // deno-lint-ignore no-explicit-any require-await
36
28
  async update() {},
37
29
  };
38
30
  }
39
- // deno-lint-ignore no-explicit-any
40
31
  const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
41
32
  return { data, stores };
42
33
  }
43
34
 
44
- Deno.test("retro-gather: emits a digest brief + learning count for the plan", async () => {
35
+ test("retro-gather: emits a digest brief + learning count for the plan", async () => {
45
36
  const { data, stores } = memData();
46
37
  stores["plans"] = [{ plan_key: "o/r#3", repo: "o/r", issue_url: "https://x/3", title: "Epic" }];
47
38
  await appendEntry(data, "o/r#3", { author_task: "t1", kind: "learning", body: "regen before build" });
@@ -49,9 +40,7 @@ Deno.test("retro-gather: emits a digest brief + learning count for the plan", as
49
40
 
50
41
  const app = { data, log: () => undefined };
51
42
  const out = await handler(
52
- // deno-lint-ignore no-explicit-any
53
43
  { variables: { planKey: "o/r#3" } } as any,
54
- // deno-lint-ignore no-explicit-any
55
44
  app as any,
56
45
  ) as Record<string, unknown>;
57
46
 
@@ -61,14 +50,12 @@ Deno.test("retro-gather: emits a digest brief + learning count for the plan", as
61
50
  assertStringIncludes(String(out.retroDigest), "o/r#3");
62
51
  });
63
52
 
64
- Deno.test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
53
+ test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
65
54
  const { data, stores } = memData();
66
55
  stores["plans"] = [{ plan_key: "o/r#4", repo: "o/r", issue_url: "", title: null }];
67
56
  const app = { data, log: () => undefined };
68
57
  const out = await handler(
69
- // deno-lint-ignore no-explicit-any
70
58
  { variables: { planKey: "o/r#4" } } as any,
71
- // deno-lint-ignore no-explicit-any
72
59
  app as any,
73
60
  ) as Record<string, unknown>;
74
61