@nanobpm/nano-workforce 0.36.0 → 0.38.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 +7 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +6 -27
  5. package/SPEC.md +15 -13
  6. package/app/abandon.test.ts +14 -19
  7. package/app/abandon.ts +2 -2
  8. package/app/baseGuard.test.ts +7 -6
  9. package/app/blackboard.test.ts +25 -35
  10. package/app/blackboard.ts +2 -2
  11. package/app/ensure-pr.test.ts +10 -12
  12. package/app/github.test.ts +9 -8
  13. package/app/github.ts +1 -21
  14. package/app/instance-tracking.test.ts +8 -6
  15. package/app/mergeExclusion.test.ts +11 -20
  16. package/app/mergeProtocol.test.ts +14 -13
  17. package/app/mergeRebaseArm.test.ts +8 -6
  18. package/app/mergeTrain.test.ts +10 -9
  19. package/app/persist-escalation.test.ts +10 -31
  20. package/app/persist-round.test.ts +7 -20
  21. package/app/plan.test.ts +19 -42
  22. package/app/record-plan-review.test.ts +8 -7
  23. package/app/retro.test.ts +24 -48
  24. package/app/reviewWait.test.ts +12 -11
  25. package/app/rounds.test.ts +13 -12
  26. package/app/service.test.ts +14 -27
  27. package/app/service.ts +1 -1
  28. package/app/taskDelta.test.ts +8 -17
  29. package/app/trialMerge.test.ts +4 -3
  30. package/app/version.ts +6 -21
  31. package/app/waves.test.ts +17 -16
  32. package/biome.json +0 -2
  33. package/main.ts +3 -3
  34. package/nano.app.json +2 -25
  35. package/openapi.yaml +649 -0
  36. package/operations/answerFeatureEscalation.test.ts +100 -0
  37. package/operations/answerFeatureEscalation.ts +49 -0
  38. package/operations/appendBlackboard.ts +61 -0
  39. package/{actions → operations}/blackboard.test.ts +28 -30
  40. package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
  41. package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
  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 +3 -4
  46. package/operations/postMessage.ts +1 -1
  47. package/operations/readBlackboard.ts +29 -0
  48. package/operations/startAndMessage.test.ts +11 -15
  49. package/operations/startConvergenceLoop.ts +17 -11
  50. package/operations/startPlanFanout.ts +13 -7
  51. package/package.json +9 -7
  52. package/pages/epic.page.json +1 -1
  53. package/pages/home.page.json +1 -1
  54. package/scripts/check-agent-prompts.test.ts +15 -11
  55. package/scripts/layout-bpmn.ts +6 -28
  56. package/scripts/pages-contract.test.ts +14 -13
  57. package/scripts/purge-db.ts +1 -1
  58. package/scripts/upgrade-from-pack.ts +1 -1
  59. package/test/assert.ts +74 -0
  60. package/tsconfig.json +4 -1
  61. package/workers/persist-task-escalation/worker.ts +1 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-results/worker.test.ts +5 -8
  64. package/workers/record-trial-merge/worker.test.ts +7 -18
  65. package/workers/record-wave/worker.test.ts +13 -20
  66. package/workers/retro-gather/worker.test.ts +4 -17
  67. package/workers/retro-record/worker.test.ts +8 -27
  68. package/workers/select-wave/worker.test.ts +5 -8
  69. package/actions/blackboard.ts +0 -77
  70. package/actions/feature-answer-hook.ts +0 -45
  71. package/actions/plan-hook.ts +0 -20
  72. package/actions/webhook-submit.ts +0 -22
  73. package/deno.json +0 -24
  74. package/deno.lock +0 -1777
  75. package/openapi.json +0 -248
