@junghanacs/entwurf 0.24.0 → 0.25.1

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/CHANGELOG.md +564 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/DELIVERY.md +24 -1
  4. package/README.md +2 -2
  5. package/VERIFY.md +20 -2
  6. package/demo/README.md +5 -2
  7. package/docs/acp-backend-rail.md +49 -2
  8. package/docs/setup-clean-host.md +4 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +5 -4
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +13 -3
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +11 -0
  18. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  19. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  20. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  21. package/mcp/entwurf-bridge/src/index.ts +5 -4
  22. package/package.json +10 -9
  23. package/pi-extensions/entwurf-control.ts +27 -154
  24. package/pi-extensions/lib/compaction-send-guard.ts +5 -3
  25. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  26. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  27. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  30. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  32. package/pi-extensions/lib/herdr-fresh-call.ts +13 -3
  33. package/pi-extensions/lib/herdr-placement.ts +11 -0
  34. package/protocol.js +0 -8
  35. package/run.sh +202 -33
  36. package/scripts/check-entwurf-v2-surface.ts +289 -10
  37. package/scripts/check-gate-qualification.ts +2354 -11
  38. package/scripts/check-herdr-fresh-call.ts +10 -2
  39. package/scripts/check-herdr-placement.ts +51 -0
  40. package/scripts/check-herdr-plugin-build.ts +11 -4
  41. package/scripts/check-herdr-plugin.ts +745 -38
  42. package/scripts/check-mux-launcher-fence.ts +37 -0
  43. package/scripts/check-pi-launch.ts +7 -4
  44. package/scripts/check-release-gate-outcomes.ts +165 -0
  45. package/scripts/doctor-pi-provider.ts +44 -4
  46. package/scripts/herdr-rails.mjs +75 -0
  47. package/scripts/lib/qualification-acceptance.ts +146 -0
  48. package/scripts/lib/qualification-artifact.ts +359 -0
  49. package/scripts/lib/qualification-collection.ts +384 -0
  50. package/scripts/lib/qualification-gh.ts +231 -0
  51. package/scripts/lib/qualification-ledger.ts +669 -0
  52. package/scripts/lib/qualification-oracle-core.ts +279 -0
  53. package/scripts/lib/qualification-receipt.ts +561 -0
  54. package/scripts/lib/qualification-release.ts +271 -0
  55. package/scripts/meta-bridge-state.py +18 -1
  56. package/scripts/mutants/bridge-command-boot.json +38 -0
  57. package/scripts/mutants/control-send-receipt.json +235 -0
  58. package/scripts/mutants/herdr-fresh-call.json +4 -4
  59. package/scripts/mutants/herdr-plugin-build.json +7 -11
  60. package/scripts/mutants/herdr-plugin.json +177 -9
  61. package/scripts/mutants/meta-retire.json +22 -0
  62. package/scripts/mutants/pack-install.json +4 -4
  63. package/scripts/mutants/pi-floor.json +17 -0
  64. package/scripts/mutants/qualification-ledger.json +489 -0
  65. package/scripts/mutants/release-gate.json +48 -0
  66. package/scripts/mutants/retired-sent-surface.json +32 -0
  67. package/scripts/mutants/v2-surface.json +7 -7
  68. package/scripts/qualification-oracle.ts +124 -0
  69. package/scripts/smoke-codex-fresh-live.ts +94 -13
  70. package/scripts/smoke-herdr-fresh-call-live.ts +15 -8
  71. package/scripts/smoke-herdr-raw-install-live.sh +66 -4
  72. package/scripts/smoke-meta-install-state.sh +107 -1
  73. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  74. package/scripts/smoke-pi-provider-state.sh +106 -0
  75. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -0,0 +1,384 @@
