@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,301 @@
1
+ // nano-workforce — epic coordination blackboard (Tier 1, issues #51 / #49 D4).
2
+ //
3
+ // A per-plan advisory shared store. Implementer agents (`senior:feature`) READ it on dispatch and
4
+ // WRITE to it during/after their work — "I now also touch state.rs", "constraint X changed
5
+ // direction Y" — so parallel siblings in a wave can coordinate without a human relay. It is the
6
+ // machine-actionable substrate the #614 retro's "structured coordination channel" asked for.
7
+ //
8
+ // Design invariants:
9
+ // - ADVISORY ONLY. Never gate a sequence flow on a blackboard read; the BPMN stays the
10
+ // control-flow source of truth. This store is shared *knowledge*, read fresh, and is not part
11
+ // of deterministic replay.
12
+ // - IDEMPOTENT write-back. The engine may re-activate a job on retry, so a re-POST carrying a
13
+ // stable `dedupe_key` is a no-op (backed by a unique index; we also short-circuit here).
14
+ // - CAPABILITY URL. The per-plan token IS the credential; the agent curls the exact URL it was
15
+ // handed (delivered in `appendPrompt`). Delivery is in-band (rides the prompt the harness
16
+ // already forwards); use is out-of-band (a direct side-channel to `/hooks/blackboard`).
17
+ //
18
+ // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
19
+ // app/service.ts and app/plan.ts.
20
+ import type { DataLayer } from "@nanobpm/urban";
21
+
22
+ const now = () => new Date().toISOString();
23
+
24
+ export const BLACKBOARD_KINDS = ["file-claim", "constraint-change", "scope-change", "note"] as const;
25
+ export type BlackboardKind = (typeof BLACKBOARD_KINDS)[number];
26
+
27
+ /** The stored row shape (files is a JSON-encoded string of paths, or NULL). */
28
+ export interface BlackboardRow {
29
+ id: number;
30
+ plan_key: string;
31
+ author_task: string;
32
+ kind: string;
33
+ files: string | null;
34
+ body: string;
35
+ wave: number | null;
36
+ dedupe_key: string | null;
37
+ created_at: string;
38
+ }
39
+
40
+ /** The parsed, agent-facing view of an entry (files decoded to an array). */
41
+ export interface BlackboardEntry {
42
+ id: number;
43
+ author_task: string;
44
+ kind: string;
45
+ files: string[];
46
+ body: string;
47
+ wave: number | null;
48
+ created_at: string;
49
+ }
50
+
51
+ /** What a writer supplies to {@link appendEntry}. `files`/`wave`/`dedupe_key` are optional. */
52
+ export interface BlackboardInput {
53
+ author_task?: string;
54
+ kind?: unknown;
55
+ files?: string[];
56
+ body: string;
57
+ wave?: number | null;
58
+ dedupe_key?: string;
59
+ }
60
+
61
+ const KIND_SET = new Set<string>(BLACKBOARD_KINDS);
62
+
63
+ /** Coerce an arbitrary `kind` to a known value, defaulting to "note" for anything unrecognised. */
64
+ export function normalizeKind(kind: unknown): BlackboardKind {
65
+ return typeof kind === "string" && KIND_SET.has(kind) ? (kind as BlackboardKind) : "note";
66
+ }
67
+
68
+ /** A URL-safe, unguessable capability token (192 bits of randomness, base64url, no padding). */
69
+ export function mintBlackboardToken(): string {
70
+ const bytes = new Uint8Array(24);
71
+ crypto.getRandomValues(bytes);
72
+ let bin = "";
73
+ for (const b of bytes) bin += String.fromCharCode(b);
74
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
75
+ }
76
+
77
+ /** The externally-reachable base URL agents use to reach this app. Must resolve from WHEREVER the
78
+ * agent runs (co-located or remote/containerised), so it is configured, never hardcoded. */
79
+ export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBLIC_BASE_URL): string {
80
+ // Cascade through the fallback chain, skipping any value that is unset OR blank/whitespace, so an
81
+ // explicitly-set-but-empty NANO_PR_PUBLIC_BASE_URL can't yield a malformed capability URL.
82
+ const base = [env, process.env.NANO_PR_BASE_URL, "http://localhost:3000"]
83
+ .map((v) => v?.trim())
84
+ .find((v) => v) as string;
85
+ return base.replace(/\/+$/, "");
86
+ }
87
+
88
+ /** The capability URL for a plan's blackboard: the token rides the query string, so the agent can
89
+ * GET/POST the exact string it was handed with no header assembly. */
90
+ export function blackboardUrl(token: string, base: string = publicBaseUrl()): string {
91
+ return `${base}/hooks/blackboard?token=${encodeURIComponent(token)}`;
92
+ }
93
+
94
+ /** The coordination-protocol block appended (verbatim, via `appendPrompt`) to each implementer
95
+ * agent's prompt. `appendPrompt` injects NO separator, so this owns its own leading rule. It
96
+ * carries the concrete, curl-able URL for THIS plan plus the read/write contract. */
97
+ export function renderCoordinationBrief(url: string): string {
98
+ return `
99
+
100
+ ---
101
+
102
+ ## Epic coordination blackboard
103
+
104
+ You are one of several agents implementing tasks for this epic in parallel. A shared, per-epic
105
+ **blackboard** lets you coordinate with your siblings without a human relay. It is ADVISORY: read it
106
+ for heads-ups, and post when your work affects others. It never blocks you.
107
+
108
+ Your blackboard endpoint (already scoped to this epic — no auth header needed):
109
+
110
+ ${url}
111
+
112
+ **On start — READ it** to see what siblings have claimed or changed:
113
+
114
+ curl -s "${url}"
115
+
116
+ Returns \`{ "planKey": "...", "cursor": <head-entry-id>, "entries": [ { "id", "author_task", "kind", "files", "body", "wave", "created_at" }, ... ] }\` (\`cursor\` is the head entry id, or \`0\` for an empty plan).
117
+ If an entry overlaps your slice (same file, a changed contract/constraint), adapt: coordinate,
118
+ rebase your plan, or if it genuinely blocks you, escalate with a \`question\` per your normal contract.
119
+
120
+ **When your work affects others — POST an entry** (do this as soon as it's true, not only at the end):
121
+
122
+ curl -s -X POST "${url}" -H 'content-type: application/json' \\
123
+ -d '{"author_task":"<your-task-id>","kind":"file-claim","files":["path/to/file"],"body":"why"}'
124
+
125
+ \`kind\` is one of: \`file-claim\` (you now edit a file outside your original slice),
126
+ \`constraint-change\` (you discovered a constraint that changes another task's direction),
127
+ \`scope-change\` (your contract/scope shifted), or \`note\`. Set \`author_task\` to your task id.
128
+ If a retry might make you re-POST the same fact, include a stable \`"dedupe_key"\` so it collapses to
129
+ one entry.
130
+
131
+ **Stay in sync while you work (this matters most while siblings run in parallel).** The GET
132
+ response includes a \`"cursor"\`. Re-read incrementally — before you start each new file, and at
133
+ least every few minutes on long tasks — passing the last cursor back as \`since\` so you fetch only
134
+ what's new:
135
+
136
+ curl -s "${url}&since=<cursor>"
137
+
138
+ **React to a file-claim conflict.** When you POST a \`file-claim\`, the response includes
139
+ \`"conflicts"\`: any prior claims by siblings on the same file(s). First claim wins (advisory). If a
140
+ conflict names you as the later claimer, don't barge in — back off that file, post a \`note\` to
141
+ coordinate, or if it genuinely blocks you, escalate a \`question\` per your normal contract. Nothing
142
+ here is a hard lock; the merge step is the real safety net.`;
143
+ }
144
+
145
+ const blackboardTable = (data: DataLayer) => data.table<BlackboardRow>("plan_blackboard", "id");
146
+
147
+ /** Resolve a capability token back to its plan, or undefined when the token is unknown. */
148
+ export async function planKeyForToken(data: DataLayer, token: string): Promise<string | undefined> {
149
+ if (!token) return undefined;
150
+ const row = await data
151
+ .table<{ plan_key: string; blackboard_token: string | null }>("plans", "plan_key")
152
+ .findOne({ blackboard_token: token });
153
+ return row?.plan_key;
154
+ }
155
+
156
+ function decodeFiles(raw: string | null): string[] {
157
+ if (!raw) return [];
158
+ try {
159
+ const v = JSON.parse(raw);
160
+ return Array.isArray(v) ? v.map(String).map((s) => s.trim()).filter((s) => s !== "") : [];
161
+ } catch {
162
+ return [];
163
+ }
164
+ }
165
+
166
+ function toEntry(r: BlackboardRow): BlackboardEntry {
167
+ return {
168
+ id: r.id,
169
+ author_task: r.author_task,
170
+ kind: r.kind,
171
+ files: decodeFiles(r.files).map((x) => x.trim()).filter((x) => x !== ""),
172
+ body: r.body,
173
+ wave: r.wave,
174
+ created_at: r.created_at,
175
+ };
176
+ }
177
+
178
+ /** One incremental read: the entries after `since` (write order) plus `cursor` — the plan's current
179
+ * head id. An agent polling midflight (Tier 2) passes `cursor` back as the next `since`, so it pulls
180
+ * only what siblings added since its last read. `cursor` is the true head even when `since` filters
181
+ * every entry out, so a caller that is fully caught up learns it is caught up (cursor unchanged). */
182
+ export interface BlackboardPage {
183
+ entries: BlackboardEntry[];
184
+ cursor: number;
185
+ }
186
+
187
+ export async function readBlackboardPage(
188
+ data: DataLayer,
189
+ planKey: string,
190
+ opts: { since?: number } = {},
191
+ ): Promise<BlackboardPage> {
192
+ const rows = await blackboardTable(data).find({ plan_key: planKey });
193
+ const cursor = rows.reduce((max, r) => (r.id > max ? r.id : max), 0);
194
+ const since = opts.since ?? 0;
195
+ const entries = rows
196
+ .filter((r) => r.id > since)
197
+ .sort((a, b) => a.id - b.id)
198
+ .map(toEntry);
199
+ return { entries, cursor };
200
+ }
201
+
202
+ /** A plan's entries in write order (id asc). `since` returns only entries with `id > since`. */
203
+ export async function readBlackboard(
204
+ data: DataLayer,
205
+ planKey: string,
206
+ opts: { since?: number } = {},
207
+ ): Promise<BlackboardEntry[]> {
208
+ return (await readBlackboardPage(data, planKey, opts)).entries;
209
+ }
210
+
211
+ /** An advisory conflict-of-intent: a sibling has already claimed a file this writer is about to
212
+ * claim. Reported per (file, prior claim) so the later claimer can back off, coordinate, or escalate.
213
+ * First-writer-wins is advisory only — the blackboard NEVER locks; merge-time gates are the real
214
+ * safety net. */
215
+ export interface ClaimConflict {
216
+ file: string;
217
+ author_task: string;
218
+ id: number;
219
+ body: string;
220
+ created_at: string;
221
+ }
222
+
223
+ /** Prior `file-claim` entries by OTHER authors on this plan that overlap `files`. Used by the
224
+ * endpoint to surface conflicts on a `file-claim` POST; a writer's own earlier claim is never a
225
+ * conflict with itself. Pass `beforeId` to restrict to strictly prior claims (`id < beforeId`) —
226
+ * the endpoint computes conflicts AFTER inserting its own claim and sets `beforeId` to that new id,
227
+ * so first-writer-wins is decided by insertion order and a sibling claim that raced in concurrently
228
+ * is still surfaced (its row exists by the time we read) without ever matching our own just-written
229
+ * row. */
230
+ export async function detectFileClaimConflicts(
231
+ data: DataLayer,
232
+ planKey: string,
233
+ opts: { author_task?: string; files: string[]; beforeId?: number },
234
+ ): Promise<ClaimConflict[]> {
235
+ const want = new Set((opts.files ?? []).map((f) => String(f).trim()).filter((s) => s !== ""));
236
+ if (want.size === 0) return [];
237
+ const me = opts.author_task?.trim() || "";
238
+ const beforeId = opts.beforeId;
239
+ const rows = await blackboardTable(data).find({ plan_key: planKey, kind: "file-claim" });
240
+ const out: ClaimConflict[] = [];
241
+ for (const r of rows.slice().sort((a, b) => a.id - b.id)) {
242
+ if (beforeId != null && r.id >= beforeId) continue;
243
+ if ((r.author_task || "") === me) continue;
244
+ for (const f of new Set(decodeFiles(r.files).map((x) => x.trim()).filter((x) => x !== ""))) {
245
+ if (want.has(f)) {
246
+ out.push({ file: f, author_task: r.author_task, id: r.id, body: r.body, created_at: r.created_at });
247
+ }
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /** Append an entry, idempotently. A blank `body` is rejected. When a `dedupe_key` is supplied and
254
+ * an entry already exists for it on this plan, the write is a no-op and the existing id is
255
+ * returned (`inserted: false`) — so an engine job retry re-POSTing the same fact never duplicates. */
256
+ export async function appendEntry(
257
+ data: DataLayer,
258
+ planKey: string,
259
+ input: BlackboardInput,
260
+ ): Promise<{ inserted: boolean; id: number | bigint }> {
261
+ const body = typeof input.body === "string" ? input.body.trim() : "";
262
+ if (!body) throw new Error("blackboard entry requires a non-empty body");
263
+ const table = blackboardTable(data);
264
+ const dedupe_key = input.dedupe_key?.trim() || undefined;
265
+ if (dedupe_key) {
266
+ const existing = await table.findOne({ plan_key: planKey, dedupe_key });
267
+ if (existing) return { inserted: false, id: existing.id };
268
+ }
269
+ const files = (input.files ?? []).map(String).map((s) => s.trim()).filter((s) => s !== "");
270
+ try {
271
+ const id = await table.insert({
272
+ plan_key: planKey,
273
+ author_task: input.author_task?.trim() || "system",
274
+ kind: normalizeKind(input.kind),
275
+ files: files.length ? JSON.stringify(files) : null,
276
+ body,
277
+ wave: typeof input.wave === "number" ? input.wave : null,
278
+ dedupe_key: dedupe_key ?? null,
279
+ created_at: now(),
280
+ });
281
+ return { inserted: true, id };
282
+ } catch (err) {
283
+ // Idempotent write-back under concurrency: two POSTs sharing a dedupe_key can both miss the
284
+ // findOne pre-check above, then one loses the race on the UNIQUE (plan_key, dedupe_key) index.
285
+ // Convert that collision into a no-op by re-reading the winner's row, so a retry never 500s.
286
+ if (dedupe_key && isUniqueViolation(err)) {
287
+ const existing = await table.findOne({ plan_key: planKey, dedupe_key });
288
+ if (existing) return { inserted: false, id: existing.id };
289
+ }
290
+ throw err;
291
+ }
292
+ }
293
+
294
+ /** True when an error is a SQLite UNIQUE-constraint violation (however the driver surfaces it). */
295
+ function isUniqueViolation(err: unknown): boolean {
296
+ if (!err || typeof err !== "object") return false;
297
+ const code = (err as { code?: unknown }).code;
298
+ if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT") return true;
299
+ const message = (err as { message?: unknown }).message;
300
+ return typeof message === "string" && /UNIQUE constraint failed/i.test(message);
301
+ }
@@ -0,0 +1,59 @@
1
+ // Unit tests for `fetchPrFiles` token-transport paging (issue #58): the D2 conflict-scan must get
2
+ // a COMPLETE file list or a thrown error — never a silently truncated one that under-approximates
3
+ // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
+ import { assertEquals, assertRejects } from "jsr:@std/assert@1";
5
+ import { fetchPrFiles } from "./github.ts";
6
+
7
+ // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
8
+ // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
9
+ function stubFetch(pages: number[]) {
10
+ const total = pages.reduce((a, b) => a + b, 0);
11
+ return (url: string | URL | Request): Promise<Response> => {
12
+ const u = new URL(String(url));
13
+ const page = Number(u.searchParams.get("page") ?? "1");
14
+ const count = pages[page - 1] ?? 0;
15
+ const start = pages.slice(0, page - 1).reduce((a, b) => a + b, 0);
16
+ const body = Array.from({ length: count }, (_, i) => ({ filename: `f${start + i}` }));
17
+ const headers = new Headers();
18
+ if (page < pages.length) {
19
+ headers.set("link", `<https://api.github.com/next?page=${page + 1}>; rel="next"`);
20
+ }
21
+ return Promise.resolve(
22
+ new Response(JSON.stringify(body), { status: 200, headers }),
23
+ );
24
+ };
25
+ }
26
+
27
+ async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
28
+ const prevMode = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
29
+ const prevFetch = globalThis.fetch;
30
+ Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token");
31
+ globalThis.fetch = stubFetch(pages) as typeof fetch;
32
+ try {
33
+ return await fn();
34
+ } finally {
35
+ globalThis.fetch = prevFetch;
36
+ if (prevMode === undefined) Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
37
+ else Deno.env.set("NANO_PR_GITHUB_TRANSPORT", prevMode);
38
+ }
39
+ }
40
+
41
+ Deno.test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
42
+ const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
43
+ assertEquals(files?.length, 142);
44
+ });
45
+
46
+ Deno.test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
47
+ // 5 full pages, but no `rel="next"` on the last → the list is exactly complete at the cap.
48
+ const files = await withTokenTransport([100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 2, "tok"));
49
+ assertEquals(files?.length, 500);
50
+ });
51
+
52
+ Deno.test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
53
+ // 6 pages available but only 5 fetched → the 5th page still advertises `rel="next"`.
54
+ await assertRejects(
55
+ () => withTokenTransport([100, 100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 3, "tok")),
56
+ Error,
57
+ "truncated",
58
+ );
59
+ });