@@ -0,0 +1,100 @@
1
+ import { test } from "node:test";
2
+ import { assertEquals } from "#test-assert";
3
+ import type { AppApi } from "@nanobpm/urban";
4
+
5
+ const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
6
+ const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
7
+ let answerFeatureEscalation: typeof import("./answerFeatureEscalation.ts").default;
8
+ try {
9
+ process.env.NANO_PR_WEBHOOK_SECRET = " test-secret ";
10
+ answerFeatureEscalation = (await import("./answerFeatureEscalation.ts")).default;
11
+ } finally {
12
+ if (hadSecret && previousSecret !== undefined) process.env.NANO_PR_WEBHOOK_SECRET = previousSecret;
13
+ else delete process.env.NANO_PR_WEBHOOK_SECRET;
14
+ }
15
+
16
+ function memTable(rows: any[], key: string) {
17
+ return {
18
+ find: (where: Record<string, unknown>) =>
19
+ Promise.resolve(rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value))),
20
+ update: (value: unknown, patch: Record<string, unknown>) => {
21
+ const row = rows.find((candidate) => candidate[key] === value);
22
+ if (row) Object.assign(row, patch);
23
+ return Promise.resolve(row);
24
+ },
25
+ };
26
+ }
27
+
28
+ function memApp(escalations: any[] = []) {
29
+ const stores: Record<string, { rows: any[]; key: string }> = {
30
+ plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
31
+ plan_escalations: { rows: escalations, key: "id" },
32
+ plan_tasks: { rows: [{ id: 1, plan_key: "owner/repo#9", task_id: "task-1" }], key: "id" },
33
+ };
34
+ const published: Record<string, unknown>[] = [];
35
+ const app = {
36
+ data: {
37
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
38
+ },
39
+ engine: {
40
+ publishMessage: (message: Record<string, unknown>) => {
41
+ published.push(message);
42
+ return Promise.resolve();
43
+ },
44
+ },
45
+ } as any as AppApi;
46
+ return { app, published };
47
+ }
48
+
49
+ function input(body: Record<string, unknown>, secret?: string) {
50
+ const headers = new Headers();
51
+ if (secret !== undefined) headers.set("x-hook-secret", secret);
52
+ return {
53
+ req: {
54
+ method: "POST",
55
+ path: "/app/api/hooks/feature-answer",
56
+ query: new URLSearchParams(),
57
+ headers,
58
+ text: async () => "",
59
+ } as any,
60
+ params: {},
61
+ query: {},
62
+ body,
63
+ };
64
+ }
65
+
66
+ test("rejects a request without the configured hook secret", async () => {
67
+ const { app } = memApp();
68
+ const result = await answerFeatureEscalation(input({ corrKey: "owner/repo#9:task-1", answer: "yes" }), app) as any;
69
+ assertEquals(result.status, 401);
70
+ assertEquals(result.body, { ok: false, error: "unauthorized" });
71
+ });
72
+
73
+ test("derives corrKey from plan + task and maps an answered escalation to 200", async () => {
74
+ const { app, published } = memApp([{
75
+ id: 1,
76
+ plan_key: "owner/repo#9",
77
+ task_id: "task-1",
78
+ corr_key: "owner/repo#9:task-1",
79
+ question: "Proceed?",
80
+ status: "open",
81
+ }]);
82
+ const result = await answerFeatureEscalation(
83
+ input({ plan: "owner/repo#9", task: "task-1", answer: " yes " }, "test-secret"),
84
+ app,
85
+ ) as any;
86
+ assertEquals(result.status, 200);
87
+ assertEquals(result.body.ok, true);
88
+ assertEquals(published[0]?.correlationKey, "owner/repo#9:task-1");
89
+ assertEquals((published[0]?.variables as Record<string, unknown>).answer, "yes");
90
+ });
91
+
92
+ test("maps an unmatched corrKey to 404", async () => {
93
+ const { app } = memApp();
94
+ const result = await answerFeatureEscalation(
95
+ input({ corrKey: "owner/repo#9:missing", answer: "yes" }, "test-secret"),
96
+ app,
97
+ ) as any;
98
+ assertEquals(result.status, 404);
99
+ assertEquals(result.body.ok, false);
100
+ });
@@ -0,0 +1,49 @@
1
+ // POST /app/api/hooks/feature-answer → operationId `answerFeatureEscalation` (ADR 0059 webhook
2
+ // operation; was the `/hooks/feature-answer` action). Answers an implementation-phase task
3
+ // escalation out of band (optional shared-secret guard via X-Hook-Secret, enforced only when
4
+ // NANO_PR_WEBHOOK_SECRET is set — mirrors the operator control surface), issue #25. Lets an
5
+ // external system (a chat relay, a CI job, a human via curl) resume a parked implementation agent
6
+ // without the page. Same idempotent `answerTaskEscalation` path the page's answer form uses.
7
+ //
8
+ // The runtime validates the body shape against openapi.yaml; this delegate keeps the semantic
9
+ // checks (an answer is required; a correlation key must be resolvable) and the shared-secret guard.
10
+ // Body accepts either the raw correlation key or a plan+task pair:
11
+ // { "corrKey": "owner/repo#12:task-3", "answer": "…" }
12
+ // { "plan": "owner/repo#12", "task": "task-3", "answer": "…" }
13
+ import { defineOperation } from "@nanobpm/urban";
14
+ import { answerTaskEscalation, featureCorrKey } from "../app/plan.ts";
15
+ import { envVar } from "../app/version.ts";
16
+
17
+ const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
18
+
19
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
20
+
21
+ interface Body {
22
+ corrKey?: unknown;
23
+ plan?: unknown;
24
+ task?: unknown;
25
+ answer?: unknown;
26
+ }
27
+
28
+ export default defineOperation<
29
+ { params: Record<string, string>; query: Record<string, string | string[] | undefined>; body: Body },
30
+ { ok: boolean } & Record<string, unknown>
31
+ >("answerFeatureEscalation", async ({ req, body }, app) => {
32
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
33
+ return { status: 401, body: { ok: false, error: "unauthorized" } };
34
+ }
35
+ const b = body ?? {};
36
+ const answer = str(b.answer);
37
+ if (!answer) return { status: 400, body: { ok: false, error: "answer is required" } };
38
+
39
+ const corrKey = str(b.corrKey) || (str(b.plan) && str(b.task) ? featureCorrKey(str(b.plan), str(b.task)) : "");
40
+ if (!corrKey) {
41
+ return {
42
+ status: 400,
43
+ body: { ok: false, error: "provide corrKey, or both plan (owner/repo#N) and task" },
44
+ };
45
+ }
46
+
47
+ const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
48
+ return { status: r.ok ? 200 : 404, body: r };
49
+ });
@@ -0,0 +1,61 @@
1
+ // POST /app/api/hooks/blackboard?token=<capabilityToken> → operationId `appendBlackboard` (ADR 0059
2
+ // webhook operation; was the POST half of the `/hooks/blackboard` action; Tier 1, issues #51 / #49 D4).
3
+ //
4
+ // The per-plan capability token (query string) IS the credential (see readBlackboard). An unknown
5
+ // token is a 404 (never leaks which plans exist).
6
+ //
7
+ // POST → append one entry: { author_task?, kind?, files?, body, wave?, dedupe_key? }. Idempotent
8
+ // on (plan, dedupe_key). Returns { id, inserted, conflicts } — `conflicts` lists prior
9
+ // sibling `file-claim`s on the same file(s) (advisory first-writer-wins; never a lock).
10
+ import { defineOperation } from "@nanobpm/urban";
11
+ import type { ClaimConflict } from "../app/blackboard.ts";
12
+ import {
13
+ appendEntry,
14
+ detectFileClaimConflicts,
15
+ normalizeKind,
16
+ planKeyForToken,
17
+ } from "../app/blackboard.ts";
18
+
19
+ export default defineOperation<
20
+ { params: Record<string, string>; query: { token?: string }; body: Record<string, unknown> },
21
+ { id: number; inserted: boolean; conflicts: ClaimConflict[] } | { error: string }
22
+ >("appendBlackboard", async ({ req, body }, app) => {
23
+ const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
24
+ if (!token) return { status: 400, body: { error: "missing blackboard token" } };
25
+ const planKey = await planKeyForToken(app.data, token);
26
+ if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
27
+
28
+ const b = body ?? {};
29
+ const text = typeof b.body === "string" ? b.body.trim() : "";
30
+ if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
31
+ const kind = normalizeKind(b.kind);
32
+ const files = Array.isArray(b.files) ? b.files.map(String) : [];
33
+ // Normalize once (trim + default to "system") so the value we send to appendEntry matches the
34
+ // value we send to detectFileClaimConflicts. Otherwise an omitted/blank author_task is stored as
35
+ // "system" but conflict detection sees "", and the caller's own prior "system" claims are wrongly
36
+ // reported as sibling conflicts.
37
+ const author_task = (typeof b.author_task === "string" ? b.author_task.trim() : "") || "system";
38
+ const res = await appendEntry(app.data, planKey, {
39
+ author_task,
40
+ kind,
41
+ files,
42
+ body: text,
43
+ wave: typeof b.wave === "number" ? b.wave : null,
44
+ dedupe_key: typeof b.dedupe_key === "string" ? b.dedupe_key : undefined,
45
+ });
46
+ // Advisory conflict-of-intent: surface prior sibling claims on the same file(s). Computed AFTER
47
+ // the append and filtered to claims strictly before ours (id < res.id), so first-writer-wins is
48
+ // decided by insertion order — a sibling that raced a claim in between is still caught, and our
49
+ // own just-written row is never reported. Never blocks the append — the agent decides how to react.
50
+ const conflicts = kind === "file-claim"
51
+ ? await detectFileClaimConflicts(app.data, planKey, {
52
+ author_task,
53
+ files,
54
+ beforeId: Number(res.id),
55
+ })
56
+ : [];
57
+ return {
58
+ status: res.inserted ? 201 : 200,
59
+ body: { id: Number(res.id), inserted: res.inserted, conflicts },
60
+ };
61
+ });
@@ -1,18 +1,17 @@
1
- // Tests for the /hooks/blackboard endpoint (Tier 1, issues #51 / #49 D4).
2
- import { assertEquals } from "jsr:@std/assert@1";
1
+ // Tests for the /app/api/hooks/blackboard operations `readBlackboard` (GET) + `appendBlackboard`
2
+ // (POST) (ADR 0059; Tier 1, issues #51 / #49 D4).
3
+ import { test } from "node:test";
4
+ import { assertEquals } from "#test-assert";
3
5
  import type { AppApi } from "@nanobpm/urban";