1
+ /**
2
+ * qualification-collection — run several exact-argv groups in one process and record
3
+ * what was attempted (#124 step 1).
4
+ *
5
+ * `check-gate-qualification.ts --groups '<JSON array of argv>' --collect <dir>` runs the
6
+ * head once, then each requested group in order, each through runGroupReceipt on its
7
+ * own fresh snapshot. The collection is an inventory, NOT a verdict:
8
+ *
9
+ * - a group that goes red (SURVIVED, WRONG-REASON, STALE, HANG, control-red, impure
10
+ * snapshot) still writes its receipt, and the tail keeps running — the next group
11
+ * gets a fresh snapshot, so that contamination stays inside the red group;
12
+ * - a runner crash (an exception in the body), an origin seal that moved during a
13
+ * group, or a receipt whose candidate differs from the one the collection started on
14
+ * (bytes moved BETWEEN groups) STOPS the collection: the candidate is no longer the
15
+ * one being measured, or the runner itself is broken. The rest are `not-attempted`;
16
+ * - `collection.json` is rewritten atomically after every group, so a process that dies
17
+ * mid-run leaves an honest, incomplete inventory behind rather than nothing.
18
+ *
19
+ * `complete` means only "every requested group wrote a receipt". Whether those receipts
20
+ * qualify anything is the ledger's question, never this file's.
21
+ */
22
+
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import type { MutantSpec } from "./mutation-qualify.ts";
26
+ import { type CurrentCandidate, checkReceipt, currentCandidate } from "./qualification-ledger.ts";
27
+ import { canonicalJson, runGroupReceipt, sha256 } from "./qualification-receipt.ts";
28
+
29
+ export const COLLECTION_SCHEMA = "entwurf.qualification-collection/v0";
30
+ export const COLLECTION_LABEL = "collection — an inventory of attempted groups, not a verdict";
31
+ export const COLLECTION_FILE = "collection.json";
32
+
33
+ export type AttemptOutcome = "receipt-written" | "runner-crash" | "not-attempted";
34
+
35
+ export interface Attempt {
36
+ argv: string[];
37
+ outcome: AttemptOutcome;
38
+ /** Receipt file name inside the collection dir, when one was written. */
39
+ file: string | null;
40
+ sha256: string | null;
41
+ detail: string | null;
42
+ }
43
+
44
+ export interface Collection {
45
+ schema: string;
46
+ label: string;
47
+ /** GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT exactly as the environment gave them; null off CI. */
48
+ runId: string | null;
49
+ runAttempt: string | null;
50
+ startedAt: string;
51
+ finishedAt: string | null;
52
+ requested: string[][];
53
+ manifestClaimsSha256: string;
54
+ candidate: CurrentCandidate;
55
+ attempted: Attempt[];
56
+ stoppedBy: null | "runner-crash" | "seal-moved" | "candidate-drift";
57
+ complete: boolean;
58
+ }
59
+
60
+ /** `--groups all`: every exact gate argv of the committed set, once, in manifest order. */
61
+ export function allGroupArgv(selected: MutantSpec[]): string[][] {
62
+ const seen = new Set<string>();
63
+ const out: string[][] = [];
64
+ for (const m of selected) {
65
+ const k = JSON.stringify(m.gate);
66
+ if (!seen.has(k)) {
67
+ seen.add(k);
68
+ out.push(m.gate);
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Refuse, before any snapshot: an empty list, a duplicated argv, or one no committed group has. */
75
+ export function checkRequestedGroups(requested: string[][], selected: MutantSpec[]): void {
76
+ if (requested.length === 0) throw new Error("--groups is empty — nothing to collect");
77
+ const known = new Set(selected.map((m) => JSON.stringify(m.gate)));
78
+ const seen = new Set<string>();
79
+ for (const argv of requested) {
80
+ const k = JSON.stringify(argv);
81
+ if (seen.has(k)) throw new Error(`--groups repeats ${k}`);
82
+ seen.add(k);
83
+ if (!known.has(k)) throw new Error(`--groups names ${k}, which is no exact gate argv of the committed manifests`);
84
+ }
85
+ }
86
+
87
+ /** `gNNN.json` for attempt index NNN — the ONLY file name a collection may declare. */
88
+ export function receiptFileName(index: number): string {
89
+ return `g${String(index).padStart(3, "0")}.json`;
90
+ }
91
+
92
+ /**
93
+ * Every way a receipt differs from the candidate its collection started on, for its own
94
+ * group: head, surface, C0, manifest claim set, spec set, claims, declared inputs. Empty =
95
+ * this receipt measured the collection's candidate. Shared by the collector (stop) and
96
+ * checkCollection (re-read), so the two cannot disagree.
97
+ */
98
+ export function candidateDrift(receiptText: string, c: Pick<Collection, "candidate">, argv: string[]): string[] {
99
+ const checked = checkReceipt(receiptText);
100
+ if (!checked.ok) return [`receipt ${checked.code}: ${checked.detail}`];
101
+ const r = checked.receipt;
102
+ const cur = c.candidate;
103
+ const g = cur.groups.find((x) => JSON.stringify(x.gate) === JSON.stringify(argv));
104
+ if (!g) return [`argv ${JSON.stringify(argv)} is not a group of the collection's candidate`];
105
+ const out: string[] = [];
106
+ if (JSON.stringify(r.group.gate) !== JSON.stringify(argv)) out.push("gate");
107
+ if (r.candidate.head !== cur.head) out.push("head");
108
+ if (r.candidate.workSurfaceSha256 !== cur.workSurfaceSha256) out.push("workSurface");
109
+ if (r.core.sha256 !== cur.coreSha256) out.push("core");
110
+ if (JSON.stringify([...r.manifestSet.claims].sort()) !== JSON.stringify(cur.manifestClaims)) out.push("manifestSet");
111
+ if (r.group.specSetSha256 !== g.specSetSha256) out.push("specSet");
112
+ if (JSON.stringify(r.group.claims) !== JSON.stringify(g.claims)) out.push("claims");
113
+ if (canonicalJson(r.group.declaredInputs) !== canonicalJson(g.declaredInputs)) out.push("declaredInputs");
114
+ return out;
115
+ }
116
+
117
+ const O_NOFOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
118
+ const O_NONBLOCK_FLAG = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0;
119
+
120
+ /**
121
+ * Read one entry of a collection dir by BASENAME only, deciding its kind on the very
122
+ * descriptor that is read (pattern of meta-session.ts readStoreRecordFile): a symlink at
123
+ * the name fails the open (ELOOP), a fifo does not block (O_NONBLOCK), and fstat refuses
124
+ * anything that is not a regular file. A name with a separator never reaches the fs.
125
+ */
126
+ function readEntry(dir: string, name: string): { ok: true; bytes: Buffer } | { ok: false; why: string } {
127
+ if (name !== path.basename(name) || name === "." || name === "..") return { ok: false, why: "not a basename" };
128
+ if (O_NOFOLLOW_FLAG === 0) return { ok: false, why: "platform has no O_NOFOLLOW" };
129
+ let fd: number;
130
+ try {
131
+ fd = fs.openSync(path.join(dir, name), fs.constants.O_RDONLY | O_NOFOLLOW_FLAG | O_NONBLOCK_FLAG);
132
+ } catch (err) {
133
+ return { ok: false, why: (err as NodeJS.ErrnoException).code ?? String(err) };
134
+ }
135
+ try {
136
+ if (!fs.fstatSync(fd).isFile()) return { ok: false, why: "not a regular file" };
137
+ return { ok: true, bytes: fs.readFileSync(fd) };
138
+ } finally {
139
+ fs.closeSync(fd);
140
+ }
141
+ }
142
+
143
+ function writeCollection(dir: string, c: Collection): void {
144
+ const file = path.join(dir, COLLECTION_FILE);
145
+ const tmp = `${file}.tmp-${process.pid}`;
146
+ const fd = fs.openSync(tmp, "wx", 0o600);
147
+ try {
148
+ fs.writeFileSync(fd, `${JSON.stringify(c, null, "\t")}\n`);
149
+ fs.fsyncSync(fd);
150
+ } finally {
151
+ fs.closeSync(fd);
152
+ }
153
+ fs.renameSync(tmp, file);
154
+ }
155
+
156
+ export interface CollectOptions {
157
+ repoDir: string;
158
+ selected: MutantSpec[];
159
+ requested: string[][];
160
+ /** Must not exist yet; its parent must. Created 0700. */
161
+ collectDir: string;
162
+ log: (line: string) => void;
163
+ logError: (line: string) => void;
164
+ tmpRoot?: string;
165
+ coreGroupGate?: string[];
166
+ env?: NodeJS.ProcessEnv;
167
+ /** Test seam only: runs before group i starts (the self-test moves origin bytes here). */
168
+ beforeGroup?: (index: number) => void;
169
+ }
170
+
171
+ export async function collectGroups(opts: CollectOptions): Promise<Collection> {
172
+ checkRequestedGroups(opts.requested, opts.selected);
173
+ fs.mkdirSync(opts.collectDir, { mode: 0o700 });
174
+ const env = opts.env ?? process.env;
175
+ const c: Collection = {
176
+ schema: COLLECTION_SCHEMA,
177
+ label: COLLECTION_LABEL,
178
+ runId: env.GITHUB_RUN_ID ?? null,
179
+ runAttempt: env.GITHUB_RUN_ATTEMPT ?? null,
180
+ startedAt: new Date().toISOString(),
181
+ finishedAt: null,
182
+ requested: opts.requested,
183
+ manifestClaimsSha256: sha256(canonicalJson(opts.selected.map((m) => m.claim).sort())),
184
+ candidate: currentCandidate(opts.repoDir, opts.selected, opts.coreGroupGate),
185
+ attempted: opts.requested.map((argv) => ({
186
+ argv,
187
+ outcome: "not-attempted",
188
+ file: null,
189
+ sha256: null,
190
+ detail: null,
191
+ })),
192
+ stoppedBy: null,
193
+ complete: false,
194
+ };
195
+ writeCollection(opts.collectDir, c);
196
+
197
+ for (let i = 0; i < opts.requested.length; i++) {
198
+ opts.beforeGroup?.(i);
199
+ const argv = opts.requested[i];
200
+ const file = receiptFileName(i);
201
+ const receiptPath = path.join(opts.collectDir, file);
202
+ try {
203
+ const r = await runGroupReceipt({
204
+ repoDir: opts.repoDir,
205
+ selected: opts.selected,
206
+ gate: argv,
207
+ receiptPath,
208
+ log: opts.log,
209
+ logError: opts.logError,
210
+ tmpRoot: opts.tmpRoot,
211
+ });
212
+ c.attempted[i] = {
213
+ argv,
214
+ outcome: "receipt-written",
215
+ file,
216
+ sha256: sha256(fs.readFileSync(receiptPath)),
217
+ detail: r.passed ? "group-green" : "group-RED",
218
+ };
219
+ if (!r.sealStable) c.stoppedBy = "seal-moved";
220
+ else {
221
+ const drift = candidateDrift(fs.readFileSync(receiptPath, "utf8"), c, argv);
222
+ if (drift.length > 0) {
223
+ c.attempted[i].detail = `${c.attempted[i].detail}; candidate drift: ${drift.join(", ")}`;
224
+ c.stoppedBy = "candidate-drift";
225
+ }
226
+ }
227
+ } catch (err) {
228
+ c.attempted[i] = {
229
+ argv,
230
+ outcome: "runner-crash",
231
+ file: null,
232
+ sha256: null,
233
+ detail: err instanceof Error ? err.message : String(err),
234
+ };
235
+ c.stoppedBy = "runner-crash";
236
+ }
237
+ writeCollection(opts.collectDir, c);
238
+ if (c.stoppedBy) {
239
+ opts.logError(`[gate-qualification --groups] STOP after ${JSON.stringify(argv)}: ${c.stoppedBy}`);
240
+ break;
241
+ }
242
+ }
243
+ c.complete = c.stoppedBy === null && c.attempted.every((a) => a.outcome === "receipt-written");
244
+ c.finishedAt = new Date().toISOString();
245
+ writeCollection(opts.collectDir, c);
246
+ return c;
247
+ }
248
+
249
+ const isStringArray = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === "string");
250
+ const isStringRecord = (v: unknown): boolean =>
251
+ v !== null && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
252
+
253
+ /** Deep shape of a collection's `candidate` — every field candidateDrift reads, so a malformed one is reported, not thrown. */
254
+ export function candidateShapeOk(v: unknown): boolean {
255
+ if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
256
+ const c = v as Record<string, unknown>;
257
+ return (
258
+ typeof c.head === "string" &&
259
+ typeof c.workSurfaceSha256 === "string" &&
260
+ typeof c.coreSha256 === "string" &&
261
+ (c.coreFiles === undefined || isStringRecord(c.coreFiles)) &&
262
+ isStringArray(c.manifestClaims) &&
263
+ Array.isArray(c.groups) &&
264
+ c.groups.every((g) => {
265
+ if (g === null || typeof g !== "object" || Array.isArray(g)) return false;
266
+ const x = g as Record<string, unknown>;
267
+ return (
268
+ isStringArray(x.gate) &&
269
+ (x.gate as string[]).length > 0 &&
270
+ isStringArray(x.claims) &&
271
+ typeof x.specSetSha256 === "string" &&
272
+ isStringRecord(x.declaredInputs)
273
+ );
274
+ })
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Read a collection dir back and name every way it is not a complete, intact inventory
280
+ * of ONE candidate. Nothing outside `dir` is ever opened: receipt names must be exactly
281
+ * `gNNN.json` for their own index, entries are read by basename with O_NOFOLLOW and
282
+ * checked regular on the descriptor, and a malformed collection.json is reported, never
283
+ * thrown. Empty result = intact AND complete.
284
+ */
285
+ export function checkCollection(dir: string): string[] {
286
+ let dirStat: fs.Stats;
287
+ try {
288
+ dirStat = fs.lstatSync(dir);
289
+ } catch (err) {
290
+ return [`collection-unreadable: ${(err as NodeJS.ErrnoException).code ?? String(err)}`];
291
+ }
292
+ if (!dirStat.isDirectory()) return ["collection-unreadable: the collection path is not a real directory"];
293
+ return checkCollectionData((name) => readEntry(dir, name), fs.readdirSync(dir).sort()).errors;
294
+ }
295
+
296
+ export type EntryReader = (name: string) => { ok: true; bytes: Buffer } | { ok: false; why: string };
297
+
298
+ /** A reader over an in-memory name→bytes map (e.g. an unpacked artifact): basenames only, no fs. */
299
+ export function mapReader(files: Record<string, Buffer>): EntryReader {
300
+ return (name) => {
301
+ if (name !== path.basename(name) || name === "." || name === "..") return { ok: false, why: "not a basename" };
302
+ return Object.hasOwn(files, name) ? { ok: true, bytes: files[name] } : { ok: false, why: "ENOENT" };
303
+ };
304
+ }
305
+
306
+ /**
307
+ * The pure core of checkCollection: judge a collection from a reader and the list of
308
+ * entry names present. Returns the errors (empty = intact AND complete) and the parsed
309
+ * collection when its shape is sound.
310
+ */
311
+ export function checkCollectionData(
312
+ read: EntryReader,
313
+ names: string[],
314
+ ): { errors: string[]; collection: Collection | null } {
315
+ const raw = read(COLLECTION_FILE);
316
+ if (!raw.ok) return { errors: [`collection-unreadable: ${COLLECTION_FILE} ${raw.why}`], collection: null };
317
+ let c: Collection;
318
+ try {
319
+ c = JSON.parse(raw.bytes.toString("utf8"));
320
+ } catch (err) {
321
+ return { errors: [`collection-unreadable: ${err instanceof Error ? err.message : String(err)}`], collection: null };
322
+ }
323
+ const isArgv = (a: unknown): a is string[] =>
324
+ Array.isArray(a) && a.length > 0 && a.every((x) => typeof x === "string");
325
+ const shapeOk =
326
+ c !== null &&
327
+ typeof c === "object" &&
328
+ Array.isArray(c.requested) &&
329
+ c.requested.every(isArgv) &&
330
+ Array.isArray(c.attempted) &&
331
+ c.attempted.every(
332
+ (a) =>
333
+ a !== null &&
334
+ typeof a === "object" &&
335
+ isArgv(a.argv) &&
336
+ ["receipt-written", "runner-crash", "not-attempted"].includes(a.outcome) &&
337
+ (a.file === null || typeof a.file === "string") &&
338
+ (a.sha256 === null || typeof a.sha256 === "string"),
339
+ ) &&
340
+ typeof c.complete === "boolean" &&
341
+ (c.stoppedBy === null || typeof c.stoppedBy === "string") &&
342
+ c.attempted.every((a) => a.detail === null || typeof a.detail === "string") &&
343
+ candidateShapeOk(c.candidate);
344
+ if (!shapeOk) {
345
+ return { errors: ["collection-shape: collection.json is missing or mistyping a required field"], collection: null };
346
+ }
347
+ const errors: string[] = [];
348
+ if (c.schema !== COLLECTION_SCHEMA) errors.push(`collection-schema: ${String(c.schema)}`);
349
+ if (
350
+ c.attempted.length !== c.requested.length ||
351
+ c.attempted.some((a, i) => JSON.stringify(a.argv) !== JSON.stringify(c.requested[i]))
352
+ ) {
353
+ errors.push("collection-order: attempted does not follow requested");
354
+ }
355
+ c.attempted.forEach((a, i) => {
356
+ const label = JSON.stringify(a.argv);
357
+ if (a.outcome !== "receipt-written") {
358
+ errors.push(`collection-incomplete: ${label} ${a.outcome}${a.detail ? ` (${a.detail})` : ""}`);
359
+ return;
360
+ }
361
+ if (a.file !== receiptFileName(i)) {
362
+ errors.push(`collection-file-name: ${label} declares ${JSON.stringify(a.file)}, expected ${receiptFileName(i)}`);
363
+ return;
364
+ }
365
+ const got = read(a.file);
366
+ if (!got.ok) {
367
+ errors.push(
368
+ `${got.why === "ENOENT" ? "collection-missing" : "collection-not-regular"}: ${label} ${a.file} (${got.why})`,
369
+ );
370
+ return;
371
+ }
372
+ if (sha256(got.bytes) !== a.sha256) {
373
+ errors.push(`collection-altered: ${label} ${a.file}`);
374
+ return;
375
+ }
376
+ const drift = candidateDrift(got.bytes.toString("utf8"), c, a.argv);
377
+ if (drift.length > 0) errors.push(`collection-candidate-drift: ${label} ${a.file} differs in ${drift.join(", ")}`);
378
+ });
379
+ const declared = new Set([COLLECTION_FILE, ...c.attempted.flatMap((a) => (a.file ? [a.file] : []))]);
380
+ for (const f of names) if (!declared.has(f)) errors.push(`collection-extra: ${f}`);
381
+ if (c.stoppedBy) errors.push(`collection-stopped: ${c.stoppedBy}`);
382
+ if (!c.complete && errors.length === 0) errors.push("collection-incomplete: complete=false");
383
+ return { errors, collection: c };
384
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * qualification-gh — the thin GitHub API boundary of the composite release oracle
3
+ * (#124 stage 3b). Every GitHub read goes through an injected `GhApi` (json + raw
4
+ * bytes), so the whole contract is testable offline with fixtures; the only networked
5
+ * implementation is `ghCli`, a two-line `gh api` wrapper that no floor test calls.
6
+ *
7
+ * What it proves about ONE run, and how it refuses:
8
+ * - the run is the NEWEST `.github/workflows/ci.yml` run at the SHA (by id) from this
9
+ * repository (not a fork), via push or workflow_dispatch, completed — an older,
10
+ * friendlier run is never selected, and another workflow's runs are not candidates;
11
+ * - the run's CURRENT attempt is the one read: its jobs, and only an artifact named
12
+ * for that attempt. An older attempt's artifact is refused by name, not fallen back to;
13
+ * - the `check` job and its dispatch-only collect + upload steps succeeded — which
14
+ * means only that the collection finished and was uploaded, never that anything
15
+ * qualified;
16
+ * - for the final SHA, the four required jobs succeeded too;
17
+ * - the artifact's raw zip bytes (never a `gh run download` tree) pass admitArtifact.
18
+ *
19
+ * Unobserved, stated rather than assumed: this repository has never uploaded an
20
+ * artifact (coordinator's read-only receipt 2026-09-29: total_count 0), so the real
21
+ * GitHub zip layout is unmeasured. A layout the reader does not accept fails closed.
22
+ */
23
+
24
+ import { spawnSync } from "node:child_process";
25
+ import { type ApiArtifact, admitArtifact, artifactName } from "./qualification-artifact.ts";
26
+ import type { Collection } from "./qualification-collection.ts";
27
+
28
+ export interface GhApi {
29
+ json(path: string): unknown;
30
+ bytes(path: string): Buffer;
31
+ }
32
+
33
+ export const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
34
+ export const REQUIRED_JOBS = ["check", "install-surface", "artifact-consumer", "macos-install-surface"];
35
+ /**
36
+ * The collection runs INSIDE the existing Linux `check` job (after its pinned herdr
37
+ * install and the FULL floor), as dispatch-only steps — never a separate job, so the
38
+ * environment matches the serial body's and the herdr supply contract is untouched.
39
+ */
40
+ export const COMPOSITE_JOB = "check";
41
+ export const COMPOSITE_STEP = "collect qualification receipts (not a verdict)";
42
+ export const COMPOSITE_UPLOAD_STEP = "upload qualification collection";
43
+
44
+ interface ApiRun {
45
+ id: number;
46
+ run_attempt: number;
47
+ head_sha: string;
48
+ event: string;
49
+ status: string;
50
+ conclusion: string | null;
51
+ path: string;
52
+ head_repository: { full_name: string } | null;
53
+ }
54
+
55
+ interface ApiJob {
56
+ name: string;
57
+ conclusion: string | null;
58
+ steps?: { name: string; conclusion: string | null }[];
59
+ }
60
+
61
+ export interface RunEvidence {
62
+ errors: string[];
63
+ runId: string | null;
64
+ runAttempt: string | null;
65
+ files: Record<string, Buffer> | null;
66
+ collection: Collection | null;
67
+ witness: string | null;
68
+ }
69
+
70
+ const fail = (errors: string[], run?: ApiRun): RunEvidence => ({
71
+ errors,
72
+ runId: run ? String(run.id) : null,
73
+ runAttempt: run ? String(run.run_attempt) : null,
74
+ files: null,
75
+ collection: null,
76
+ witness: null,
77
+ });
78
+
79
+ function ciRunsAt(api: GhApi, repo: string, sha: string): ApiRun[] {
80
+ const body = api.json(`repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`) as { workflow_runs?: ApiRun[] };
81
+ return (body.workflow_runs ?? []).filter((r) => r.path === CI_WORKFLOW_PATH && r.head_sha === sha);
82
+ }
83
+
84
+ function jobsOf(api: GhApi, repo: string, run: ApiRun): ApiJob[] {
85
+ const body = api.json(`repos/${repo}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`) as {
86
+ jobs?: ApiJob[];
87
+ };
88
+ return body.jobs ?? [];
89
+ }
90
+
91
+ function compositeSucceeded(jobs: ApiJob[]): string[] {
92
+ const job = jobs.find((j) => j.name === COMPOSITE_JOB);
93
+ if (!job) return [`composite-job: no ${COMPOSITE_JOB} job in this attempt`];
94
+ const errors: string[] = [];
95
+ if (job.conclusion !== "success") errors.push(`composite-job: ${COMPOSITE_JOB} concluded ${String(job.conclusion)}`);
96
+ for (const name of [COMPOSITE_STEP, COMPOSITE_UPLOAD_STEP]) {
97
+ const step = (job.steps ?? []).find((s) => s.name === name);
98
+ if (!step) errors.push(`composite-step: no step named "${name}" in ${COMPOSITE_JOB}`);
99
+ else if (step.conclusion !== "success")
100
+ errors.push(`composite-step: "${name}" concluded ${String(step.conclusion)}`);
101
+ }
102
+ return errors;
103
+ }
104
+
105
+ function checkRunIdentity(run: ApiRun, repo: string, sha: string, final: boolean): string[] {
106
+ const errors: string[] = [];
107
+ if (run.head_repository?.full_name !== repo) {
108
+ errors.push(`run-repository: head repository ${String(run.head_repository?.full_name)} is not ${repo}`);
109
+ }
110
+ if (run.event !== "push" && run.event !== "workflow_dispatch") errors.push(`run-event: ${run.event}`);
111
+ if (run.head_sha !== sha) errors.push(`run-head: ${run.head_sha}`);
112
+ if (run.status !== "completed") errors.push(`run-status: ${run.status}`);
113
+ if (final && run.conclusion !== "success") errors.push(`run-conclusion: ${String(run.conclusion)}`);
114
+ return errors;
115
+ }
116
+
117
+ /** Artifact of the run's CURRENT attempt, raw zip bytes, admitted. */
118
+ function admitRunArtifact(
119
+ api: GhApi,
120
+ repo: string,
121
+ run: ApiRun,
122
+ independentCoreFiles: Record<string, string> | undefined,
123
+ ): RunEvidence {
124
+ const listed = api.json(`repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`) as {
125
+ artifacts?: ApiArtifact[];
126
+ };
127
+ const artifacts = listed.artifacts ?? [];
128
+ const want = artifactName(String(run.id), String(run.run_attempt));
129
+ const hits = artifacts.filter((a) => a.name === want);
130
+ if (hits.length !== 1) {
131
+ const older = artifacts.filter((a) => a.name.startsWith(`qualification-collection-${run.id}-`) && a.name !== want);
132
+ return fail(
133
+ [
134
+ older.length > 0 && hits.length === 0
135
+ ? `artifact-attempt: attempt ${run.run_attempt} has no artifact; ${older.map((a) => a.name).join(", ")} belongs to an older attempt and is refused`
136
+ : `artifact-select: ${hits.length} artifacts named ${want}, need exactly 1`,
137
+ ],
138
+ run,
139
+ );
140
+ }
141
+ const zip = api.bytes(`repos/${repo}/actions/artifacts/${hits[0].id}/zip`);
142
+ const admitted = admitArtifact({
143
+ runId: String(run.id),
144
+ runAttempt: String(run.run_attempt),
145
+ headSha: run.head_sha,
146
+ artifact: hits[0],
147
+ zip,
148
+ independentCoreFiles,
149
+ });
150
+ return {
151
+ errors: admitted.errors,
152
+ runId: String(run.id),
153
+ runAttempt: String(run.run_attempt),
154
+ files: admitted.files,
155
+ collection: admitted.collection,
156
+ witness: admitted.witness,
157
+ };
158
+ }
159
+
160
+ /** The final SHA: newest ci.yml run, four required jobs, composite collection, artifact. */
161
+ export function finalRunEvidence(
162
+ api: GhApi,
163
+ repo: string,
164
+ sha: string,
165
+ independentCoreFiles: Record<string, string>,
166
+ ): RunEvidence {
167
+ const runs = ciRunsAt(api, repo, sha);
168
+ if (runs.length === 0) return fail([`run-missing: no ${CI_WORKFLOW_PATH} run at ${sha}`]);
169
+ const run = runs.reduce((a, b) => (b.id > a.id ? b : a));
170
+ const identity = checkRunIdentity(run, repo, sha, true);
171
+ if (identity.length > 0) return fail(identity, run);
172
+ const jobs = jobsOf(api, repo, run);
173
+ const errors: string[] = [];
174
+ for (const name of REQUIRED_JOBS) {
175
+ const j = jobs.find((x) => x.name === name);
176
+ if (j?.conclusion !== "success")
177
+ errors.push(`required-job: ${name} concluded ${String(j?.conclusion ?? "absent")}`);
178
+ }
179
+ errors.push(...compositeSucceeded(jobs));
180
+ if (errors.length > 0) return fail(errors, run);
181
+ return admitRunArtifact(api, repo, run, independentCoreFiles);
182
+ }
183
+
184
+ /**
185
+ * An ancestor SHA: the operator names a run id; it must be a ci.yml run of this repo at
186
+ * that SHA whose current attempt collected successfully, AND no newer ci.yml run at the
187
+ * same SHA may also carry a successful collection (the newest evidence wins).
188
+ */
189
+ export function ancestorRunEvidence(api: GhApi, repo: string, sha: string, runId: string): RunEvidence {
190
+ const runs = ciRunsAt(api, repo, sha);
191
+ const run = runs.find((r) => String(r.id) === runId);
192
+ if (!run) return fail([`run-missing: ${runId} is not a ${CI_WORKFLOW_PATH} run at ${sha}`]);
193
+ const identity = checkRunIdentity(run, repo, sha, false);
194
+ if (identity.length > 0) return fail(identity, run);
195
+ const own = compositeSucceeded(jobsOf(api, repo, run));
196
+ if (own.length > 0) return fail(own, run);
197
+ // Every newer ci.yml run at this SHA is classified on two separate axes: did it ATTEMPT
198
+ // a collection (its collect step ran, i.e. was not skipped/absent, or it uploaded a
199
+ // collection artifact), and did that attempt SUCCEED. A newer failed attempt rejects
200
+ // this older one — a new collection's failure is never papered over by an old green.
201
+ // A newer run that never attempted (push/schedule: step skipped or absent, no artifact)
202
+ // is not a collection and does not displace this one.
203
+ for (const r of runs.filter((x) => x.id > run.id).sort((x, y) => y.id - x.id)) {
204
+ const jobs = jobsOf(api, repo, r);
205
+ const step = jobs.find((j) => j.name === COMPOSITE_JOB)?.steps?.find((st) => st.name === COMPOSITE_STEP);
206
+ const listed = api.json(`repos/${repo}/actions/runs/${r.id}/artifacts?per_page=100`) as {
207
+ artifacts?: ApiArtifact[];
208
+ };
209
+ const uploaded = (listed.artifacts ?? []).some((a) => a.name.startsWith(`qualification-collection-${r.id}-`));
210
+ const attempted = (step !== undefined && step.conclusion !== "skipped") || uploaded;
211
+ if (!attempted) continue;
212
+ if (compositeSucceeded(jobs).length > 0 || r.conclusion !== "success") {
213
+ return fail(
214
+ [`run-newer-collection-failed: run ${r.id} at the same SHA attempted a collection and did not succeed`],
215
+ run,
216
+ );
217
+ }
218
+ return fail([`run-not-newest: run ${r.id} at the same SHA also collected`], run);
219
+ }
220
+ return admitRunArtifact(api, repo, run, undefined);
221
+ }
222
+
223
+ /** The only networked GhApi. Not called by any floor test. */
224
+ export function ghCli(): GhApi {
225
+ const call = (path: string): Buffer => {
226
+ const r = spawnSync("gh", ["api", path], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
227
+ if (r.status !== 0) throw new Error(`gh api ${path} failed: ${r.stderr.toString("utf8").trim()}`);
228
+ return r.stdout;
229
+ };
230
+ return { json: (path) => JSON.parse(call(path).toString("utf8")), bytes: call };
231
+ }