@nanobpm/nano-workforce 0.26.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 (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
@@ -0,0 +1,195 @@
1
+ // Tests for the /hooks/blackboard endpoint (Tier 1, issues #51 / #49 D4).
2
+ import { assertEquals } from "jsr:@std/assert@1";
3
+ import type { AppApi } from "@nanobpm/urban";
4
+ import handler from "./blackboard.ts";
5
+
6
+ // deno-lint-ignore no-explicit-any
7
+ function memApp(): { app: AppApi; stores: Record<string, any[]> } {
8
+ // deno-lint-ignore no-explicit-any
9
+ const stores: Record<string, any[]> = {};
10
+ const seq: Record<string, number> = {};
11
+ function tbl(name: string, pk = "id") {
12
+ // deno-lint-ignore no-explicit-any
13
+ const rows = (stores[name] ??= [] as any[]);
14
+ return {
15
+ // deno-lint-ignore no-explicit-any require-await
16
+ async insert(row: any) {
17
+ if (pk === "id") {
18
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
19
+ rows.push({ id, ...row });
20
+ return id;
21
+ }
22
+ rows.push({ ...row });
23
+ return row[pk];
24
+ },
25
+ // deno-lint-ignore no-explicit-any require-await
26
+ async find(where: any = {}) {
27
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
28
+ },
29
+ // deno-lint-ignore no-explicit-any require-await
30
+ async findOne(where: any = {}) {
31
+ return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
32
+ },
33
+ };
34
+ }
35
+ // deno-lint-ignore no-explicit-any
36
+ const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) } } as any as AppApi;
37
+ return { app, stores };
38
+ }
39
+
40
+ function req(method: string, query: Record<string, string>) {
41
+ return {
42
+ method,
43
+ path: "/hooks/blackboard",
44
+ query: new URLSearchParams(query),
45
+ headers: new Headers(),
46
+ text: async () => "",
47
+ };
48
+ }
49
+
50
+ async function call(
51
+ app: AppApi,
52
+ method: string,
53
+ query: Record<string, string>,
54
+ body?: unknown,
55
+ ) {
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
59
+ return res as any;
60
+ }
61
+
62
+ async function seedPlan(app: AppApi, planKey: string, token: string) {
63
+ await app.data.table("plans", "plan_key").insert({ plan_key: planKey, blackboard_token: token });
64
+ }
65
+
66
+ Deno.test("missing token → 400", async () => {
67
+ const { app } = memApp();
68
+ assertEquals((await call(app, "GET", {})).status, 400);
69
+ });
70
+
71
+ Deno.test("unknown token → 404 (does not reveal plans)", async () => {
72
+ const { app } = memApp();
73
+ await seedPlan(app, "o/r#1", "good");
74
+ assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
75
+ });
76
+
77
+ Deno.test("POST appends then GET reads back, scoped by the token's plan", async () => {
78
+ const { app } = memApp();
79
+ await seedPlan(app, "o/r#1", "tok");
80
+ const post = await call(app, "POST", { token: "tok" }, {
81
+ author_task: "gap-2",
82
+ kind: "file-claim",
83
+ files: ["engine/tests.rs"],
84
+ body: "appending to shared boilerplate",
85
+ });
86
+ assertEquals(post.status, 201);
87
+ assertEquals(post.body.inserted, true);
88
+
89
+ const get = await call(app, "GET", { token: "tok" });
90
+ assertEquals(get.status, 200);
91
+ assertEquals(get.body.planKey, "o/r#1");
92
+ assertEquals(get.body.entries.length, 1);
93
+ assertEquals(get.body.entries[0].files, ["engine/tests.rs"]);
94
+ assertEquals(get.body.entries[0].kind, "file-claim");
95
+ });
96
+
97
+ Deno.test("POST with a blank body → 400", async () => {
98
+ const { app } = memApp();
99
+ await seedPlan(app, "o/r#1", "tok");
100
+ assertEquals((await call(app, "POST", { token: "tok" }, { body: " " })).status, 400);
101
+ });
102
+
103
+ Deno.test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async () => {
104
+ const { app, stores } = memApp();
105
+ await seedPlan(app, "o/r#1", "tok");
106
+ const body = { author_task: "t", body: "claim", dedupe_key: "t:claim:1" };
107
+ assertEquals((await call(app, "POST", { token: "tok" }, body)).status, 201);
108
+ const retry = await call(app, "POST", { token: "tok" }, body);
109
+ assertEquals(retry.status, 200);
110
+ assertEquals(retry.body.inserted, false);
111
+ assertEquals(stores["plan_blackboard"].length, 1);
112
+ });
113
+
114
+ Deno.test("GET ?since returns only newer entries", async () => {
115
+ const { app } = memApp();
116
+ await seedPlan(app, "o/r#1", "tok");
117
+ await call(app, "POST", { token: "tok" }, { body: "one" });
118
+ await call(app, "POST", { token: "tok" }, { body: "two" });
119
+ const all = await call(app, "GET", { token: "tok" });
120
+ const since = String(all.body.entries[0].id);
121
+ const tail = await call(app, "GET", { token: "tok", since });
122
+ assertEquals(tail.body.entries.map((e: { body: string }) => e.body), ["two"]);
123
+ });
124
+
125
+ Deno.test("GET returns a cursor at the plan head for incremental polling (Tier 2)", async () => {
126
+ const { app } = memApp();
127
+ await seedPlan(app, "o/r#1", "tok");
128
+ await call(app, "POST", { token: "tok" }, { body: "one" });
129
+ await call(app, "POST", { token: "tok" }, { body: "two" });
130
+ const all = await call(app, "GET", { token: "tok" });
131
+ assertEquals(all.body.cursor, all.body.entries[1].id, "cursor is the head id");
132
+ // Poll from the cursor: caught up, cursor holds.
133
+ const caughtUp = await call(app, "GET", { token: "tok", since: String(all.body.cursor) });
134
+ assertEquals(caughtUp.body.entries, []);
135
+ assertEquals(caughtUp.body.cursor, all.body.cursor);
136
+ });
137
+
138
+ Deno.test("POST file-claim surfaces a sibling's prior claim as a conflict (advisory)", async () => {
139
+ const { app } = memApp();
140
+ await seedPlan(app, "o/r#1", "tok");
141
+ const first = await call(app, "POST", { token: "tok" }, {
142
+ author_task: "gap-2",
143
+ kind: "file-claim",
144
+ files: ["engine/state.rs"],
145
+ body: "owns state.rs",
146
+ });
147
+ assertEquals(first.body.conflicts, [], "first claimer sees no conflict");
148
+
149
+ const second = await call(app, "POST", { token: "tok" }, {
150
+ author_task: "gap-8",
151
+ kind: "file-claim",
152
+ files: ["engine/state.rs"],
153
+ body: "also needs state.rs",
154
+ });
155
+ assertEquals(second.status, 201, "the later claim is still recorded (advisory, not blocked)");
156
+ assertEquals(second.body.conflicts.length, 1);
157
+ assertEquals(second.body.conflicts[0].author_task, "gap-2", "reports the first (winning) claimer");
158
+ assertEquals(second.body.conflicts[0].file, "engine/state.rs");
159
+ });
160
+
161
+ Deno.test("POST file-claim without author_task does not report the caller's own prior 'system' claim as a conflict", async () => {
162
+ const { app } = memApp();
163
+ await seedPlan(app, "o/r#1", "tok");
164
+ // First claim omits author_task → stored as "system".
165
+ const first = await call(app, "POST", { token: "tok" }, {
166
+ kind: "file-claim",
167
+ files: ["engine/state.rs"],
168
+ body: "system owns state.rs",
169
+ });
170
+ assertEquals(first.body.conflicts, []);
171
+ // Same anonymous caller claims the same file again. Because author_task normalizes to "system" for
172
+ // both the append and the conflict detection, the earlier "system" row is the caller's own and must
173
+ // not be reported as a sibling conflict.
174
+ const second = await call(app, "POST", { token: "tok" }, {
175
+ author_task: " ",
176
+ kind: "file-claim",
177
+ files: ["engine/state.rs"],
178
+ body: "system re-claims state.rs",
179
+ });
180
+ assertEquals(second.status, 201);
181
+ assertEquals(second.body.conflicts, [], "own prior 'system' claim is not a conflict");
182
+ });
183
+
184
+ Deno.test("POST a non-file-claim carries no conflicts", async () => {
185
+ const { app } = memApp();
186
+ await seedPlan(app, "o/r#1", "tok");
187
+ const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
188
+ assertEquals(res.body.conflicts, []);
189
+ });
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
+ });
@@ -0,0 +1,76 @@
1
+ // GET/POST /hooks/blackboard?token=<capabilityToken> — the epic coordination blackboard endpoint
2
+ // (Tier 1, issues #51 / #49 D4).
3
+ //
4
+ // This is a DIRECT side-channel for agents, distinct from the c8ctl-nano activation/completion
5
+ // channel. The per-plan capability token (query string) IS the credential: it scopes every read
6
+ // and write to exactly one plan, so no shared secret is needed — the agent curls the exact URL it
7
+ // was handed in its prompt. An unknown token is a 404 (never leaks which plans exist).
8
+ //
9
+ // GET → { planKey, entries: [ { id, author_task, kind, files, body, wave, created_at } ], cursor }
10
+ // optional ?since=<id> returns only entries with id > since (incremental poll). `cursor` is
11
+ // the plan's current head id; pass it back as `since` on the next poll (Tier 2).
12
+ // POST → append one entry: { author_task?, kind?, files?, body, wave?, dedupe_key? }. Idempotent
13
+ // on (plan, dedupe_key). Returns { id, inserted, conflicts } — `conflicts` lists prior
14
+ // sibling `file-claim`s on the same file(s) (advisory first-writer-wins; never a lock).
15
+ import type { ActionHandler } from "@nanobpm/urban";
16
+ import {
17
+ appendEntry,
18
+ detectFileClaimConflicts,
19
+ normalizeKind,
20
+ planKeyForToken,
21
+ readBlackboardPage,
22
+ } from "../app/blackboard.ts";
23
+
24
+ const handler: ActionHandler = async ({ req, body }, app) => {
25
+ const token = (req.query.get("token") ?? req.headers.get("x-blackboard-token") ?? "").trim();
26
+ if (!token) return { status: 400, body: { error: "missing blackboard token" } };
27
+ const planKey = await planKeyForToken(app.data, token);
28
+ if (!planKey) return { status: 404, body: { error: "unknown blackboard token" } };
29
+
30
+ if (req.method === "GET") {
31
+ const rawSince = req.query.get("since");
32
+ const since = rawSince != null && /^\d+$/.test(rawSince) ? Number(rawSince) : undefined;
33
+ const { entries, cursor } = await readBlackboardPage(app.data, planKey, { since });
34
+ return { status: 200, body: { planKey, entries, cursor } };
35
+ }
36
+
37
+ if (req.method === "POST") {
38
+ const b = (body ?? {}) as Record<string, unknown>;
39
+ const text = typeof b.body === "string" ? b.body.trim() : "";
40
+ if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
41
+ const kind = normalizeKind(b.kind);
42
+ const files = Array.isArray(b.files) ? b.files.map(String) : [];
43
+ // Normalize once (trim + default to "system") so the value we send to appendEntry matches the
44
+ // value we send to detectFileClaimConflicts. Otherwise an omitted/blank author_task is stored as
45
+ // "system" but conflict detection sees "", and the caller's own prior "system" claims are wrongly
46
+ // reported as sibling conflicts.
47
+ const author_task = (typeof b.author_task === "string" ? b.author_task.trim() : "") || "system";
48
+ const res = await appendEntry(app.data, planKey, {
49
+ author_task,
50
+ kind,
51
+ files,
52
+ body: text,
53
+ wave: typeof b.wave === "number" ? b.wave : null,
54
+ dedupe_key: typeof b.dedupe_key === "string" ? b.dedupe_key : undefined,
55
+ });
56
+ // Advisory conflict-of-intent: surface prior sibling claims on the same file(s). Computed AFTER
57
+ // the append and filtered to claims strictly before ours (id < res.id), so first-writer-wins is
58
+ // decided by insertion order — a sibling that raced a claim in between is still caught, and our
59
+ // own just-written row is never reported. Never blocks the append — the agent decides how to react.
60
+ const conflicts = kind === "file-claim"
61
+ ? await detectFileClaimConflicts(app.data, planKey, {
62
+ author_task,
63
+ files,
64
+ beforeId: Number(res.id),
65
+ })
66
+ : [];
67
+ return {
68
+ status: res.inserted ? 201 : 200,
69
+ body: { id: Number(res.id), inserted: res.inserted, conflicts },
70
+ };
71
+ }
72
+
73
+ return { status: 405, body: { error: "method not allowed (use GET or POST)" } };
74
+ };
75
+
76
+ export default handler;
@@ -0,0 +1,29 @@
1
+ // POST /app/actions/cancel — override the generic row-cancel action. Terminating the engine
2
+ // instance emits no completion event, so reconcile the app row (status='abandoned', clear the
3
+ // open escalation) here. Accepts either `processInstanceKey` or the `prKey` the status endpoint
4
+ // reports, so a caller can cancel a run it discovered via GET /app/status.
5
+ import type { ActionHandler } from "@nanobpm/urban";
6
+ import { cancelRun } from "../app/service.ts";
7
+
8
+ const str = (v: unknown): string | undefined => {
9
+ if (v == null) return undefined;
10
+ const s = String(v).trim();
11
+ return s === "" ? undefined : s;
12
+ };
13
+
14
+ const handler: ActionHandler = async ({ body }, app) => {
15
+ const b = (body ?? {}) as { processInstanceKey?: unknown; prKey?: unknown };
16
+ const processInstanceKey = str(b.processInstanceKey);
17
+ const prKey = str(b.prKey);
18
+ if (!processInstanceKey && !prKey) {
19
+ return { status: 400, body: { error: "processInstanceKey or prKey is required" } };
20
+ }
21
+ if (processInstanceKey && prKey) {
22
+ return { status: 400, body: { error: "provide exactly one of processInstanceKey or prKey" } };
23
+ }
24
+ const r = await cancelRun(app.data, app.engine, { processInstanceKey, prKey });
25
+ if (r.ok) return { status: 200, body: r };
26
+ return { status: r.kind === "terminal" ? 409 : 404, body: r };
27
+ };
28
+
29
+ export default handler;
@@ -0,0 +1,44 @@
1
+ // POST /hooks/feature-answer — answer an implementation-phase task escalation out
2
+ // of band (optional shared-secret guard via X-Hook-Secret, enforced only when
3
+ // NANO_PR_WEBHOOK_SECRET is set — mirrors /hooks/submit and /hooks/plan), issue #25.
4
+ // Lets an external
5
+ // system (a chat relay, a CI job, a human via curl) resume a parked implementation
6
+ // agent without the page. Same idempotent `answerTaskEscalation` path the page's
7
+ // answer form uses.
8
+ //
9
+ // Body accepts either the raw correlation key or a plan+task pair:
10
+ // { "corrKey": "owner/repo#12:task-3", "answer": "…" }
11
+ // { "plan": "owner/repo#12", "task": "task-3", "answer": "…" }
12
+ import type { ActionHandler } from "@nanobpm/urban";
13
+ import { answerTaskEscalation, featureCorrKey } from "../app/plan.ts";
14
+
15
+ const WEBHOOK_SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
16
+
17
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
18
+
19
+ const handler: ActionHandler = async ({ req, body }, app) => {
20
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
21
+ return { status: 401, body: { error: "unauthorized" } };
22
+ }
23
+ const b = (body ?? {}) as {
24
+ corrKey?: unknown;
25
+ plan?: unknown;
26
+ task?: unknown;
27
+ answer?: unknown;
28
+ };
29
+ const answer = str(b.answer);
30
+ if (!answer) return { status: 400, body: { error: "answer is required" } };
31
+
32
+ const corrKey = str(b.corrKey) || (str(b.plan) && str(b.task) ? featureCorrKey(str(b.plan), str(b.task)) : "");
33
+ if (!corrKey) {
34
+ return {
35
+ status: 400,
36
+ body: { error: "provide corrKey, or both plan (owner/repo#N) and task" },
37
+ };
38
+ }
39
+
40
+ const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
41
+ return { status: r.ok ? 200 : 404, body: r };
42
+ };
43
+
44
+ export default handler;
@@ -0,0 +1,49 @@
1
+ // POST /app/actions/message — override the generic publishMessage action. For the
2
+ // `escalation-answered` message we run the review answer flow, and for
3
+ // `feature-escalation-answered` the implementation-phase (per-task) answer flow
4
+ // (issue #25): record the answer, resume the parked token, then re-surface the
5
+ // next open escalation. Any other message falls back to a plain publishMessage
6
+ // (this override shadows the generic route entirely, so the fallback preserves it).
7
+ import type { ActionHandler } from "@nanobpm/urban";
8
+ import { answerEscalation } from "../app/service.ts";
9
+ import { answerTaskEscalation, FEATURE_ESCALATION_MESSAGE } from "../app/plan.ts";
10
+
11
+ const handler: ActionHandler = async ({ body }, app) => {
12
+ const b = (body ?? {}) as {
13
+ name?: unknown;
14
+ correlationKey?: unknown;
15
+ variables?: Record<string, unknown>;
16
+ };
17
+ const name = String(b.name ?? "");
18
+ if (!name) return { status: 400, body: { error: "name is required" } };
19
+
20
+ if (name === "escalation-answered") {
21
+ const prKey = String(b.correlationKey ?? "");
22
+ const answer = String((b.variables?.answer ?? "") as string).trim();
23
+ if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
24
+ if (!answer) return { status: 400, body: { error: "answer is required" } };
25
+ const r = await answerEscalation(app.data, app.engine, prKey, answer);
26
+ return { status: r.ok ? 200 : 404, body: r };
27
+ }
28
+
29
+ if (name === FEATURE_ESCALATION_MESSAGE) {
30
+ // Implementation-phase task escalation (issue #25): correlationKey is the
31
+ // task's `<plan_key>:<task_id>`; record the answer, resume the parked child,
32
+ // and re-surface the next open escalation.
33
+ const corrKey = String(b.correlationKey ?? "");
34
+ const answer = String((b.variables?.answer ?? "") as string).trim();
35
+ if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
36
+ if (!answer) return { status: 400, body: { error: "answer is required" } };
37
+ const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
38
+ return { status: r.ok ? 200 : 404, body: r };
39
+ }
40
+
41
+ await app.engine.publishMessage({
42
+ name,
43
+ correlationKey: b.correlationKey != null ? String(b.correlationKey) : undefined,
44
+ variables: b.variables,
45
+ });
46
+ return { status: 200, body: { ok: true } };
47
+ };
48
+
49
+ export default handler;
@@ -0,0 +1,19 @@
1
+ // POST /hooks/plan — kick off a planning fan-out out of band (shared-secret auth via
2
+ // X-Hook-Secret). Lets an external system (a GitHub webhook relay on issue open/label, a CI job)
3
+ // hand an issue to the fleet. Same idempotent startPlan path as the page's "Plan issue" action.
4
+ import type { ActionHandler } from "@nanobpm/urban";
5
+ import { parseIssue, startPlan } from "../app/plan.ts";
6
+
7
+ const WEBHOOK_SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
8
+
9
+ const handler: ActionHandler = async ({ req, body }, app) => {
10
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
11
+ return { status: 401, body: { error: "unauthorized" } };
12
+ }
13
+ const b = (body ?? {}) as { url?: unknown; issue?: unknown };
14
+ const parsed = parseIssue(String((b.issue ?? b.url ?? "") as string));
15
+ if (!parsed) return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
16
+ return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
17
+ };
18
+
19
+ export default handler;
@@ -0,0 +1,17 @@
1
+ // POST /app/actions/start/plan-fanout — override the generic "start process" action for the
2
+ // planning fan-out. We parse the issue reference and register/refresh the plan aggregate
3
+ // (idempotent on planKey) before starting the process.
4
+ import type { ActionHandler } from "@nanobpm/urban";
5
+ import { parseIssue, startPlan } from "../app/plan.ts";
6
+
7
+ const handler: ActionHandler = async ({ body }, app) => {
8
+ const vars = ((body as { variables?: Record<string, unknown> })?.variables ?? {}) as Record<string, unknown>;
9
+ const raw = String((vars.issue ?? vars.url ?? "") as string).trim();
10
+ const parsed = parseIssue(raw);
11
+ if (!parsed) {
12
+ return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
13
+ }
14
+ return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
15
+ };
16
+
17
+ export default handler;
@@ -0,0 +1,19 @@
1
+ // POST /app/actions/start/convergence-loop — override the generic "start process" action.
2
+ // The generic runtime would just createInstance; we first parse the PR reference and
3
+ // register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
4
+ import type { ActionHandler } from "@nanobpm/urban";
5
+ import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
6
+
7
+ const handler: ActionHandler = async ({ body }, app) => {
8
+ const vars = ((body as { variables?: Record<string, unknown> })?.variables ?? {}) as Record<string, unknown>;
9
+ const raw = String((vars.pr ?? vars.url ?? "") as string).trim();
10
+ const parsed = parsePr(raw);
11
+ if (!parsed) {
12
+ return { status: 400, body: { error: "could not parse PR (use owner/repo#123 or a PR URL)" } };
13
+ }
14
+ const dependsOn = Array.isArray(vars.dependsOn) ? vars.dependsOn.map((d) => String(d)) : [];
15
+ const maxRounds = clampRounds(vars.maxRounds, MAX_ROUNDS);
16
+ return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
17
+ };
18
+
19
+ export default handler;
@@ -0,0 +1,22 @@
1
+ // GET /app/status — list the PRs currently in flight (every tracked PR not converged/abandoned).
2
+ // A read-only projection over the app datasource so an operator or an external automation
3
+ // harness can see active work — and grab a prKey to cancel — without opening the DB or the UI.
4
+ //
5
+ // Optional shared-secret guard, mirroring /hooks/submit: when NANO_PR_WEBHOOK_SECRET is set,
6
+ // callers must present it via the x-hook-secret header. Unset → open (unchanged default). The
7
+ // pages UI does not call this endpoint (its grid reads the datasource directly), so the guard
8
+ // never affects the UI.
9
+ import type { ActionHandler } from "@nanobpm/urban";
10
+ import { activePrs } from "../app/service.ts";
11
+
12
+ const SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
13
+
14
+ const handler: ActionHandler = async ({ req }, app) => {
15
+ if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
16
+ return { status: 401, body: { error: "unauthorized" } };
17
+ }
18
+ const prs = await activePrs(app.data);
19
+ return { status: 200, body: { count: prs.length, prs } };
20
+ };
21
+
22
+ export default handler;
@@ -0,0 +1,21 @@
1
+ // POST /hooks/submit — submit a PR out-of-band (shared-secret auth via X-Hook-Secret). Not
2
+ // part of the page UI; lets an external system (a GitHub webhook relay, a CI job) kick off a
3
+ // convergence run. Same idempotent submit path as the page's "Start review" action.
4
+ import type { ActionHandler } from "@nanobpm/urban";
5
+ import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
6
+
7
+ const WEBHOOK_SECRET = process.env.NANO_PR_WEBHOOK_SECRET ?? "";
8
+
9
+ const handler: ActionHandler = async ({ req, body }, app) => {
10
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
11
+ return { status: 401, body: { error: "unauthorized" } };
12
+ }
13
+ const b = (body ?? {}) as { url?: unknown; pr?: unknown; dependsOn?: unknown; maxRounds?: unknown };
14
+ const parsed = parsePr(String((b.url ?? b.pr ?? "") as string));
15
+ if (!parsed) return { status: 400, body: { error: "could not parse PR url" } };
16
+ const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
17
+ const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
18
+ return { status: 202, body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds) };
19
+ };
20
+
21
+ export default handler;
@@ -0,0 +1,97 @@
1
+ // Tests for the cooperative abandon-check helpers (issue #76).
2
+ import { assertEquals, assertNotEquals } from "jsr:@std/assert@1";
3
+ import type { DataLayer } from "@nanobpm/urban";
4
+ import {
5
+ abandonStatusForToken,
6
+ abandonUrl,
7
+ isAbandoned,
8
+ mintAbandonToken,
9
+ prKeyForAbandonToken,
10
+ renderAbandonBrief,
11
+ } from "./abandon.ts";
12
+
13
+ // deno-lint-ignore no-explicit-any
14
+ function memData(): DataLayer {
15
+ // deno-lint-ignore no-explicit-any
16
+ const stores: Record<string, any[]> = {};
17
+ function tbl(name: string) {
18
+ // deno-lint-ignore no-explicit-any
19
+ const rows = (stores[name] ??= [] as any[]);
20
+ return {
21
+ // deno-lint-ignore no-explicit-any require-await
22
+ async insert(row: any) {
23
+ rows.push({ ...row });
24
+ return row.pr_key;
25
+ },
26
+ // deno-lint-ignore no-explicit-any require-await
27
+ async findOne(where: any = {}) {
28
+ return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
29
+ },
30
+ };
31
+ }
32
+ // deno-lint-ignore no-explicit-any
33
+ return { table: (n: string) => tbl(n) } as any as DataLayer;
34
+ }
35
+
36
+ async function seedPr(data: DataLayer, pr_key: string, abandon_token: string, status: string) {
37
+ await data.table("pull_requests", "pr_key").insert({ pr_key, abandon_token, status });
38
+ }
39
+
40
+ Deno.test("isAbandoned is true only for the 'abandoned' status", () => {
41
+ assertEquals(isAbandoned("abandoned"), true);
42
+ assertEquals(isAbandoned("converging"), false);
43
+ assertEquals(isAbandoned("converged"), false);
44
+ assertEquals(isAbandoned("merged"), false);
45
+ assertEquals(isAbandoned(null), false);
46
+ assertEquals(isAbandoned(undefined), false);
47
+ });
48
+
49
+ Deno.test("mintAbandonToken is url-safe, unpadded, and unique", () => {
50
+ const a = mintAbandonToken();
51
+ const b = mintAbandonToken();
52
+ assertNotEquals(a, b);
53
+ assertEquals(/^[A-Za-z0-9_-]+$/.test(a), true, "base64url, no padding");
54
+ assertEquals(a.includes("="), false);
55
+ });
56
+
57
+ Deno.test("abandonUrl carries the token on the query string (url-encoded)", () => {
58
+ assertEquals(
59
+ abandonUrl("tok+/=", "https://host"),
60
+ "https://host/hooks/abandon?token=tok%2B%2F%3D",
61
+ );
62
+ });
63
+
64
+ Deno.test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
65
+ const brief = renderAbandonBrief("https://host/hooks/abandon?token=tok");
66
+ assertEquals(brief.includes("https://host/hooks/abandon?token=tok"), true);
67
+ assertEquals(brief.includes("abandoned"), true);
68
+ assertEquals(brief.includes("Abort"), true);
69
+ // Must use `curl -f` so a 404 (torn-down run) fails the command instead of exiting 0 with an
70
+ // error body the agent would parse as "not abandoned".
71
+ assertEquals(brief.includes("curl -fsS"), true);
72
+ });
73
+
74
+ Deno.test("prKeyForAbandonToken resolves a known token and rejects unknowns", async () => {
75
+ const data = memData();
76
+ await seedPr(data, "o/r#1", "tok", "converging");
77
+ assertEquals(await prKeyForAbandonToken(data, "tok"), "o/r#1");
78
+ assertEquals(await prKeyForAbandonToken(data, "nope"), undefined);
79
+ assertEquals(await prKeyForAbandonToken(data, ""), undefined);
80
+ });
81
+
82
+ Deno.test("abandonStatusForToken derives abandoned from the row status", async () => {
83
+ const data = memData();
84
+ await seedPr(data, "o/r#1", "live", "converging");
85
+ await seedPr(data, "o/r#2", "dead", "abandoned");
86
+ assertEquals(await abandonStatusForToken(data, "live"), {
87
+ prKey: "o/r#1",
88
+ status: "converging",
89
+ abandoned: false,
90
+ });
91
+ assertEquals(await abandonStatusForToken(data, "dead"), {
92
+ prKey: "o/r#2",
93
+ status: "abandoned",
94
+ abandoned: true,
95
+ });
96
+ assertEquals(await abandonStatusForToken(data, "nope"), undefined);
97
+ });