4
- import handler from "./blackboard.ts";
6
+ import readBlackboard from "./readBlackboard.ts";
7
+ import appendBlackboard from "./appendBlackboard.ts";
5
8
 
6
- // deno-lint-ignore no-explicit-any
7
9
  function memApp(): { app: AppApi; stores: Record<string, any[]> } {
8
- // deno-lint-ignore no-explicit-any
9
10
  const stores: Record<string, any[]> = {};
10
11
  const seq: Record<string, number> = {};
11
12
  function tbl(name: string, pk = "id") {
12
- // deno-lint-ignore no-explicit-any
13
13
  const rows = (stores[name] ??= [] as any[]);
14
14
  return {
15
- // deno-lint-ignore no-explicit-any require-await
16
15
  async insert(row: any) {
17
16
  if (pk === "id") {
18
17
  const id = (seq[name] = (seq[name] ?? 0) + 1);
@@ -22,17 +21,14 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
22
21
  rows.push({ ...row });
23
22
  return row[pk];
24
23
  },
25
- // deno-lint-ignore no-explicit-any require-await
26
24
  async find(where: any = {}) {
27
25
  return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
28
26
  },
29
- // deno-lint-ignore no-explicit-any require-await
30
27
  async findOne(where: any = {}) {
31
28
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
32
29
  },
33
30
  };
34
31
  }
35
- // deno-lint-ignore no-explicit-any
36
32
  const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) } } as any as AppApi;
