@haystackeditor/cli 0.15.31 → 0.16.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 (133) hide show
  1. package/README.md +111 -60
  2. package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
  3. package/dist/assets/hooks/scripts/post-commit.sh +3 -0
  4. package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
  5. package/dist/assets/hooks/scripts/pre-push.sh +3 -0
  6. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
  7. package/dist/commands/case-batch-contract.js +608 -0
  8. package/dist/commands/case-batch.js +713 -0
  9. package/dist/commands/cloud-verifier-identity-census.js +5 -2
  10. package/dist/commands/combination-search-hook.js +139 -0
  11. package/dist/commands/dismiss.js +2 -1
  12. package/dist/commands/hooks.js +66 -7
  13. package/dist/commands/install-session-hooks.js +131 -44
  14. package/dist/commands/policy.js +32 -45
  15. package/dist/commands/precompute-delivery.js +6 -1
  16. package/dist/commands/scaffold-provisional-universe.js +8 -10
  17. package/dist/commands/setup.js +32 -10
  18. package/dist/commands/submit.js +7 -2
  19. package/dist/commands/telemetry.js +17 -2
  20. package/dist/commands/triage.js +2 -1
  21. package/dist/commands/verify-explore.js +1 -0
  22. package/dist/commands/verify-hosted-mcp.js +3 -12
  23. package/dist/commands/verify-hosted-reproducibility.js +49 -547
  24. package/dist/commands/verify-hosted.js +66 -168
  25. package/dist/commands/verify-precompute.js +57 -10
  26. package/dist/commands/verify.js +51 -139
  27. package/dist/index.js +238 -350
  28. package/dist/lazy.js +8 -0
  29. package/dist/schema.js +1 -0
  30. package/dist/tools/detect.js +3 -24
  31. package/dist/triage/prompts.js +46 -45
  32. package/dist/triage/runner.js +119 -33
  33. package/dist/types.js +3 -3
  34. package/dist/utils/auth.js +14 -2
  35. package/dist/utils/git.js +60 -29
  36. package/dist/utils/github-api.js +14 -1
  37. package/dist/utils/haystack-api.js +26 -7
  38. package/dist/utils/hooks.js +43 -6
  39. package/dist/utils/prompter.js +25 -10
  40. package/dist/utils/safe-write.js +31 -0
  41. package/dist/utils/secret-paths.js +115 -0
  42. package/dist/utils/secrets.js +0 -1
  43. package/dist/utils/telemetry.js +34 -14
  44. package/dist/utils/update-check.js +151 -0
  45. package/package.json +18 -14
  46. package/schemas/case-batch.v1.json +184 -0
  47. package/schemas/cloud-verifier.v1.json +55 -100
  48. package/dist/commands/ask.d.ts +0 -14
  49. package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
  50. package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
  51. package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
  52. package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
  53. package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
  54. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
  55. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
  56. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
  57. package/dist/commands/cloud-verifier-universe.d.ts +0 -31
  58. package/dist/commands/config.d.ts +0 -46
  59. package/dist/commands/design-verify.d.ts +0 -33
  60. package/dist/commands/dismiss.d.ts +0 -29
  61. package/dist/commands/hooks.d.ts +0 -13
  62. package/dist/commands/inbox.d.ts +0 -65
  63. package/dist/commands/init.d.ts +0 -10
  64. package/dist/commands/install-session-hooks.d.ts +0 -17
  65. package/dist/commands/login.d.ts +0 -8
  66. package/dist/commands/mcp.d.ts +0 -1
  67. package/dist/commands/policy.d.ts +0 -31
  68. package/dist/commands/pr-status.d.ts +0 -144
  69. package/dist/commands/pr.d.ts +0 -40
  70. package/dist/commands/precompute-delivery-contract.d.ts +0 -68
  71. package/dist/commands/precompute-delivery.d.ts +0 -20
  72. package/dist/commands/prepare-universe-review.d.ts +0 -115
  73. package/dist/commands/production-source-deny-policy.d.ts +0 -15
  74. package/dist/commands/request-review.d.ts +0 -26
  75. package/dist/commands/review.d.ts +0 -25
  76. package/dist/commands/rules.d.ts +0 -4
  77. package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
  78. package/dist/commands/schema-cmd.d.ts +0 -2
  79. package/dist/commands/setup.d.ts +0 -28
  80. package/dist/commands/skills.d.ts +0 -8
  81. package/dist/commands/status.d.ts +0 -4
  82. package/dist/commands/submit.d.ts +0 -30
  83. package/dist/commands/system-map.d.ts +0 -42
  84. package/dist/commands/telemetry.d.ts +0 -53
  85. package/dist/commands/tokens.d.ts +0 -14
  86. package/dist/commands/triage.d.ts +0 -35
  87. package/dist/commands/verify-core.d.ts +0 -449
  88. package/dist/commands/verify-core.js +0 -789
  89. package/dist/commands/verify-explore.d.ts +0 -14
  90. package/dist/commands/verify-history.d.ts +0 -14
  91. package/dist/commands/verify-hosted-mcp.d.ts +0 -12
  92. package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
  93. package/dist/commands/verify-hosted.d.ts +0 -92
  94. package/dist/commands/verify-mcp.d.ts +0 -8
  95. package/dist/commands/verify-mcp.js +0 -517
  96. package/dist/commands/verify-ops.d.ts +0 -158
  97. package/dist/commands/verify-ops.js +0 -1148
  98. package/dist/commands/verify-precompute.d.ts +0 -30
  99. package/dist/commands/verify-reproducibility.d.ts +0 -85
  100. package/dist/commands/verify-reproducibility.js +0 -494
  101. package/dist/commands/verify-reseal.d.ts +0 -9
  102. package/dist/commands/verify-reseal.js +0 -148
  103. package/dist/commands/verify-sandboxes.d.ts +0 -95
  104. package/dist/commands/verify-sandboxes.js +0 -352
  105. package/dist/commands/verify.d.ts +0 -28
  106. package/dist/commands/webhooks.d.ts +0 -30
  107. package/dist/index.d.ts +0 -22
  108. package/dist/schema.d.ts +0 -28
  109. package/dist/states.d.ts +0 -29
  110. package/dist/tools/detect.d.ts +0 -50
  111. package/dist/triage/prompts.d.ts +0 -24
  112. package/dist/triage/runner.d.ts +0 -34
  113. package/dist/triage/types.d.ts +0 -42
  114. package/dist/types/verify-history.d.ts +0 -38
  115. package/dist/types.d.ts +0 -1684
  116. package/dist/utils/action-output.d.ts +0 -24
  117. package/dist/utils/analysis-api.d.ts +0 -187
  118. package/dist/utils/auth.d.ts +0 -79
  119. package/dist/utils/config.d.ts +0 -24
  120. package/dist/utils/design-verifier-api.d.ts +0 -208
  121. package/dist/utils/design-verifier-history.d.ts +0 -46
  122. package/dist/utils/design-verifier-result.d.ts +0 -74
  123. package/dist/utils/detect.d.ts +0 -43
  124. package/dist/utils/git.d.ts +0 -135
  125. package/dist/utils/github-api.d.ts +0 -104
  126. package/dist/utils/haystack-api.d.ts +0 -37
  127. package/dist/utils/hooks.d.ts +0 -12
  128. package/dist/utils/pending-state.d.ts +0 -40
  129. package/dist/utils/pr-ref.d.ts +0 -27
  130. package/dist/utils/prompter.d.ts +0 -85
  131. package/dist/utils/secrets.d.ts +0 -47
  132. package/dist/utils/telemetry.d.ts +0 -19
  133. /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
