@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.
- package/.github/workflows/ci.yml +60 -0
- package/.github/workflows/release.yml +58 -0
- package/.releaserc.json +17 -0
- package/AGENTS.md +168 -0
- package/CHANGELOG.md +231 -0
- package/LICENSE +202 -0
- package/README.md +303 -0
- package/SPEC.md +492 -0
- package/actions/abandon.test.ts +93 -0
- package/actions/abandon.ts +23 -0
- package/actions/blackboard.test.ts +195 -0
- package/actions/blackboard.ts +76 -0
- package/actions/cancel.ts +29 -0
- package/actions/feature-answer-hook.ts +44 -0
- package/actions/message.ts +49 -0
- package/actions/plan-hook.ts +19 -0
- package/actions/plan-start.ts +17 -0
- package/actions/start.ts +19 -0
- package/actions/status.ts +22 -0
- package/actions/webhook-submit.ts +21 -0
- package/app/abandon.test.ts +97 -0
- package/app/abandon.ts +105 -0
- package/app/baseGuard.test.ts +35 -0
- package/app/baseGuard.ts +62 -0
- package/app/blackboard.test.ts +295 -0
- package/app/blackboard.ts +301 -0
- package/app/github.test.ts +59 -0
- package/app/github.ts +647 -0
- package/app/mergeExclusion.test.ts +168 -0
- package/app/mergeExclusion.ts +211 -0
- package/app/mergeProtocol.test.ts +124 -0
- package/app/mergeProtocol.ts +193 -0
- package/app/mergeRebaseArm.test.ts +72 -0
- package/app/mergeTrain.test.ts +91 -0
- package/app/mergeTrain.ts +117 -0
- package/app/persist-escalation.test.ts +119 -0
- package/app/persist-round.test.ts +65 -0
- package/app/plan.test.ts +317 -0
- package/app/plan.ts +321 -0
- package/app/record-plan-review.test.ts +38 -0
- package/app/reviewWait.test.ts +70 -0
- package/app/reviewWait.ts +59 -0
- package/app/rounds.test.ts +74 -0
- package/app/rounds.ts +48 -0
- package/app/service.test.ts +101 -0
- package/app/service.ts +895 -0
- package/app/taskDelta.test.ts +144 -0
- package/app/taskDelta.ts +175 -0
- package/app/trialMerge.test.ts +15 -0
- package/app/trialMerge.ts +102 -0
- package/app/waves.test.ts +128 -0
- package/app/waves.ts +116 -0
- package/assets/icon.svg +13 -0
- package/components/review-round.json +69 -0
- package/db/migrations/001_init.sql +46 -0
- package/db/migrations/002_transcript.sql +7 -0
- package/db/migrations/003_open_escalation.sql +8 -0
- package/db/migrations/004_merge.sql +36 -0
- package/db/migrations/004_planning.sql +37 -0
- package/db/migrations/005_job_activation.sql +15 -0
- package/db/migrations/005_plan_deps.sql +20 -0
- package/db/migrations/006_plan_review.sql +22 -0
- package/db/migrations/006_task_escalation.sql +52 -0
- package/db/migrations/007_plan_review_job_key.sql +14 -0
- package/db/migrations/007_wave_gate.sql +16 -0
- package/db/migrations/008_review_nudge.sql +9 -0
- package/db/migrations/009_plan_blackboard.sql +46 -0
- package/db/migrations/010_plan_task_deltas.sql +27 -0
- package/db/migrations/011_plan_merge_exclusions.sql +26 -0
- package/db/migrations/012_merge_protocol_attempt.sql +4 -0
- package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
- package/db/migrations/014_plan_trial_merges.sql +21 -0
- package/db/migrations/015_pr_abandon_token.sql +9 -0
- package/deno.json +24 -0
- package/deno.lock +1776 -0
- package/main.ts +71 -0
- package/nano-ide.ext.json +7 -0
- package/nano.app.json +138 -0
- package/nanobpm.project.json +20 -0
- package/package.json +56 -0
- package/pages/epic.page.json +195 -0
- package/pages/home.page.json +296 -0
- package/prompts/feature.md +132 -0
- package/prompts/fix-ci.md +65 -0
- package/prompts/plan-review.md +69 -0
- package/prompts/plan.md +183 -0
- package/prompts/rebase.md +82 -0
- package/prompts/review-round.md +171 -0
- package/prompts/trial-merge.md +43 -0
- package/renovate.json +21 -0
- package/resources/processes/convergence-loop.bpmn +399 -0
- package/resources/processes/merge-loop.bpmn +585 -0
- package/resources/processes/plan-fanout.bpmn +546 -0
- package/scripts/check-agent-prompts.test.ts +84 -0
- package/scripts/check-agent-prompts.ts +143 -0
- package/scripts/layout-bpmn.ts +99 -0
- package/scripts/purge-db.ts +57 -0
- package/scripts/upgrade-from-pack.ts +334 -0
- package/tsconfig.json +51 -0
- package/workers/arm-merge/worker.ts +18 -0
- package/workers/finalize/worker.ts +89 -0
- package/workers/mark-merged/worker.ts +21 -0
- package/workers/merge/worker.ts +119 -0
- package/workers/persist-escalation/worker.ts +107 -0
- package/workers/persist-round/worker.ts +52 -0
- package/workers/persist-task-escalation/worker.ts +112 -0
- package/workers/record-plan/worker.ts +135 -0
- package/workers/record-plan-review/worker.ts +92 -0
- package/workers/record-results/worker.ts +30 -0
- package/workers/record-trial-merge/worker.test.ts +104 -0
- package/workers/record-trial-merge/worker.ts +88 -0
- package/workers/record-wave/worker.test.ts +221 -0
- package/workers/record-wave/worker.ts +308 -0
- package/workers/select-wave/worker.test.ts +130 -0
- 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
|
+
});
|