37
33
  return { app, stores };
38
34
  }
@@ -40,7 +36,7 @@ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
40
36
  function req(method: string, query: Record<string, string>) {
41
37
  return {
42
38
  method,
43
- path: "/hooks/blackboard",
39
+ path: "/app/api/hooks/blackboard",
44
40
  query: new URLSearchParams(query),
45
41
  headers: new Headers(),
46
42
  text: async () => "",
@@ -53,9 +49,17 @@ async function call(
53
49
  query: Record<string, string>,
54
50
  body?: unknown,
55
51
  ) {
56
- // deno-lint-ignore no-explicit-any
57
- const res = await handler({ req: req(method, query) as any, body }, app);
58
- // deno-lint-ignore no-explicit-any
52
+ // Method routing is the runtime's job; here we dispatch to the delegate the spec mounts per verb.
53
+ // Be explicit so an unexpected method fails loudly rather than silently running the POST delegate.
54
+ const handler =
55
+ method === "GET"
56
+ ? readBlackboard
57
+ : method === "POST"
58
+ ? appendBlackboard
59
+ : (() => {
60
+ throw new Error(`blackboard test helper: unsupported method ${method}`);
61
+ })();
62
+ const res = await handler({ req: req(method, query) as any, params: {}, query: {}, body } as any, app);
59
63
  return res as any;
60
64
  }
61
65
 
@@ -63,18 +67,18 @@ async function seedPlan(app: AppApi, planKey: string, token: string) {
63
67
  await app.data.table("plans", "plan_key").insert({ plan_key: planKey, blackboard_token: token });
64
68
  }
65
69
 
66
- Deno.test("missing token → 400", async () => {
70
+ test("missing token → 400", async () => {
67
71
  const { app } = memApp();
68
72
  assertEquals((await call(app, "GET", {})).status, 400);
69
73
  });
70
74
 
71
- Deno.test("unknown token → 404 (does not reveal plans)", async () => {
75
+ test("unknown token → 404 (does not reveal plans)", async () => {
72
76
  const { app } = memApp();
73
77
  await seedPlan(app, "o/r#1", "good");
74
78
  assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
75
79
  });
76
80
 
77
- Deno.test("POST appends then GET reads back, scoped by the token's plan", async () => {
81
+ test("POST appends then GET reads back, scoped by the token's plan", async () => {
78
82
  const { app } = memApp();
79
83
  await seedPlan(app, "o/r#1", "tok");
80
84
  const post = await call(app, "POST", { token: "tok" }, {
@@ -94,13 +98,13 @@ Deno.test("POST appends then GET reads back, scoped by the token's plan", async
94
98
  assertEquals(get.body.entries[0].kind, "file-claim");
95
99
  });
96
100
 
97
- Deno.test("POST with a blank body → 400", async () => {
101
+ test("POST with a blank body → 400", async () => {
98
102
  const { app } = memApp();
99
103
  await seedPlan(app, "o/r#1", "tok");
100
104
  assertEquals((await call(app, "POST", { token: "tok" }, { body: " " })).status, 400);
101
105
  });
102
106
 
103
- Deno.test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
107
+ test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
104
108
  const { app, stores } = memApp();
105
109
  await seedPlan(app, "o/r#1", "tok");
106
110
  const body = { author_task: "t", body: "claim", dedupe_key: "t:claim:1" };
@@ -111,7 +115,7 @@ Deno.test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", a
111
115
  assertEquals(stores["plan_blackboard"].length, 1);
112
116
  });
113
117
 
114
- Deno.test("GET ?since returns only newer entries", async () => {
118
+ test("GET ?since returns only newer entries", async () => {
115
119
  const { app } = memApp();
116
120
  await seedPlan(app, "o/r#1", "tok");
117
121
  await call(app, "POST", { token: "tok" }, { body: "one" });
@@ -122,7 +126,7 @@ Deno.test("GET ?since returns only newer entries", async () => {
122
126
  assertEquals(tail.body.entries.map((e: { body: string }) => e.body), ["two"]);
123
127
  });
124
128
 
125
- Deno.test("GET returns a cursor at the plan head for incremental polling (Tier 2)", async () => {
129
+ test("GET returns a cursor at the plan head for incremental polling (Tier 2)", async () => {
126
130
  const { app } = memApp();
127
131
  await seedPlan(app, "o/r#1", "tok");
128
132
  await call(app, "POST", { token: "tok" }, { body: "one" });
@@ -135,7 +139,7 @@ Deno.test("GET returns a cursor at the plan head for incremental polling (Tier 2
135
139
  assertEquals(caughtUp.body.cursor, all.body.cursor);
136
140
  });
137
141
 
138
- Deno.test("POST file-claim surfaces a sibling's prior claim as a conflict (advisory)", async () => {
142
+ test("POST file-claim surfaces a sibling's prior claim as a conflict (advisory)", async () => {
139
143
  const { app } = memApp();
140
144
  await seedPlan(app, "o/r#1", "tok");
141
145
  const first = await call(app, "POST", { token: "tok" }, {
@@ -158,7 +162,7 @@ Deno.test("POST file-claim surfaces a sibling's prior claim as a conflict (advis
158
162
  assertEquals(second.body.conflicts[0].file, "engine/state.rs");
159
163
  });
160
164
 
161
- Deno.test("POST file-claim without author_task does not report the caller's own prior 'system' claim as a conflict", async () => {
165
+ test("POST file-claim without author_task does not report the caller's own prior 'system' claim as a conflict", async () => {
162
166
  const { app } = memApp();
163
167
  await seedPlan(app, "o/r#1", "tok");
164
168
  // First claim omits author_task → stored as "system".
@@ -181,15 +185,9 @@ Deno.test("POST file-claim without author_task does not report the caller's own
181
185
  assertEquals(second.body.conflicts, [], "own prior 'system' claim is not a conflict");
182
186
  });
183
187
 
184
- Deno.test("POST a non-file-claim carries no conflicts", async () => {
188
+ test("POST a non-file-claim carries no conflicts", async () => {
185
189
  const { app } = memApp();
186
190
  await seedPlan(app, "o/r#1", "tok");
187
191
  const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
188
192
  assertEquals(res.body.conflicts, []);
189
193
  });
190
-
191
- Deno.test("unsupported method → 405", async () => {
192
- const { app } = memApp();
193
- await seedPlan(app, "o/r#1", "tok");
194
- assertEquals((await call(app, "DELETE", { token: "tok" })).status, 405);
195
- });
@@ -1,28 +1,23 @@
1
- // Tests for the GET /hooks/abandon endpoint (issue #76).
2
- import { assertEquals } from "jsr:@std/assert@1";
1
+ // Tests for the GET /app/api/hooks/abandon operation `checkAbandon` (ADR 0059; issue #76).
2
+ import { test } from "node:test";
3
+ import { assertEquals } from "#test-assert";
3
4
  import type { AppApi } from "@nanobpm/urban";
4
- import handler from "./abandon.ts";
5
+ import handler from "./checkAbandon.ts";
5
6
 
6
- // deno-lint-ignore no-explicit-any
7
7
  function memApp(): { app: AppApi } {
8
- // deno-lint-ignore no-explicit-any
9
8
  const stores: Record<string, any[]> = {};
10
9
  function tbl(name: string) {
11
- // deno-lint-ignore no-explicit-any
12
10
  const rows = (stores[name] ??= [] as any[]);
13
11
  return {
14
- // deno-lint-ignore no-explicit-any require-await
15
12
  async insert(row: any) {
16
13
  rows.push({ ...row });
17
14
  return row.pr_key;
18
15
  },
19
- // deno-lint-ignore no-explicit-any require-await
20
16
  async findOne(where: any = {}) {
21
17
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
22
18
  },
23
19
  };
24
20
  }
25
- // deno-lint-ignore no-explicit-any
26
21
  const app = { data: { table: (n: string) => tbl(n) } } as any as AppApi;
27
22
  return { app };
28
23
  }
@@ -30,7 +25,7 @@ function memApp(): { app: AppApi } {
30
25
  function req(method: string, query: Record<string, string>) {
31
26
  return {
32
27
  method,
33
- path: "/hooks/abandon",
28
+ path: "/app/api/hooks/abandon",
34
29
  query: new URLSearchParams(query),
35
30
  headers: new Headers(),
36
31
  text: async () => "",
@@ -38,9 +33,7 @@ function req(method: string, query: Record<string, string>) {
38
33
  }
39
34
 
40
35
  async function call(app: AppApi, method: string, query: Record<string, string>) {
41
- // deno-lint-ignore no-explicit-any
42
- const res = await handler({ req: req(method, query) as any, body: undefined }, app);
43
- // deno-lint-ignore no-explicit-any
36
+ const res = await handler({ req: req(method, query) as any, params: {}, query: {}, body: undefined }, app);
44
37
  return res as any;
45
38
  }
46
39
 
@@ -48,18 +41,18 @@ async function seedPr(app: AppApi, prKey: string, token: string, status: string)
48
41
  await app.data.table("pull_requests", "pr_key").insert({ pr_key: prKey, abandon_token: token, status });
49
42
  }
50
43
 
51
- Deno.test("missing token → 400", async () => {
44
+ test("missing token → 400", async () => {
52
45
  const { app } = memApp();
53
46
  assertEquals((await call(app, "GET", {})).status, 400);
54
47
  });
55
48
 
56
- Deno.test("unknown token → 404 (does not reveal PRs)", async () => {
49
+ test("unknown token → 404 (does not reveal PRs)", async () => {
57
50
  const { app } = memApp();
58
51
  await seedPr(app, "o/r#1", "good", "converging");
59
52
  assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
60
53
  });
61
54
 
62
- Deno.test("a running PR → { abandoned: false }", async () => {
55
+ test("a running PR → { abandoned: false }", async () => {
63
56
  const { app } = memApp();
64
57
  await seedPr(app, "o/r#1", "tok", "converging");
65
58
  const res = await call(app, "GET", { token: "tok" });
@@ -67,7 +60,7 @@ Deno.test("a running PR → { abandoned: false }", async () => {
67
60
  assertEquals(res.body, { prKey: "o/r#1", status: "converging", abandoned: false });
68
61
  });
69
62
 
70
- Deno.test("a cancelled PR → { abandoned: true }", async () => {
63
+ test("a cancelled PR → { abandoned: true }", async () => {
71
64
  const { app } = memApp();
72
65
  await seedPr(app, "o/r#1", "tok", "abandoned");
73
66
  const res = await call(app, "GET", { token: "tok" });
@@ -75,19 +68,12 @@ Deno.test("a cancelled PR → { abandoned: true }", async () => {
75
68
  assertEquals(res.body.abandoned, true);
76
69
  });
77
70
 
78
- Deno.test("token via header is accepted", async () => {
71
+ test("token via header is accepted", async () => {
79
72
  const { app } = memApp();
80
73
  await seedPr(app, "o/r#1", "tok", "abandoned");
81
74
  const r = req("GET", {});
82
75
  r.headers.set("x-abandon-token", "tok");
83
- // deno-lint-ignore no-explicit-any
84
- const res = await handler({ req: r as any, body: undefined }, app) as any;
76
+ const res = await handler({ req: r as any, params: {}, query: {}, body: undefined }, app) as any;
85
77
  assertEquals(res.status, 200);
86
78
  assertEquals(res.body.abandoned, true);
87
79
  });
88
-
89
- Deno.test("non-GET → 405", async () => {
90
- const { app } = memApp();
91
- await seedPr(app, "o/r#1", "tok", "converging");
92
- assertEquals((await call(app, "POST", { token: "tok" })).status, 405);
93
- });
@@ -1,4 +1,5 @@
1
- // GET /hooks/abandon?token=<capabilityToken> the cooperative abandon check (issue #76).
1
+ // GET /app/api/hooks/abandon?token=<capabilityToken> operationId `checkAbandon` (ADR 0059
2
+ // webhook operation; was the `/hooks/abandon` action, issue #76).
2
3
  //
3
4
  // A DIRECT side-channel for a running `senior:*` agent to learn whether its run was cancelled
4
5
  // before it performs an irreversible side effect (push / open PR / request review / merge). The
@@ -8,16 +9,16 @@
8
9
  //
9
10
  // GET → { prKey, status, abandoned } — `abandoned` is derived from `pull_requests.status`,
10
11
  // which Urban's cancel primitive sets to 'abandoned' on cancel. `true` ⇒ the agent must stop.
11
- import type { ActionHandler } from "@nanobpm/urban";
12
+ import { defineOperation } from "@nanobpm/urban";
12
13
  import { abandonStatusForToken } from "../app/abandon.ts";
13
14
 
14
- const handler: ActionHandler = async ({ req }, app) => {
15
- if (req.method !== "GET") return { status: 405, body: { error: "method not allowed (use GET)" } };
15
+ export default defineOperation<
16
+ { params: Record<string, string>; query: { token?: string }; body: unknown },
17
+ { prKey: string; status: string; abandoned: boolean } | { error: string }
18
+ >("checkAbandon", async ({ req }, app) => {
16
19
  const token = (req.query.get("token") ?? req.headers.get("x-abandon-token") ?? "").trim();
17
20
  if (!token) return { status: 400, body: { error: "missing abandon token" } };
18
21
  const state = await abandonStatusForToken(app.data, token);
19
22
  if (!state) return { status: 404, body: { error: "unknown abandon token" } };
20
23
  return { status: 200, body: state };
21
- };
22
-
23
- export default handler;
24
+ });
@@ -1,11 +1,11 @@
1
1
  // Tests for GET /app/api/version → operation `getVersion` (ADR 0058 OpenAPI surface).
2
2
  // Ported from the previous actions/version.test.ts. Method handling now belongs to the router
3
3
  // (only GET is routed here), so there is no 405 case to test at the delegate level.
4
- import { assert, assertEquals } from "jsr:@std/assert@1";
4
+ import { test } from "node:test";
5
+ import { assert, assertEquals } from "#test-assert";
5
6
  import type { AppApi } from "@nanobpm/urban";
6
7
  import handler from "./getVersion.ts";
7
8
 
8
- // deno-lint-ignore no-explicit-any
9
9
  const app = {} as any as AppApi;
10
10
 
11
11
  function input(headers: Record<string, string> = {}) {
@@ -16,7 +16,6 @@ function input(headers: Record<string, string> = {}) {
16
16
  query: new URLSearchParams(),
17
17
  headers: new Headers(headers),
18
18
  text: async () => "",
19
- // deno-lint-ignore no-explicit-any
20
19
  } as any,
21
20
  params: {},
22
21
  query: {},
@@ -24,9 +23,8 @@ function input(headers: Record<string, string> = {}) {
24
23
  };
25
24
  }
26
25
 
27
- Deno.test("returns 200 with the app identity", async () => {
26
+ test("returns 200 with the app identity", async () => {
28
27
  const res = await handler(input(), app);
29
- // deno-lint-ignore no-explicit-any
30
28
  const r = res as any;
31
29
  assertEquals(r.status, 200);
32
30
  assertEquals(r.body.name, "nano-workforce");
@@ -40,21 +38,19 @@ Deno.test("returns 200 with the app identity", async () => {
40
38
  assert(typeof r.body.uptimeSeconds === "number");
41
39
  });
42
40
 
43
- Deno.test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
44
- const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
45
- Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
41
+ test("shared-secret guard rejects a missing/wrong secret when configured", async () => {
42
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
43
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
46
44
  try {
47
45
  // SECRET is bound at import time, so import a cache-busted copy to observe the guard.
48
46
  const mod = await import(`./getVersion.ts?guard=${Date.now()}`);
49
47
  const guarded = mod.default as typeof handler;
50
- // deno-lint-ignore no-explicit-any
51
48
  const bad = (await guarded(input(), app)) as any;
52
49
  assertEquals(bad.status, 401);
53
- // deno-lint-ignore no-explicit-any
54
50
  const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
55
51
  assertEquals(ok.status, 200);
56
52
  } finally {
57
- if (prev === undefined) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
58
- else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
53
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
54
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
59
55
  }
60
56
  });
@@ -9,9 +9,8 @@
9
9
  import { defineOperation } from "@nanobpm/urban";
10
10
  import { buildVersionInfo, envVar, type VersionInfo } from "../app/version.ts";
11
11
 
12
- // Cross-runtime env read (Node `process.env` OR Deno `Deno.env`): reading via `process.env` alone
13
- // would silently disable the guard under Deno/compiled runtimes where `process` is absent, leaving
14
- // the endpoint open even when NANO_PR_WEBHOOK_SECRET is set. Captured once, at module load.
12
+ // The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
13
+ // the x-hook-secret header. Captured once, at module load.
15
14
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
16
15
 
17
16
  export default defineOperation<
@@ -1,19 +1,17 @@
1
1
  // Tests for GET /app/api/status → operation `listActivePrs` (ADR 0058 OpenAPI surface).
2
2
  // Covers the happy path (count/prs projection) and the optional shared-secret guard. A minimal
3
3
  // in-memory DataLayer backs `activePrs` (it reads the `pull_requests` table via `.all()`).
4
- import { assert, assertEquals } from "jsr:@std/assert@1";
4
+ import { test } from "node:test";
5
+ import { assert, assertEquals } from "#test-assert";
5
6
  import type { AppApi } from "@nanobpm/urban";
6
7
  import handler from "./listActivePrs.ts";
7
8
 
8
- // deno-lint-ignore no-explicit-any
9
9
  function memApp(rows: any[]): AppApi {
10
10
  const tbl = {
11
- // deno-lint-ignore require-await
12
11
  async all() {
13
12
  return rows;
14
13
  },
15
14
  };
16
- // deno-lint-ignore no-explicit-any
17
15
  return { data: { table: () => tbl } } as any as AppApi;
18
16
  }
19
17
 
@@ -25,7 +23,6 @@ function input(headers: Record<string, string> = {}) {
25
23
  query: new URLSearchParams(),
26
24
  headers: new Headers(headers),
27
25
  text: async () => "",
28
- // deno-lint-ignore no-explicit-any
29
26
  } as any,
30
27
  params: {},
31
28
  query: {},
@@ -33,13 +30,12 @@ function input(headers: Record<string, string> = {}) {
33
30
  };
34
31
  }
35
32
 
36
- Deno.test("returns 200 with a count + projected active PRs", async () => {
33
+ test("returns 200 with a count + projected active PRs", async () => {
37
34
  const app = memApp([
38
35
  { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", title: "t", status: "converging", current_round: 2, process_key: "9", updated_at: "2026-01-02" },
39
36
  { pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", title: null, status: "converged", current_round: 1, process_key: null, updated_at: "2026-01-01" },
40
37
  ]);
41
38
  const res = await handler(input(), app);
42
- // deno-lint-ignore no-explicit-any
43
39
  const r = res as any;
44
40
  assertEquals(r.status, 200);
45
41
  // `converged` is terminal → filtered out, leaving one active PR.
@@ -49,22 +45,20 @@ Deno.test("returns 200 with a count + projected active PRs", async () => {
49
45
  assertEquals(r.body.prs[0].processKey, "9");
50
46
  });
51
47
 
52
- Deno.test("shared-secret guard rejects a missing secret when configured", async () => {
53
- const prev = Deno.env.get("NANO_PR_WEBHOOK_SECRET");
54
- Deno.env.set("NANO_PR_WEBHOOK_SECRET", "s3cr3t");
48
+ test("shared-secret guard rejects a missing secret when configured", async () => {
49
+ const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
50
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
55
51
  try {
56
52
  const mod = await import(`./listActivePrs.ts?guard=${Date.now()}`);
57
53
  const guarded = mod.default as typeof handler;
58
54
  const app = memApp([]);
59
- // deno-lint-ignore no-explicit-any
60
55
  const bad = (await guarded(input(), app)) as any;
61
56
  assertEquals(bad.status, 401);
62
- // deno-lint-ignore no-explicit-any
63
57
  const ok = (await guarded(input({ "x-hook-secret": "s3cr3t" }), app)) as any;
64
58
  assertEquals(ok.status, 200);
65
59
  assert("count" in ok.body);
66
60
  } finally {
67
- if (prev === undefined) Deno.env.delete("NANO_PR_WEBHOOK_SECRET");
68
- else Deno.env.set("NANO_PR_WEBHOOK_SECRET", prev);
61
+ if (prev === undefined) delete process.env["NANO_PR_WEBHOOK_SECRET"];
62
+ else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
69
63
  }
70
64
  });