@@ -0,0 +1,713 @@
1
+ /**
2
+ * `haystack case-batch` — submit an already-generated product-fuzzing case
3
+ * batch to the hosted coordinator, poll it, cancel it, and fetch its replay
4
+ * bundle. Lane D of docs/case-batch-coordinator.md: the laptop only submits
5
+ * and polls.
6
+ *
7
+ * The CLI holds no fleet credential. Every request below carries exactly one
8
+ * credential, the caller's Haystack login bearer resolved by
9
+ * `resolveAuthContext`; nothing here reads HAYSTACK_API_TOKEN, a queue service
10
+ * token, or a control-plane lease. Case generation, ranking, confirmation,
11
+ * shrinking and judging stay on the submitting side and are not here either.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { dirname, isAbsolute, resolve, sep } from 'node:path';
16
+ import chalk from 'chalk';
17
+ import { withSchema } from '../schema.js';
18
+ import { resolveAuthContext } from '../utils/auth.js';
19
+ import { classifyHttpError, haystackApiUrl } from '../utils/haystack-api.js';
20
+ import { boundedCaseBatchJson, buildCaseBatchRequest, CASE_BATCH_MAX_CASES, CASE_BATCH_MAX_CONCURRENT_CASES, CASE_BATCH_MAX_INPUT_BYTES, CASE_BATCH_MAX_CASE_WALL_MS, CASE_BATCH_MAX_TOTAL_BUDGET_MS, CASE_BATCH_MIN_CASE_WALL_MS, CASE_BATCH_MIN_TOTAL_BUDGET_MS, CaseBatchRequestValidationError, CaseBatchResponseError, isJsonObject, isTerminalCaseBatchStatus, parseCaseBatchCommit, parseCaseBatchIdempotencyKey, parseCaseBatchLimits, parseCaseBatchRepository, parseCaseBatchSnapshot, parseCaseBatchSource, parseCaseBatchWorld, parseProductCases, } from './case-batch-contract.js';
21
+ /** The CLI gateway mount. The same worker handlers are `/v1/case-batches` and
22
+ * `/api/cloud-verifier/case-batches`; CLI callers use the agent gateway, as
23
+ * they already do for `/api/agent/cloud-verifier/searches`.
24
+ *
25
+ * BLOCKED ON THE SERVER LANE. No handler serves this path in this repository
26
+ * yet, and the auth worker does not proxy it: `isSearchProxyPath` in
27
+ * `infra/auth-worker/index.js` covers `/api/agent/cloud-verifier/searches`
28
+ * only, and `agent/cloudflare/src/combination-search-route.ts` has no
29
+ * case-batch sibling. Until Lane A of `docs/case-batch-coordinator.md` lands
30
+ * `POST/GET /v1/case-batches`, `GET/DELETE /v1/case-batches/:runId`,
31
+ * `GET /v1/case-batches/:runId/bundle[/path]` and the matching auth-worker
32
+ * proxy entry, every command in this file reaches a 404 in production. That
33
+ * lane must merge first; this file is written against the frozen contract
34
+ * ahead of it, which is what the implementation plan assigns to Lane D.
35
+ *
36
+ * Three things the server side must hold for this file to work as written:
37
+ * the 8 MiB request bound, `repository`-scoped reads with `limit` and
38
+ * `caseCursor` pagination, and a bundle route that serves each sealed object's
39
+ * SHA-256 as its ETag (the manifest's is required here, because the manifest
40
+ * is the one artifact with no parent digest to check it against). */
41
+ const GATEWAY = '/api/agent/cloud-verifier/case-batches';
42
+ const RUN_ID = /^cv_[0-9a-f]{48}$/;
43
+ /** Page size for the bounded case pagination; the search read route caps
44
+ * `limit` at 500 and the batch read mirrors it. */
45
+ const CASE_PAGE_LIMIT = 500;
46
+ /** A hard stop on pagination independent of what the server reports, so a
47
+ * cursor that never terminates cannot spin the CLI forever. */
48
+ const MAX_CASE_PAGES = Math.ceil(CASE_BATCH_MAX_CASES / CASE_PAGE_LIMIT) + 2;
49
+ const DEFAULT_POLL_INTERVAL_SECONDS = 10;
50
+ const DEFAULT_POLL_MAX_WALL_MINUTES = 45;
51
+ const MAX_POLL_INTERVAL_SECONDS = 300;
52
+ const MAX_POLL_MAX_WALL_MINUTES = 240;
53
+ /** Per-case wall default. The MVP measured 66.8 s min / 78.8 s median /
54
+ * 103.8 s max over its 48 executed cases (docs/case-batch-coordinator.md), so
55
+ * this sits above the measured maximum: the default kills a hung guest without
56
+ * killing a case that runs at today's measured cost. The 15 s acceptance
57
+ * target is what the fleet work is chasing, not a safe default to impose. */
58
+ const DEFAULT_PER_CASE_WALL_MS = 120_000;
59
+ /** Launch budget default: the contract ceiling. The budget bounds launches,
60
+ * not the run, and a shorter one silently truncates a large batch. */
61
+ const DEFAULT_TOTAL_BUDGET_MS = CASE_BATCH_MAX_TOTAL_BUDGET_MS;
62
+ const MAX_BUNDLE_ARTIFACTS = 20_000;
63
+ const MAX_BUNDLE_BYTES = 512 * 1024 * 1024;
64
+ const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
65
+ function readJsonFile(path, flag) {
66
+ if (typeof path !== 'string' || path.length === 0) {
67
+ throw new CaseBatchRequestValidationError(`${flag} is required and must name a JSON file.`);
68
+ }
69
+ let source;
70
+ try {
71
+ source = readFileSync(path, 'utf8');
72
+ }
73
+ catch (error) {
74
+ throw new CaseBatchRequestValidationError(`Could not read ${flag} "${path}": ${error instanceof Error ? error.message : String(error)}`);
75
+ }
76
+ if (Buffer.byteLength(source) > CASE_BATCH_MAX_INPUT_BYTES) {
77
+ throw new CaseBatchRequestValidationError(`${flag} "${path}" is larger than the ${CASE_BATCH_MAX_INPUT_BYTES}-byte case-batch request bound.`);
78
+ }
79
+ try {
80
+ return JSON.parse(source);
81
+ }
82
+ catch {
83
+ throw new CaseBatchRequestValidationError(`${flag} "${path}" must contain valid JSON.`);
84
+ }
85
+ }
86
+ function flagInteger(value, fallback, flag, minimum, maximum) {
87
+ if (value === undefined)
88
+ return fallback;
89
+ if (!/^\d{1,15}$/.test(value)) {
90
+ throw new CaseBatchRequestValidationError(`${flag} must be a whole number from ${minimum} to ${maximum}.`);
91
+ }
92
+ const parsed = Number(value);
93
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
94
+ throw new CaseBatchRequestValidationError(`${flag} must be a whole number from ${minimum} to ${maximum}.`);
95
+ }
96
+ return parsed;
97
+ }
98
+ function requireRunId(runId) {
99
+ if (!RUN_ID.test(runId)) {
100
+ throw new CaseBatchRequestValidationError('Run id must be the exact cv_ id returned by `haystack case-batch submit` (cv_ plus 48 lowercase hex characters).');
101
+ }
102
+ return runId;
103
+ }
104
+ /** Every outbound request. The header set is built here and nowhere else, so
105
+ * the credential surface of this command is one line: the login bearer. */
106
+ async function gatewayFetch(path, token, init = { method: 'GET' }) {
107
+ const headers = new Headers();
108
+ headers.set('Authorization', `Bearer ${token}`);
109
+ headers.set('Accept', init.accept ?? 'application/json');
110
+ headers.set('User-Agent', 'Haystack-CLI');
111
+ if (init.body !== undefined)
112
+ headers.set('Content-Type', 'application/json');
113
+ return fetch(haystackApiUrl(path), {
114
+ method: init.method,
115
+ headers,
116
+ ...(init.body === undefined ? {} : { body: init.body }),
117
+ signal: AbortSignal.timeout(120_000),
118
+ });
119
+ }
120
+ async function gatewayJson(path, token, init = { method: 'GET' }) {
121
+ const response = await gatewayFetch(path, token, init);
122
+ if (!response.ok)
123
+ throw await classifyHttpError(response, `Haystack API ${path}`);
124
+ let body;
125
+ try {
126
+ body = await response.json();
127
+ }
128
+ catch (error) {
129
+ throw new CaseBatchResponseError(`Case batch service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
130
+ }
131
+ return { status: response.status, body };
132
+ }
133
+ function repositoryQuery(repository) {
134
+ return `repository=${encodeURIComponent(repository)}`;
135
+ }
136
+ async function authFor(repository, account) {
137
+ const parsed = parseCaseBatchRepository(repository);
138
+ const auth = await resolveAuthContext({
139
+ preferredLogin: account,
140
+ owner: parsed.owner,
141
+ repo: parsed.name,
142
+ });
143
+ return { repository: parsed, auth };
144
+ }
145
+ /* ------------------------------------------------------------------ submit */
146
+ export function defaultCaseBatchIdempotencyKey(request) {
147
+ return `case-batch:${createHash('sha256').update(JSON.stringify(request)).digest('hex')}`;
148
+ }
149
+ export function prepareCaseBatchSubmission(options) {
150
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
151
+ throw new CaseBatchRequestValidationError('--repository is required and must be an exact GitHub owner/repository name.');
152
+ }
153
+ const repository = parseCaseBatchRepository(options.repository);
154
+ const baseCommit = parseCaseBatchCommit(options.base, '--base');
155
+ const headCommit = parseCaseBatchCommit(options.head, '--head');
156
+ const baseWorld = options.baseWorld === undefined ? undefined : parseCaseBatchWorld(options.baseWorld, '--base-world');
157
+ const headWorld = options.headWorld === undefined ? undefined : parseCaseBatchWorld(options.headWorld, '--head-world');
158
+ const cases = parseProductCases(readJsonFile(options.cases, '--cases'));
159
+ const combinations = boundedCaseBatchJson(readJsonFile(options.combinations, '--combinations'), 'combination space');
160
+ const limits = parseCaseBatchLimits({
161
+ // A ceiling, not a reservation: effective concurrency is the smaller of
162
+ // this and what GET /v1/capacity leaves free. Defaulting to the batch's own
163
+ // size asks for exactly as much as the batch can use, capped by the
164
+ // contract ceiling, instead of inventing a number.
165
+ maxConcurrentCases: flagInteger(options.maxConcurrent, Math.min(cases.length, CASE_BATCH_MAX_CONCURRENT_CASES), '--max-concurrent', 1, CASE_BATCH_MAX_CONCURRENT_CASES),
166
+ perCaseWallMs: flagInteger(options.perCaseWallMs, DEFAULT_PER_CASE_WALL_MS, '--per-case-wall-ms', CASE_BATCH_MIN_CASE_WALL_MS, CASE_BATCH_MAX_CASE_WALL_MS),
167
+ totalBudgetMs: flagInteger(options.totalBudgetMs, DEFAULT_TOTAL_BUDGET_MS, '--total-budget-ms', CASE_BATCH_MIN_TOTAL_BUDGET_MS, CASE_BATCH_MAX_TOTAL_BUDGET_MS),
168
+ });
169
+ const source = options.sourcePatch === undefined
170
+ ? undefined
171
+ : parseCaseBatchSource(readJsonFile(options.sourcePatch, '--source-patch'), baseCommit, headCommit);
172
+ const identity = {
173
+ repository: repository.fullName,
174
+ baseCommit,
175
+ headCommit,
176
+ baseWorld: baseWorld ?? null,
177
+ headWorld: headWorld ?? null,
178
+ combinations,
179
+ cases,
180
+ limits,
181
+ source: source ? source.patchSha256 : null,
182
+ };
183
+ const idempotencyKey = parseCaseBatchIdempotencyKey(options.idempotencyKey ?? defaultCaseBatchIdempotencyKey(identity));
184
+ const request = buildCaseBatchRequest({
185
+ repository: repository.fullName,
186
+ baseCommit,
187
+ headCommit,
188
+ idempotencyKey,
189
+ baseWorld,
190
+ headWorld,
191
+ combinations: combinations,
192
+ cases,
193
+ limits,
194
+ source,
195
+ });
196
+ return { repository, request, caseCount: cases.length, limits };
197
+ }
198
+ export async function caseBatchSubmitCommand(options) {
199
+ // Validate every local input before touching credentials or the network.
200
+ const prepared = prepareCaseBatchSubmission(options);
201
+ const { auth } = await authFor(prepared.repository.fullName, options.account);
202
+ if (!options.json) {
203
+ console.error(chalk.dim(`Submitting ${prepared.caseCount} cases to the hosted coordinator…`));
204
+ }
205
+ const { status, body } = await gatewayJson(GATEWAY, auth.token, {
206
+ method: 'POST',
207
+ body: JSON.stringify(prepared.request),
208
+ });
209
+ if (!isJsonObject(body) || typeof body.runId !== 'string' || !RUN_ID.test(body.runId)) {
210
+ throw new CaseBatchResponseError('Case batch admission did not return a run id.');
211
+ }
212
+ const runId = body.runId;
213
+ // The search route answers 202 for a newly admitted run and 200 for an
214
+ // identical replay of an admitted idempotency key.
215
+ const admitted = status === 200 ? 'existing' : 'created';
216
+ const nextCommand = `haystack case-batch status ${runId} --repository ${prepared.repository.fullName} --watch`;
217
+ if (options.json) {
218
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
219
+ operation: 'submit',
220
+ run_id: runId,
221
+ admitted,
222
+ repository: prepared.repository.fullName,
223
+ base_commit: prepared.request.baseCommit,
224
+ head_commit: prepared.request.headCommit,
225
+ idempotency_key: prepared.request.idempotencyKey,
226
+ cases: prepared.caseCount,
227
+ limits: {
228
+ max_concurrent_cases: prepared.limits.maxConcurrentCases,
229
+ per_case_wall_ms: prepared.limits.perCaseWallMs,
230
+ total_budget_ms: prepared.limits.totalBudgetMs,
231
+ },
232
+ ...(prepared.request.baseWorld ? { base_world: prepared.request.baseWorld } : {}),
233
+ ...(prepared.request.headWorld ? { head_world: prepared.request.headWorld } : {}),
234
+ source_patch_attached: prepared.request.source !== undefined,
235
+ next_command: nextCommand,
236
+ }), null, 2)}\n`);
237
+ return;
238
+ }
239
+ console.log(`${chalk.cyan('•')} Case batch ${chalk.bold(runId)} ${admitted === 'existing' ? 'already admitted' : 'queued'}`);
240
+ console.log(` ${chalk.dim('Repository:')} ${prepared.repository.fullName}`);
241
+ console.log(` ${chalk.dim('Commits:')} ${prepared.request.baseCommit.slice(0, 12)} → ${prepared.request.headCommit.slice(0, 12)}`);
242
+ console.log(` ${chalk.dim('Cases:')} ${prepared.caseCount}`);
243
+ console.log(` ${chalk.dim('Limits:')} max ${prepared.limits.maxConcurrentCases} concurrent (ceiling; capacity decides), `
244
+ + `${prepared.limits.perCaseWallMs} ms per case, ${prepared.limits.totalBudgetMs} ms launch budget`);
245
+ console.log(` ${chalk.dim('Idempotency key:')} ${prepared.request.idempotencyKey}`);
246
+ console.log(` ${chalk.dim('Poll:')} ${nextCommand}`);
247
+ }
248
+ /** One bounded read: follow `nextCaseCursor` to the end of the case list. */
249
+ export async function readCaseBatch(runId, repository, token) {
250
+ const collected = new Map();
251
+ let cursor = null;
252
+ let snapshot = null;
253
+ let pages = 0;
254
+ let wavesTruncated = false;
255
+ let truncated = false;
256
+ for (;;) {
257
+ const query = `?${repositoryQuery(repository)}&limit=${CASE_PAGE_LIMIT}`
258
+ + (cursor === null ? '' : `&caseCursor=${encodeURIComponent(cursor)}`);
259
+ const { body } = await gatewayJson(`${GATEWAY}/${encodeURIComponent(runId)}${query}`, token);
260
+ const page = parseCaseBatchSnapshot(body, runId);
261
+ pages += 1;
262
+ // The freshest page owns the summary; rows are keyed by their stable case
263
+ // id so a checkpoint landing between pages cannot duplicate a case.
264
+ snapshot = page;
265
+ for (const record of page.cases)
266
+ collected.set(record.id, record);
267
+ if (pages === 1)
268
+ wavesTruncated = page.nextWaveCursor !== null;
269
+ if (page.nextCaseCursor === null)
270
+ break;
271
+ if (page.cases.length === 0) {
272
+ throw new CaseBatchResponseError('Case batch pagination returned an empty page with a cursor.');
273
+ }
274
+ if (pages >= MAX_CASE_PAGES) {
275
+ // The only way this read is short: the loop gave up while the server was
276
+ // still handing back a cursor.
277
+ truncated = true;
278
+ break;
279
+ }
280
+ cursor = page.nextCaseCursor;
281
+ }
282
+ if (!snapshot)
283
+ throw new CaseBatchResponseError('Case batch read returned no pages.');
284
+ const cases = [...collected.values()].sort((left, right) => left.position - right.position);
285
+ return { snapshot, cases, pages, truncated, wavesTruncated };
286
+ }
287
+ function delay(milliseconds) {
288
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
289
+ }
290
+ export function caseBatchPollBounds(interval, maxWall) {
291
+ return {
292
+ intervalMs: flagInteger(interval, DEFAULT_POLL_INTERVAL_SECONDS, '--interval', 1, MAX_POLL_INTERVAL_SECONDS) * 1_000,
293
+ maxWallMs: flagInteger(maxWall, DEFAULT_POLL_MAX_WALL_MINUTES, '--max-wall', 1, MAX_POLL_MAX_WALL_MINUTES) * 60_000,
294
+ };
295
+ }
296
+ function unknownReasonCounts(cases) {
297
+ const counts = new Map();
298
+ for (const record of cases) {
299
+ const reason = record.outcome?.unknownReason;
300
+ if (!reason)
301
+ continue;
302
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
303
+ }
304
+ return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
305
+ }
306
+ function caseRow(record) {
307
+ const outcome = record.outcome;
308
+ const columns = [
309
+ String(record.position).padStart(5),
310
+ record.id.padEnd(32),
311
+ record.status.padEnd(10),
312
+ (outcome ? outcome.status : '—').padEnd(7),
313
+ (outcome?.unknownReason ?? '—').padEnd(22),
314
+ outcome ? `${outcome.wallMs} ms`.padStart(10) : ''.padStart(10),
315
+ outcome ? (outcome.cleanupProven ? 'cleanup:proven' : 'cleanup:UNPROVEN') : 'cleanup:pending',
316
+ ];
317
+ return ` ${columns.join(' ')}`;
318
+ }
319
+ function caseJson(record) {
320
+ const outcome = record.outcome;
321
+ return {
322
+ id: record.id,
323
+ position: record.position,
324
+ label: record.label,
325
+ condition_id: record.conditionId,
326
+ status: record.status,
327
+ launched_at_ms: record.launchedAtMs,
328
+ finished_at_ms: record.finishedAtMs,
329
+ outcome_status: outcome ? outcome.status : null,
330
+ unknown_reason: outcome ? outcome.unknownReason : null,
331
+ wall_ms: outcome ? outcome.wallMs : null,
332
+ cleanup_proven: outcome ? outcome.cleanupProven : null,
333
+ comparison_path: outcome ? outcome.comparisonPath : null,
334
+ case_bundle_path: outcome ? outcome.caseBundlePath : null,
335
+ differing_outcomes: outcome ? outcome.differingOutcomes : [],
336
+ detail: outcome ? outcome.detail : null,
337
+ phases: outcome ? outcome.phases : null,
338
+ vm_ids: record.vmIds,
339
+ lease_ids: record.leaseIds,
340
+ };
341
+ }
342
+ /** A batch that ends `incomplete` is a run with per-case results, not one
343
+ * failure: every row is printed either way and the summary says what is
344
+ * missing. Exit 2 marks the run as not passing; it never suppresses rows. */
345
+ export function caseBatchExitCode(snapshot, cases, timedOut) {
346
+ if (timedOut)
347
+ return 2;
348
+ if (!isTerminalCaseBatchStatus(snapshot.status))
349
+ return 0;
350
+ if (snapshot.status !== 'completed')
351
+ return 2;
352
+ if (snapshot.totals.cleanupUnproven > 0)
353
+ return 2;
354
+ if (cases.some(record => record.outcome?.cleanupProven === false))
355
+ return 2;
356
+ if (!snapshot.cleanupComplete)
357
+ return 2;
358
+ return 0;
359
+ }
360
+ function printCaseBatch(result, repository, timedOut) {
361
+ const { snapshot, cases } = result;
362
+ console.log(`${chalk.cyan('•')} Case batch ${chalk.bold(snapshot.runId)}: ${snapshot.status}`
363
+ + ` ${chalk.dim(`(generation ${snapshot.generation}, observed ${snapshot.observedAt})`)}`);
364
+ console.log(` ${chalk.dim('Repository:')} ${repository}`);
365
+ console.log(` ${chalk.dim('Worlds:')} base ${snapshot.worlds.base ?? 'pending'} · head ${snapshot.worlds.head ?? 'pending'}`);
366
+ if (snapshot.admission) {
367
+ const admission = snapshot.admission;
368
+ console.log(` ${chalk.dim('Admission:')} requested ${admission.requestedCases}, admitted ${admission.admittedCases}`
369
+ + ` · pool ${admission.pool} with ${admission.freeVmSlots} free VM slots (${admission.reservedVmSlots} reserved)`
370
+ + ` · ${admission.workerProcesses} workers × ${admission.casesPerProcess}`);
371
+ }
372
+ else {
373
+ console.log(` ${chalk.dim('Admission:')} not recorded yet`);
374
+ }
375
+ const totals = snapshot.totals;
376
+ console.log(` ${chalk.dim('Totals:')} ${totals.settled}/${totals.cases} settled (${totals.launched} launched)`
377
+ + ` · AGREE ${totals.agree} · DIFFER ${totals.differ} · UNKNOWN ${totals.unknown}`);
378
+ console.log(` ${chalk.dim('Cleanup:')} proven ${totals.cleanupProven}, unproven ${totals.cleanupUnproven},`
379
+ + ` leases released ${totals.leasesReleased}, batch cleanup ${snapshot.cleanupComplete ? 'complete' : 'incomplete'}`);
380
+ if (snapshot.error)
381
+ console.log(` ${chalk.dim('Error:')} ${snapshot.error}`);
382
+ console.log(` ${chalk.dim('Cases:')} ${cases.length} row(s) of ${totals.launched} launched`
383
+ + ` (${totals.cases} in the batch) over ${result.pages} page(s)`);
384
+ console.log(` ${chalk.dim(' pos id status outcome unknownReason wallMs cleanup')}`);
385
+ for (const record of cases)
386
+ console.log(caseRow(record));
387
+ if (result.truncated) {
388
+ console.log(chalk.yellow(` Pagination stopped after ${cases.length} of ${totals.launched} launched cases; re-run to read the rest.`));
389
+ }
390
+ if (result.wavesTruncated) {
391
+ console.log(chalk.dim(' Wave records are paginated; only the first page is shown.'));
392
+ }
393
+ const reasons = unknownReasonCounts(cases);
394
+ if (reasons.length) {
395
+ console.log(` ${chalk.dim('Unknown reasons:')} ${reasons.map(([reason, count]) => `${reason} ${count}`).join(', ')}`);
396
+ }
397
+ if (snapshot.status === 'incomplete') {
398
+ console.log(chalk.yellow(` Batch ended incomplete: ${totals.settled} of ${totals.cases} cases settled,`
399
+ + ` ${totals.unknown} UNKNOWN, ${totals.cleanupUnproven} with cleanup unproven.`
400
+ + ' Every case above carries its own outcome.'));
401
+ }
402
+ if (snapshot.status === 'cancelled') {
403
+ console.log(chalk.yellow(' Batch was cancelled; cases that had already settled keep their outcomes.'));
404
+ }
405
+ if (timedOut) {
406
+ console.log(chalk.yellow(' Polling reached its --max-wall bound; the batch is still running.'));
407
+ console.log(` ${chalk.dim('Resume:')} haystack case-batch status ${snapshot.runId} --repository ${repository} --watch`);
408
+ }
409
+ }
410
+ export async function caseBatchStatusCommand(runId, options) {
411
+ requireRunId(runId);
412
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
413
+ throw new CaseBatchRequestValidationError('--repository is required; every case-batch read is scoped to an exact repository.');
414
+ }
415
+ const bounds = caseBatchPollBounds(options.interval, options.maxWall);
416
+ const { repository, auth } = await authFor(options.repository, options.account);
417
+ const startedAt = Date.now();
418
+ let result = await readCaseBatch(runId, repository.fullName, auth.token);
419
+ let timedOut = false;
420
+ if (options.watch) {
421
+ while (!isTerminalCaseBatchStatus(result.snapshot.status)) {
422
+ const elapsed = Date.now() - startedAt;
423
+ if (elapsed >= bounds.maxWallMs) {
424
+ timedOut = true;
425
+ break;
426
+ }
427
+ if (!options.json) {
428
+ console.error(chalk.dim(`Case batch ${runId}: ${result.snapshot.status}`
429
+ + ` · ${result.snapshot.totals.settled}/${result.snapshot.totals.cases} settled…`));
430
+ }
431
+ await delay(Math.min(bounds.intervalMs, bounds.maxWallMs - elapsed));
432
+ result = await readCaseBatch(runId, repository.fullName, auth.token);
433
+ }
434
+ }
435
+ const exitCode = caseBatchExitCode(result.snapshot, result.cases, timedOut);
436
+ if (options.json) {
437
+ const snapshot = result.snapshot;
438
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
439
+ operation: 'status',
440
+ run_id: snapshot.runId,
441
+ repository: repository.fullName,
442
+ status: snapshot.status,
443
+ terminal: isTerminalCaseBatchStatus(snapshot.status),
444
+ timed_out: timedOut,
445
+ generation: snapshot.generation,
446
+ observed_at: snapshot.observedAt,
447
+ cleanup_complete: snapshot.cleanupComplete,
448
+ vm_ids: snapshot.vmIds,
449
+ worlds: { base: snapshot.worlds.base, head: snapshot.worlds.head },
450
+ clocks: {
451
+ preparation_started_at: snapshot.clocks.preparationStartedAt,
452
+ search_started_at: snapshot.clocks.searchStartedAt,
453
+ launches_closed_at: snapshot.clocks.launchesClosedAt,
454
+ finished_at: snapshot.clocks.finishedAt,
455
+ search_budget_ms: snapshot.clocks.searchBudgetMs,
456
+ search_wall_ms: snapshot.clocks.searchWallMs,
457
+ drain_wall_ms: snapshot.clocks.drainWallMs,
458
+ },
459
+ totals: {
460
+ cases: snapshot.totals.cases,
461
+ launched: snapshot.totals.launched,
462
+ settled: snapshot.totals.settled,
463
+ agree: snapshot.totals.agree,
464
+ differ: snapshot.totals.differ,
465
+ unknown: snapshot.totals.unknown,
466
+ cleanup_proven: snapshot.totals.cleanupProven,
467
+ cleanup_unproven: snapshot.totals.cleanupUnproven,
468
+ leases_released: snapshot.totals.leasesReleased,
469
+ },
470
+ ...(snapshot.admission ? {
471
+ admission: {
472
+ requested_cases: snapshot.admission.requestedCases,
473
+ admitted_cases: snapshot.admission.admittedCases,
474
+ pool: snapshot.admission.pool,
475
+ free_vm_slots: snapshot.admission.freeVmSlots,
476
+ reserved_vm_slots: snapshot.admission.reservedVmSlots,
477
+ worker_processes: snapshot.admission.workerProcesses,
478
+ cases_per_process: snapshot.admission.casesPerProcess,
479
+ },
480
+ } : {}),
481
+ ...(snapshot.error ? { error: snapshot.error } : {}),
482
+ unknown_reasons: Object.fromEntries(unknownReasonCounts(result.cases)),
483
+ cases_returned: result.cases.length,
484
+ cases_truncated: result.truncated,
485
+ pages: result.pages,
486
+ waves: snapshot.waves.map(wave => ({ wave: wave.wave, at_ms: wave.atMs, launched: wave.launched })),
487
+ waves_truncated: result.wavesTruncated,
488
+ cases: result.cases.map(caseJson),
489
+ exit_code: exitCode,
490
+ }), null, 2)}\n`);
491
+ }
492
+ else {
493
+ printCaseBatch(result, repository.fullName, timedOut);
494
+ }
495
+ if (exitCode !== 0)
496
+ process.exitCode = exitCode;
497
+ }
498
+ /* ------------------------------------------------------------------ cancel */
499
+ export async function caseBatchCancelCommand(runId, options) {
500
+ requireRunId(runId);
501
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
502
+ throw new CaseBatchRequestValidationError('--repository is required; cancellation is scoped to an exact repository.');
503
+ }
504
+ const { repository, auth } = await authFor(options.repository, options.account);
505
+ const { body } = await gatewayJson(`${GATEWAY}/${encodeURIComponent(runId)}?${repositoryQuery(repository.fullName)}`, auth.token, { method: 'DELETE' });
506
+ if (!isJsonObject(body) || body.runId !== runId || body.cancelRequested !== true) {
507
+ throw new CaseBatchResponseError('Case batch cancellation was not acknowledged for this run.');
508
+ }
509
+ if (options.json) {
510
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
511
+ operation: 'cancel',
512
+ run_id: runId,
513
+ repository: repository.fullName,
514
+ cancel_requested: true,
515
+ next_command: `haystack case-batch status ${runId} --repository ${repository.fullName} --watch`,
516
+ }), null, 2)}\n`);
517
+ return;
518
+ }
519
+ console.log(`${chalk.cyan('•')} Cancellation requested for case batch ${chalk.bold(runId)}.`);
520
+ console.log(' A claimed batch stays draining until its owner proves physical cleanup and posts a terminal checkpoint.');
521
+ console.log(` ${chalk.dim('Watch:')} haystack case-batch status ${runId} --repository ${repository.fullName} --watch`);
522
+ }
523
+ function safeBundlePath(path) {
524
+ if (typeof path !== 'string' || path.length === 0 || path.length > 512) {
525
+ throw new CaseBatchResponseError('Bundle manifest names an invalid artifact path.');
526
+ }
527
+ if (isAbsolute(path) || path.includes('\\') || path.includes('//')
528
+ // eslint-disable-next-line no-control-regex -- rejecting/stripping control characters is the point
529
+ || /[\u0000-\u001f\u007f]/.test(path)
530
+ || path.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
531
+ throw new CaseBatchResponseError(`Bundle manifest names an unsafe artifact path: ${JSON.stringify(path)}`);
532
+ }
533
+ return path;
534
+ }
535
+ /** Collect every artifact reference in a manifest document. A reference is an
536
+ * object carrying `path`, a 64-hex `sha256` and an integer `bytes` — the shape
537
+ * the publication side already writes for manifest pages
538
+ * (`CombinationSearchPublicManifestPageRef`) and the only shape the bundle
539
+ * route will serve. Nothing else in the document is followed. */
540
+ export function collectArtifactRefs(document, into) {
541
+ const visit = (value, depth) => {
542
+ if (depth > 64)
543
+ return;
544
+ if (Array.isArray(value)) {
545
+ for (const item of value)
546
+ visit(item, depth + 1);
547
+ return;
548
+ }
549
+ if (!isJsonObject(value))
550
+ return;
551
+ if (typeof value.path === 'string' && typeof value.sha256 === 'string'
552
+ && /^[0-9a-f]{64}$/.test(value.sha256)
553
+ && typeof value.bytes === 'number' && Number.isSafeInteger(value.bytes) && value.bytes >= 0) {
554
+ const path = safeBundlePath(value.path);
555
+ if (value.bytes > MAX_ARTIFACT_BYTES) {
556
+ throw new CaseBatchResponseError(`Bundle artifact ${path} declares ${value.bytes} bytes, above the ${MAX_ARTIFACT_BYTES}-byte per-file bound.`);
557
+ }
558
+ const existing = into.get(path);
559
+ if (existing && (existing.sha256 !== value.sha256 || existing.bytes !== value.bytes)) {
560
+ throw new CaseBatchResponseError(`Bundle manifest lists ${path} twice with different digests.`);
561
+ }
562
+ into.set(path, { path, sha256: value.sha256, bytes: value.bytes });
563
+ }
564
+ for (const item of Object.values(value))
565
+ visit(item, depth + 1);
566
+ };
567
+ visit(document, 0);
568
+ }
569
+ const SEALED_ETAG = /^(?:W\/)?"([0-9a-f]{64})"$/;
570
+ /** Reads the sealed SHA-256 out of a bundle response's ETag. The service sets
571
+ * a strong entity tag `"<sha256>"`; an edge that re-encodes the body (Cloudflare
572
+ * serving brotli to a client that accepts it) rewrites it to the weak form
573
+ * `W/"<sha256>"`. Either way the quoted value is the digest of the decoded
574
+ * bytes, which is what the caller hashes. Absent header returns null; any
575
+ * other shape is refused and named. */
576
+ export function sealedDigestFromEtag(relative, header) {
577
+ if (header === null)
578
+ return null;
579
+ const match = SEALED_ETAG.exec(header.trim());
580
+ if (!match) {
581
+ throw new CaseBatchResponseError(`Bundle ${relative} was served with ETag ${JSON.stringify(header)}, which is not a sealed SHA-256 entity tag (expected "<64 hex>" or W/"<64 hex>").`);
582
+ }
583
+ return match[1];
584
+ }
585
+ /** `requireSealedDigest` is set for the manifest, the one artifact with no
586
+ * parent digest to check it against: without the service's own sealed digest
587
+ * there is nothing to verify it with, so the fetch fails rather than
588
+ * proceeding while the output claims every digest was verified. */
589
+ async function fetchBundleArtifact(runId, repository, token, relative, requireSealedDigest = false) {
590
+ const encoded = relative.split('/').map(encodeURIComponent).join('/');
591
+ const suffix = relative === 'manifest.json' ? '' : `/${encoded}`;
592
+ const response = await gatewayFetch(`${GATEWAY}/${encodeURIComponent(runId)}/bundle${suffix}?${repositoryQuery(repository)}`, token, { method: 'GET', accept: '*/*' });
593
+ if (!response.ok) {
594
+ throw await classifyHttpError(response, `Haystack API ${GATEWAY}/${runId}/bundle${suffix}`);
595
+ }
596
+ const etag = sealedDigestFromEtag(relative, response.headers.get('etag'));
597
+ if (requireSealedDigest && etag === null) {
598
+ throw new CaseBatchResponseError(`Bundle ${relative} was served without an ETag, so the service's sealed SHA-256 for it is unavailable and it cannot be verified.`);
599
+ }
600
+ const bytes = Buffer.from(await response.arrayBuffer());
601
+ if (bytes.byteLength > MAX_ARTIFACT_BYTES) {
602
+ throw new CaseBatchResponseError(`Bundle artifact ${relative} exceeded the ${MAX_ARTIFACT_BYTES}-byte per-file bound.`);
603
+ }
604
+ return { bytes, sha256: createHash('sha256').update(bytes).digest('hex'), etag };
605
+ }
606
+ function writeArtifact(outDir, relative, bytes) {
607
+ const destination = resolve(outDir, relative);
608
+ const root = resolve(outDir);
609
+ if (destination !== root && !destination.startsWith(root + sep)) {
610
+ throw new CaseBatchResponseError(`Bundle artifact ${relative} resolves outside --out.`);
611
+ }
612
+ mkdirSync(dirname(destination), { recursive: true });
613
+ writeFileSync(destination, bytes);
614
+ return destination;
615
+ }
616
+ export async function caseBatchBundleCommand(runId, options) {
617
+ requireRunId(runId);
618
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
619
+ throw new CaseBatchRequestValidationError('--repository is required; every bundle read is scoped to an exact repository.');
620
+ }
621
+ if (typeof options.out !== 'string' || options.out.length === 0) {
622
+ throw new CaseBatchRequestValidationError('--out is required and must name a directory to write the bundle into.');
623
+ }
624
+ const outDir = resolve(options.out);
625
+ const { repository, auth } = await authFor(options.repository, options.account);
626
+ // Nothing is written before the manifest is verified, so a bundle that fails
627
+ // its first integrity check leaves no directory and no bytes behind.
628
+ const manifest = await fetchBundleArtifact(runId, repository.fullName, auth.token, 'manifest.json', true);
629
+ // The bundle route seals each object with its own digest and serves it as the
630
+ // ETag; a mismatch means the served bytes are not the sealed bytes.
631
+ if (manifest.etag !== manifest.sha256) {
632
+ throw new CaseBatchResponseError(`Bundle manifest bytes hash to ${manifest.sha256}; the service sealed them under ${manifest.etag}.`);
633
+ }
634
+ let manifestDocument;
635
+ try {
636
+ manifestDocument = JSON.parse(manifest.bytes.toString('utf8'));
637
+ }
638
+ catch {
639
+ throw new CaseBatchResponseError('Bundle manifest is not JSON.');
640
+ }
641
+ writeArtifact(outDir, 'manifest.json', manifest.bytes);
642
+ const pending = new Map();
643
+ collectArtifactRefs(manifestDocument, pending);
644
+ const verified = [];
645
+ const done = new Set(['manifest.json']);
646
+ let totalBytes = manifest.bytes.byteLength;
647
+ for (;;) {
648
+ const next = [...pending.values()].find(ref => !done.has(ref.path));
649
+ if (!next)
650
+ break;
651
+ done.add(next.path);
652
+ if (verified.length >= MAX_BUNDLE_ARTIFACTS) {
653
+ throw new CaseBatchResponseError(`Bundle lists more than ${MAX_BUNDLE_ARTIFACTS} artifacts.`);
654
+ }
655
+ if (!options.json)
656
+ console.error(chalk.dim(`Fetching ${next.path}…`));
657
+ const artifact = await fetchBundleArtifact(runId, repository.fullName, auth.token, next.path);
658
+ if (artifact.bytes.byteLength !== next.bytes) {
659
+ throw new CaseBatchResponseError(`Bundle artifact ${next.path} is ${artifact.bytes.byteLength} bytes; the manifest declares ${next.bytes}.`);
660
+ }
661
+ if (artifact.sha256 !== next.sha256) {
662
+ throw new CaseBatchResponseError(`Bundle artifact ${next.path} hashes to ${artifact.sha256}; the manifest declares ${next.sha256}.`);
663
+ }
664
+ if (artifact.etag !== null && artifact.etag !== artifact.sha256) {
665
+ throw new CaseBatchResponseError(`Bundle artifact ${next.path} hashes to ${artifact.sha256}; the service sealed it under ${artifact.etag}.`);
666
+ }
667
+ totalBytes += artifact.bytes.byteLength;
668
+ if (totalBytes > MAX_BUNDLE_BYTES) {
669
+ throw new CaseBatchResponseError(`Bundle exceeded the ${MAX_BUNDLE_BYTES}-byte download bound.`);
670
+ }
671
+ writeArtifact(outDir, next.path, artifact.bytes);
672
+ // A manifest page lists its own PNGs; follow one level at a time until the
673
+ // closure is complete. Only verified bytes are ever parsed.
674
+ const json = next.path.endsWith('.json');
675
+ if (json) {
676
+ try {
677
+ collectArtifactRefs(JSON.parse(artifact.bytes.toString('utf8')), pending);
678
+ }
679
+ catch (error) {
680
+ if (error instanceof CaseBatchResponseError)
681
+ throw error;
682
+ throw new CaseBatchResponseError(`Bundle page ${next.path} is not JSON.`);
683
+ }
684
+ }
685
+ verified.push({ ...next, json });
686
+ }
687
+ if (options.json) {
688
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
689
+ operation: 'bundle',
690
+ run_id: runId,
691
+ repository: repository.fullName,
692
+ out_dir: outDir,
693
+ manifest: { path: 'manifest.json', sha256: manifest.sha256, bytes: manifest.bytes.byteLength },
694
+ artifacts: verified.map(ref => ({ path: ref.path, sha256: ref.sha256, bytes: ref.bytes, verified: true })),
695
+ artifact_count: verified.length,
696
+ total_bytes: totalBytes,
697
+ }), null, 2)}\n`);
698
+ return;
699
+ }
700
+ console.log(`${chalk.cyan('•')} Bundle for case batch ${chalk.bold(runId)} written to ${outDir}`);
701
+ console.log(` ${chalk.dim('manifest.json')} ${manifest.sha256} (${manifest.bytes.byteLength} bytes)`);
702
+ for (const ref of verified) {
703
+ console.log(` ${chalk.dim(ref.path)} ${ref.sha256} (${ref.bytes} bytes)`);
704
+ }
705
+ console.log(` ${verified.length + 1} file(s), ${totalBytes} bytes, every digest verified against the manifest.`);
706
+ }
707
+ export const caseBatchDefaults = {
708
+ perCaseWallMs: DEFAULT_PER_CASE_WALL_MS,
709
+ totalBudgetMs: DEFAULT_TOTAL_BUDGET_MS,
710
+ pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
711
+ pollMaxWallMinutes: DEFAULT_POLL_MAX_WALL_MINUTES,
712
+ casePageLimit: CASE_PAGE_LIMIT,
713
+ };