@haystackeditor/cli 0.15.31 → 0.17.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 (136) hide show
  1. package/README.md +141 -61
  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 +694 -0
  8. package/dist/commands/case-batch.js +1011 -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 +39 -71
  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 +64 -11
  26. package/dist/commands/verify.js +51 -139
  27. package/dist/index.js +290 -366
  28. package/dist/lazy.js +8 -0
  29. package/dist/schema.js +2 -1
  30. package/dist/tools/detect.js +3 -24
  31. package/dist/triage/astra.js +199 -0
  32. package/dist/triage/prompts.js +145 -179
  33. package/dist/triage/runner.js +104 -300
  34. package/dist/triage/types.js +2 -2
  35. package/dist/types.js +2 -6
  36. package/dist/utils/auth.js +14 -2
  37. package/dist/utils/git.js +60 -29
  38. package/dist/utils/github-api.js +14 -1
  39. package/dist/utils/haystack-api.js +38 -7
  40. package/dist/utils/hooks.js +43 -6
  41. package/dist/utils/prompter.js +25 -10
  42. package/dist/utils/safe-write.js +31 -0
  43. package/dist/utils/secret-paths.js +115 -0
  44. package/dist/utils/secrets.js +0 -1
  45. package/dist/utils/telemetry.js +34 -14
  46. package/dist/utils/update-check.js +151 -0
  47. package/package.json +19 -14
  48. package/schemas/case-batch.v1.json +235 -0
  49. package/schemas/cloud-verifier.v1.json +55 -100
  50. package/schemas/submit.v1.json +4 -2
  51. package/dist/commands/ask.d.ts +0 -14
  52. package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
  53. package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
  54. package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
  55. package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
  56. package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
  57. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
  58. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
  59. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
  60. package/dist/commands/cloud-verifier-universe.d.ts +0 -31
  61. package/dist/commands/config.d.ts +0 -46
  62. package/dist/commands/design-verify.d.ts +0 -33
  63. package/dist/commands/dismiss.d.ts +0 -29
  64. package/dist/commands/hooks.d.ts +0 -13
  65. package/dist/commands/inbox.d.ts +0 -65
  66. package/dist/commands/init.d.ts +0 -10
  67. package/dist/commands/install-session-hooks.d.ts +0 -17
  68. package/dist/commands/login.d.ts +0 -8
  69. package/dist/commands/mcp.d.ts +0 -1
  70. package/dist/commands/policy.d.ts +0 -31
  71. package/dist/commands/pr-status.d.ts +0 -144
  72. package/dist/commands/pr.d.ts +0 -40
  73. package/dist/commands/precompute-delivery-contract.d.ts +0 -68
  74. package/dist/commands/precompute-delivery.d.ts +0 -20
  75. package/dist/commands/prepare-universe-review.d.ts +0 -115
  76. package/dist/commands/production-source-deny-policy.d.ts +0 -15
  77. package/dist/commands/request-review.d.ts +0 -26
  78. package/dist/commands/review.d.ts +0 -25
  79. package/dist/commands/rules.d.ts +0 -4
  80. package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
  81. package/dist/commands/schema-cmd.d.ts +0 -2
  82. package/dist/commands/setup.d.ts +0 -28
  83. package/dist/commands/skills.d.ts +0 -8
  84. package/dist/commands/status.d.ts +0 -4
  85. package/dist/commands/submit.d.ts +0 -30
  86. package/dist/commands/system-map.d.ts +0 -42
  87. package/dist/commands/telemetry.d.ts +0 -53
  88. package/dist/commands/tokens.d.ts +0 -14
  89. package/dist/commands/triage.d.ts +0 -35
  90. package/dist/commands/verify-core.d.ts +0 -449
  91. package/dist/commands/verify-core.js +0 -789
  92. package/dist/commands/verify-explore.d.ts +0 -14
  93. package/dist/commands/verify-history.d.ts +0 -14
  94. package/dist/commands/verify-hosted-mcp.d.ts +0 -12
  95. package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
  96. package/dist/commands/verify-hosted.d.ts +0 -92
  97. package/dist/commands/verify-mcp.d.ts +0 -8
  98. package/dist/commands/verify-mcp.js +0 -517
  99. package/dist/commands/verify-ops.d.ts +0 -158
  100. package/dist/commands/verify-ops.js +0 -1148
  101. package/dist/commands/verify-precompute.d.ts +0 -30
  102. package/dist/commands/verify-reproducibility.d.ts +0 -85
  103. package/dist/commands/verify-reproducibility.js +0 -494
  104. package/dist/commands/verify-reseal.d.ts +0 -9
  105. package/dist/commands/verify-reseal.js +0 -148
  106. package/dist/commands/verify-sandboxes.d.ts +0 -95
  107. package/dist/commands/verify-sandboxes.js +0 -352
  108. package/dist/commands/verify.d.ts +0 -28
  109. package/dist/commands/webhooks.d.ts +0 -30
  110. package/dist/index.d.ts +0 -22
  111. package/dist/schema.d.ts +0 -28
  112. package/dist/states.d.ts +0 -29
  113. package/dist/tools/detect.d.ts +0 -50
  114. package/dist/triage/prompts.d.ts +0 -24
  115. package/dist/triage/runner.d.ts +0 -34
  116. package/dist/triage/types.d.ts +0 -42
  117. package/dist/types/verify-history.d.ts +0 -38
  118. package/dist/types.d.ts +0 -1684
  119. package/dist/utils/action-output.d.ts +0 -24
  120. package/dist/utils/analysis-api.d.ts +0 -187
  121. package/dist/utils/auth.d.ts +0 -79
  122. package/dist/utils/config.d.ts +0 -24
  123. package/dist/utils/design-verifier-api.d.ts +0 -208
  124. package/dist/utils/design-verifier-history.d.ts +0 -46
  125. package/dist/utils/design-verifier-result.d.ts +0 -74
  126. package/dist/utils/detect.d.ts +0 -43
  127. package/dist/utils/git.d.ts +0 -135
  128. package/dist/utils/github-api.d.ts +0 -104
  129. package/dist/utils/haystack-api.d.ts +0 -37
  130. package/dist/utils/hooks.d.ts +0 -12
  131. package/dist/utils/pending-state.d.ts +0 -40
  132. package/dist/utils/pr-ref.d.ts +0 -27
  133. package/dist/utils/prompter.d.ts +0 -85
  134. package/dist/utils/secrets.d.ts +0 -47
  135. package/dist/utils/telemetry.d.ts +0 -19
  136. /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
@@ -0,0 +1,1011 @@
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, HaystackApiError, 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
+ if (options.driverBundle !== undefined && !/^[0-9a-f]{64}$/u.test(options.driverBundle))
159
+ throw new CaseBatchRequestValidationError('--driver-bundle must be the resident probe bundle sha256 (64 lowercase hex).');
160
+ const driver = options.driverBundle === undefined ? undefined : { mode: 'resident', bundleSha256: options.driverBundle };
161
+ const cases = parseProductCases(readJsonFile(options.cases, '--cases'));
162
+ const combinations = boundedCaseBatchJson(readJsonFile(options.combinations, '--combinations'), 'combination space');
163
+ const limits = parseCaseBatchLimits({
164
+ // A ceiling, not a reservation: effective concurrency is the smaller of
165
+ // this and what GET /v1/capacity leaves free. Defaulting to the batch's own
166
+ // size asks for exactly as much as the batch can use, capped by the
167
+ // contract ceiling, instead of inventing a number.
168
+ maxConcurrentCases: flagInteger(options.maxConcurrent, Math.min(cases.length, CASE_BATCH_MAX_CONCURRENT_CASES), '--max-concurrent', 1, CASE_BATCH_MAX_CONCURRENT_CASES),
169
+ 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),
170
+ totalBudgetMs: flagInteger(options.totalBudgetMs, DEFAULT_TOTAL_BUDGET_MS, '--total-budget-ms', CASE_BATCH_MIN_TOTAL_BUDGET_MS, CASE_BATCH_MAX_TOTAL_BUDGET_MS),
171
+ // Absent unless given: an absent settle is the contract's 0 and leaves the request as before.
172
+ ...(options.warmSettleMs === undefined ? {} : {
173
+ warmSettleMs: flagInteger(options.warmSettleMs, 0, '--warm-settle-ms', 0, CASE_BATCH_MAX_CASE_WALL_MS),
174
+ }),
175
+ });
176
+ const source = options.sourcePatch === undefined
177
+ ? undefined
178
+ : parseCaseBatchSource(readJsonFile(options.sourcePatch, '--source-patch'), baseCommit, headCommit);
179
+ const identity = {
180
+ repository: repository.fullName,
181
+ baseCommit,
182
+ headCommit,
183
+ baseWorld: baseWorld ?? null,
184
+ headWorld: headWorld ?? null,
185
+ driver: driver ?? null,
186
+ combinations,
187
+ cases,
188
+ limits,
189
+ source: source ? source.patchSha256 : null,
190
+ };
191
+ const idempotencyKey = parseCaseBatchIdempotencyKey(options.idempotencyKey ?? defaultCaseBatchIdempotencyKey(identity));
192
+ const request = buildCaseBatchRequest({
193
+ repository: repository.fullName,
194
+ baseCommit,
195
+ headCommit,
196
+ idempotencyKey,
197
+ baseWorld,
198
+ headWorld,
199
+ driver,
200
+ combinations: combinations,
201
+ cases,
202
+ limits,
203
+ source,
204
+ });
205
+ return { repository, request, caseCount: cases.length, limits };
206
+ }
207
+ /** A refused submission: the HTTP answer, and the gateway's stable reason when it gave one
208
+ * (`world-pair-locked`, `idempotency-conflict`, `runner-unavailable`, ...). Callers switch on
209
+ * `reason`, never on the message. */
210
+ export class CaseBatchAdmissionRefusedError extends HaystackApiError {
211
+ reason;
212
+ constructor(refusal, reason) {
213
+ super(refusal.status, refusal.message, refusal.code);
214
+ this.name = 'CaseBatchAdmissionRefusedError';
215
+ this.reason = reason;
216
+ }
217
+ }
218
+ /** `case-batch submit` as a library call: the same validation, the same login bearer and the same
219
+ * request. A caller that submits many batches (the product-fuzz search) runs this in its own
220
+ * process, so every request after the first reuses one warm connection instead of paying for a
221
+ * new process and a new TLS session per call. */
222
+ export async function submitCaseBatch(options) {
223
+ // Validate every local input before touching credentials or the network.
224
+ const prepared = prepareCaseBatchSubmission(options);
225
+ const { auth } = await authFor(prepared.repository.fullName, options.account);
226
+ const response = await gatewayFetch(GATEWAY, auth.token, { method: 'POST', body: JSON.stringify(prepared.request) });
227
+ if (!response.ok) {
228
+ const copy = response.clone();
229
+ const refusal = await classifyHttpError(response, `Haystack API ${GATEWAY}`);
230
+ let reason = null;
231
+ try {
232
+ const body = await copy.json();
233
+ if (isJsonObject(body) && typeof body.reason === 'string')
234
+ reason = body.reason;
235
+ }
236
+ catch {
237
+ // Not JSON: the refusal above already carries the status and the readable part of the body.
238
+ }
239
+ throw new CaseBatchAdmissionRefusedError(refusal, reason);
240
+ }
241
+ let body;
242
+ try {
243
+ body = await response.json();
244
+ }
245
+ catch (error) {
246
+ throw new CaseBatchResponseError(`Case batch service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
247
+ }
248
+ if (!isJsonObject(body) || typeof body.runId !== 'string' || !RUN_ID.test(body.runId)) {
249
+ throw new CaseBatchResponseError('Case batch admission did not return a run id.');
250
+ }
251
+ // The search route answers 202 for a newly admitted run and 200 for an
252
+ // identical replay of an admitted idempotency key.
253
+ return { runId: body.runId, admitted: response.status === 200 ? 'existing' : 'created', prepared };
254
+ }
255
+ /** One bounded status read as a library call, with the bearer `case-batch status` would use. */
256
+ export async function readCaseBatchStatus(runId, repository, account) {
257
+ requireRunId(runId);
258
+ const { repository: parsed, auth } = await authFor(repository, account);
259
+ return readCaseBatch(runId, parsed.fullName, auth.token);
260
+ }
261
+ export async function caseBatchSubmitCommand(options) {
262
+ if (!options.json) {
263
+ console.error(chalk.dim('Submitting cases to the hosted coordinator…'));
264
+ }
265
+ const { runId, admitted, prepared } = await submitCaseBatch(options);
266
+ const nextCommand = `haystack case-batch status ${runId} --repository ${prepared.repository.fullName} --watch`;
267
+ if (options.json) {
268
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
269
+ operation: 'submit',
270
+ run_id: runId,
271
+ admitted,
272
+ repository: prepared.repository.fullName,
273
+ base_commit: prepared.request.baseCommit,
274
+ head_commit: prepared.request.headCommit,
275
+ idempotency_key: prepared.request.idempotencyKey,
276
+ cases: prepared.caseCount,
277
+ limits: {
278
+ max_concurrent_cases: prepared.limits.maxConcurrentCases,
279
+ per_case_wall_ms: prepared.limits.perCaseWallMs,
280
+ total_budget_ms: prepared.limits.totalBudgetMs,
281
+ ...(prepared.limits.warmSettleMs === undefined ? {} : { warm_settle_ms: prepared.limits.warmSettleMs }),
282
+ },
283
+ ...(prepared.request.baseWorld ? { base_world: prepared.request.baseWorld } : {}),
284
+ ...(prepared.request.headWorld ? { head_world: prepared.request.headWorld } : {}),
285
+ source_patch_attached: prepared.request.source !== undefined,
286
+ next_command: nextCommand,
287
+ }), null, 2)}\n`);
288
+ return;
289
+ }
290
+ console.log(`${chalk.cyan('•')} Case batch ${chalk.bold(runId)} ${admitted === 'existing' ? 'already admitted' : 'queued'}`);
291
+ console.log(` ${chalk.dim('Repository:')} ${prepared.repository.fullName}`);
292
+ console.log(` ${chalk.dim('Commits:')} ${prepared.request.baseCommit.slice(0, 12)} → ${prepared.request.headCommit.slice(0, 12)}`);
293
+ console.log(` ${chalk.dim('Cases:')} ${prepared.caseCount}`);
294
+ console.log(` ${chalk.dim('Limits:')} max ${prepared.limits.maxConcurrentCases} concurrent (ceiling; capacity decides), `
295
+ + `${prepared.limits.perCaseWallMs} ms per case, ${prepared.limits.totalBudgetMs} ms launch budget`);
296
+ console.log(` ${chalk.dim('Idempotency key:')} ${prepared.request.idempotencyKey}`);
297
+ console.log(` ${chalk.dim('Poll:')} ${nextCommand}`);
298
+ }
299
+ /** One bounded read: follow `nextCaseCursor` to the end of the case list. */
300
+ export async function readCaseBatch(runId, repository, token) {
301
+ const collected = new Map();
302
+ let cursor = null;
303
+ let snapshot = null;
304
+ let pages = 0;
305
+ let wavesTruncated = false;
306
+ let truncated = false;
307
+ for (;;) {
308
+ const query = `?${repositoryQuery(repository)}&limit=${CASE_PAGE_LIMIT}`
309
+ + (cursor === null ? '' : `&caseCursor=${encodeURIComponent(cursor)}`);
310
+ const { body } = await gatewayJson(`${GATEWAY}/${encodeURIComponent(runId)}${query}`, token);
311
+ const page = parseCaseBatchSnapshot(body, runId);
312
+ pages += 1;
313
+ // The freshest page owns the summary; rows are keyed by their stable case
314
+ // id so a checkpoint landing between pages cannot duplicate a case.
315
+ snapshot = page;
316
+ for (const record of page.cases)
317
+ collected.set(record.id, record);
318
+ if (pages === 1)
319
+ wavesTruncated = page.nextWaveCursor !== null;
320
+ if (page.nextCaseCursor === null)
321
+ break;
322
+ if (page.cases.length === 0) {
323
+ throw new CaseBatchResponseError('Case batch pagination returned an empty page with a cursor.');
324
+ }
325
+ if (pages >= MAX_CASE_PAGES) {
326
+ // The only way this read is short: the loop gave up while the server was
327
+ // still handing back a cursor.
328
+ truncated = true;
329
+ break;
330
+ }
331
+ cursor = page.nextCaseCursor;
332
+ }
333
+ if (!snapshot)
334
+ throw new CaseBatchResponseError('Case batch read returned no pages.');
335
+ const cases = [...collected.values()].sort((left, right) => left.position - right.position);
336
+ return { snapshot, cases, pages, truncated, wavesTruncated };
337
+ }
338
+ function delay(milliseconds) {
339
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
340
+ }
341
+ export function caseBatchPollBounds(interval, maxWall) {
342
+ return {
343
+ intervalMs: flagInteger(interval, DEFAULT_POLL_INTERVAL_SECONDS, '--interval', 1, MAX_POLL_INTERVAL_SECONDS) * 1_000,
344
+ maxWallMs: flagInteger(maxWall, DEFAULT_POLL_MAX_WALL_MINUTES, '--max-wall', 1, MAX_POLL_MAX_WALL_MINUTES) * 60_000,
345
+ };
346
+ }
347
+ function unknownReasonCounts(cases) {
348
+ const counts = new Map();
349
+ for (const record of cases) {
350
+ const reason = record.outcome?.unknownReason;
351
+ if (!reason)
352
+ continue;
353
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
354
+ }
355
+ return [...counts.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
356
+ }
357
+ function caseRow(record) {
358
+ const outcome = record.outcome;
359
+ const columns = [
360
+ String(record.position).padStart(5),
361
+ record.id.padEnd(32),
362
+ record.status.padEnd(10),
363
+ (outcome ? outcome.status : '—').padEnd(7),
364
+ (outcome?.unknownReason ?? '—').padEnd(22),
365
+ outcome ? `${outcome.wallMs} ms`.padStart(10) : ''.padStart(10),
366
+ outcome ? (outcome.cleanupProven ? 'cleanup:proven' : 'cleanup:UNPROVEN') : 'cleanup:pending',
367
+ ];
368
+ return ` ${columns.join(' ')}`;
369
+ }
370
+ function caseJson(record) {
371
+ const outcome = record.outcome;
372
+ return {
373
+ id: record.id,
374
+ position: record.position,
375
+ label: record.label,
376
+ condition_id: record.conditionId,
377
+ status: record.status,
378
+ launched_at_ms: record.launchedAtMs,
379
+ finished_at_ms: record.finishedAtMs,
380
+ outcome_status: outcome ? outcome.status : null,
381
+ unknown_reason: outcome ? outcome.unknownReason : null,
382
+ wall_ms: outcome ? outcome.wallMs : null,
383
+ cleanup_proven: outcome ? outcome.cleanupProven : null,
384
+ comparison_path: outcome ? outcome.comparisonPath : null,
385
+ case_bundle_path: outcome ? outcome.caseBundlePath : null,
386
+ differing_outcomes: outcome ? outcome.differingOutcomes : [],
387
+ detail: outcome ? outcome.detail : null,
388
+ execution_path: outcome?.executionPath ?? null,
389
+ // v4: null when the outcome carries none (before v4, or a settled comparison's `failure`).
390
+ node_ids: outcome?.nodeIds ? { base1: outcome.nodeIds.base1, head: outcome.nodeIds.head } : null,
391
+ failure: outcome?.failure ? { source: outcome.failure.source, arm: outcome.failure.arm,
392
+ error_class: outcome.failure.errorClass, error_code: outcome.failure.errorCode,
393
+ node_id: outcome.failure.nodeId } : null,
394
+ phases: outcome ? outcome.phases : null,
395
+ vm_ids: record.vmIds,
396
+ lease_ids: record.leaseIds,
397
+ };
398
+ }
399
+ /** A batch that ends `incomplete` is a run with per-case results, not one
400
+ * failure: every row is printed either way and the summary says what is
401
+ * missing. Exit 2 marks the run as not passing; it never suppresses rows. */
402
+ export function caseBatchExitCode(snapshot, cases, timedOut) {
403
+ if (timedOut)
404
+ return 2;
405
+ if (!isTerminalCaseBatchStatus(snapshot.status))
406
+ return 0;
407
+ if (snapshot.status !== 'completed')
408
+ return 2;
409
+ if (snapshot.totals.cleanupUnproven > 0)
410
+ return 2;
411
+ if (cases.some(record => record.outcome?.cleanupProven === false))
412
+ return 2;
413
+ if (!snapshot.cleanupComplete)
414
+ return 2;
415
+ return 0;
416
+ }
417
+ function printCaseBatch(result, repository, timedOut) {
418
+ const { snapshot, cases } = result;
419
+ console.log(`${chalk.cyan('•')} Case batch ${chalk.bold(snapshot.runId)}: ${snapshot.status}`
420
+ + ` ${chalk.dim(`(generation ${snapshot.generation}, observed ${snapshot.observedAt})`)}`);
421
+ console.log(` ${chalk.dim('Repository:')} ${repository}`);
422
+ console.log(` ${chalk.dim('Worlds:')} base ${snapshot.worlds.base ?? 'pending'} · head ${snapshot.worlds.head ?? 'pending'}`);
423
+ if (snapshot.admission) {
424
+ const admission = snapshot.admission;
425
+ console.log(` ${chalk.dim('Admission:')} requested ${admission.requestedCases}, admitted ${admission.admittedCases}`
426
+ + ` · pool ${admission.pool} with ${admission.freeVmSlots} free VM slots (${admission.reservedVmSlots} reserved)`
427
+ + ` · ${admission.workerProcesses} workers × ${admission.casesPerProcess}`);
428
+ }
429
+ else {
430
+ console.log(` ${chalk.dim('Admission:')} not recorded yet`);
431
+ }
432
+ const totals = snapshot.totals;
433
+ console.log(` ${chalk.dim('Totals:')} ${totals.settled}/${totals.cases} settled (${totals.launched} launched)`
434
+ + ` · AGREE ${totals.agree} · DIFFER ${totals.differ} · UNKNOWN ${totals.unknown}`);
435
+ console.log(` ${chalk.dim('Cleanup:')} proven ${totals.cleanupProven}, unproven ${totals.cleanupUnproven},`
436
+ + ` leases released ${totals.leasesReleased}, batch cleanup ${snapshot.cleanupComplete ? 'complete' : 'incomplete'}`);
437
+ if (snapshot.error)
438
+ console.log(` ${chalk.dim('Error:')} ${snapshot.error}`);
439
+ console.log(` ${chalk.dim('Cases:')} ${cases.length} row(s) of ${totals.launched} launched`
440
+ + ` (${totals.cases} in the batch) over ${result.pages} page(s)`);
441
+ console.log(` ${chalk.dim(' pos id status outcome unknownReason wallMs cleanup')}`);
442
+ for (const record of cases)
443
+ console.log(caseRow(record));
444
+ if (result.truncated) {
445
+ console.log(chalk.yellow(` Pagination stopped after ${cases.length} of ${totals.launched} launched cases; re-run to read the rest.`));
446
+ }
447
+ if (result.wavesTruncated) {
448
+ console.log(chalk.dim(' Wave records are paginated; only the first page is shown.'));
449
+ }
450
+ const reasons = unknownReasonCounts(cases);
451
+ if (reasons.length) {
452
+ console.log(` ${chalk.dim('Unknown reasons:')} ${reasons.map(([reason, count]) => `${reason} ${count}`).join(', ')}`);
453
+ }
454
+ if (snapshot.status === 'incomplete') {
455
+ console.log(chalk.yellow(` Batch ended incomplete: ${totals.settled} of ${totals.cases} cases settled,`
456
+ + ` ${totals.unknown} UNKNOWN, ${totals.cleanupUnproven} with cleanup unproven.`
457
+ + ' Every case above carries its own outcome.'));
458
+ }
459
+ if (snapshot.status === 'cancelled') {
460
+ console.log(chalk.yellow(' Batch was cancelled; cases that had already settled keep their outcomes.'));
461
+ }
462
+ if (timedOut) {
463
+ console.log(chalk.yellow(' Polling reached its --max-wall bound; the batch is still running.'));
464
+ console.log(` ${chalk.dim('Resume:')} haystack case-batch status ${snapshot.runId} --repository ${repository} --watch`);
465
+ }
466
+ }
467
+ export async function caseBatchStatusCommand(runId, options) {
468
+ requireRunId(runId);
469
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
470
+ throw new CaseBatchRequestValidationError('--repository is required; every case-batch read is scoped to an exact repository.');
471
+ }
472
+ const bounds = caseBatchPollBounds(options.interval, options.maxWall);
473
+ const { repository, auth } = await authFor(options.repository, options.account);
474
+ const startedAt = Date.now();
475
+ let result = await readCaseBatch(runId, repository.fullName, auth.token);
476
+ let timedOut = false;
477
+ if (options.watch) {
478
+ while (!isTerminalCaseBatchStatus(result.snapshot.status)) {
479
+ const elapsed = Date.now() - startedAt;
480
+ if (elapsed >= bounds.maxWallMs) {
481
+ timedOut = true;
482
+ break;
483
+ }
484
+ if (!options.json) {
485
+ console.error(chalk.dim(`Case batch ${runId}: ${result.snapshot.status}`
486
+ + ` · ${result.snapshot.totals.settled}/${result.snapshot.totals.cases} settled…`));
487
+ }
488
+ await delay(Math.min(bounds.intervalMs, bounds.maxWallMs - elapsed));
489
+ result = await readCaseBatch(runId, repository.fullName, auth.token);
490
+ }
491
+ }
492
+ const exitCode = caseBatchExitCode(result.snapshot, result.cases, timedOut);
493
+ if (options.json) {
494
+ const snapshot = result.snapshot;
495
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
496
+ operation: 'status',
497
+ run_id: snapshot.runId,
498
+ repository: repository.fullName,
499
+ status: snapshot.status,
500
+ terminal: isTerminalCaseBatchStatus(snapshot.status),
501
+ timed_out: timedOut,
502
+ generation: snapshot.generation,
503
+ observed_at: snapshot.observedAt,
504
+ cleanup_complete: snapshot.cleanupComplete,
505
+ vm_ids: snapshot.vmIds,
506
+ worlds: { base: snapshot.worlds.base, head: snapshot.worlds.head },
507
+ clocks: {
508
+ preparation_started_at: snapshot.clocks.preparationStartedAt,
509
+ search_started_at: snapshot.clocks.searchStartedAt,
510
+ launches_closed_at: snapshot.clocks.launchesClosedAt,
511
+ finished_at: snapshot.clocks.finishedAt,
512
+ search_budget_ms: snapshot.clocks.searchBudgetMs,
513
+ search_wall_ms: snapshot.clocks.searchWallMs,
514
+ drain_wall_ms: snapshot.clocks.drainWallMs,
515
+ },
516
+ totals: {
517
+ cases: snapshot.totals.cases,
518
+ launched: snapshot.totals.launched,
519
+ settled: snapshot.totals.settled,
520
+ agree: snapshot.totals.agree,
521
+ differ: snapshot.totals.differ,
522
+ unknown: snapshot.totals.unknown,
523
+ cleanup_proven: snapshot.totals.cleanupProven,
524
+ cleanup_unproven: snapshot.totals.cleanupUnproven,
525
+ leases_released: snapshot.totals.leasesReleased,
526
+ },
527
+ ...(snapshot.admission ? {
528
+ admission: {
529
+ requested_cases: snapshot.admission.requestedCases,
530
+ admitted_cases: snapshot.admission.admittedCases,
531
+ pool: snapshot.admission.pool,
532
+ free_vm_slots: snapshot.admission.freeVmSlots,
533
+ reserved_vm_slots: snapshot.admission.reservedVmSlots,
534
+ worker_processes: snapshot.admission.workerProcesses,
535
+ cases_per_process: snapshot.admission.casesPerProcess,
536
+ },
537
+ } : {}),
538
+ ...(snapshot.error ? { error: snapshot.error } : {}),
539
+ unknown_reasons: Object.fromEntries(unknownReasonCounts(result.cases)),
540
+ cases_returned: result.cases.length,
541
+ cases_truncated: result.truncated,
542
+ pages: result.pages,
543
+ waves: snapshot.waves.map(wave => ({ wave: wave.wave, at_ms: wave.atMs, launched: wave.launched })),
544
+ waves_truncated: result.wavesTruncated,
545
+ cases: result.cases.map(caseJson),
546
+ exit_code: exitCode,
547
+ }), null, 2)}\n`);
548
+ }
549
+ else {
550
+ printCaseBatch(result, repository.fullName, timedOut);
551
+ }
552
+ if (exitCode !== 0)
553
+ process.exitCode = exitCode;
554
+ }
555
+ /* ------------------------------------------------------------------ cancel */
556
+ export async function caseBatchCancelCommand(runId, options) {
557
+ requireRunId(runId);
558
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
559
+ throw new CaseBatchRequestValidationError('--repository is required; cancellation is scoped to an exact repository.');
560
+ }
561
+ const { repository, auth } = await authFor(options.repository, options.account);
562
+ const { body } = await gatewayJson(`${GATEWAY}/${encodeURIComponent(runId)}?${repositoryQuery(repository.fullName)}`, auth.token, { method: 'DELETE' });
563
+ if (!isJsonObject(body) || body.runId !== runId || body.cancelRequested !== true) {
564
+ throw new CaseBatchResponseError('Case batch cancellation was not acknowledged for this run.');
565
+ }
566
+ if (options.json) {
567
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
568
+ operation: 'cancel',
569
+ run_id: runId,
570
+ repository: repository.fullName,
571
+ cancel_requested: true,
572
+ next_command: `haystack case-batch status ${runId} --repository ${repository.fullName} --watch`,
573
+ }), null, 2)}\n`);
574
+ return;
575
+ }
576
+ console.log(`${chalk.cyan('•')} Cancellation requested for case batch ${chalk.bold(runId)}.`);
577
+ console.log(' A claimed batch stays draining until its owner proves physical cleanup and posts a terminal checkpoint.');
578
+ console.log(` ${chalk.dim('Watch:')} haystack case-batch status ${runId} --repository ${repository.fullName} --watch`);
579
+ }
580
+ /* ---------------------------------------------------------------- finalize */
581
+ /** Mirrors CASE_BATCH_FINALIZE_STATUSES and CASE_BATCH_FINALIZE_REASON_MAX in
582
+ * infra/lambda/haystack-design-verifier-shared/case_batch_contracts.ts. */
583
+ const FINALIZE_STATUSES = ['cancelled', 'incomplete'];
584
+ const FINALIZE_REASON_MAX = 1_000;
585
+ /** A refused finalize: the HTTP answer and the gateway's typed reason (`finalize-not-stranded`,
586
+ * `finalize-live-claim`, `finalize-already-terminal`, `finalize-conflict`). */
587
+ export class CaseBatchFinalizeRefusedError extends HaystackApiError {
588
+ reason;
589
+ constructor(refusal, reason) {
590
+ super(refusal.status, reason ? `Case batch finalize refused (${reason}): ${refusal.message}` : refusal.message, refusal.code);
591
+ this.name = 'CaseBatchFinalizeRefusedError';
592
+ this.reason = reason;
593
+ }
594
+ }
595
+ /** Operator finalize of a stranded batch: terminal with the given status, the reason recorded as its
596
+ * error, and its world-pair lock freed. The gateway accepts it only when the batch was cancelled or has
597
+ * gone 15 minutes without an update, and no claim is live. Cleanup is reported exactly as the batch
598
+ * last recorded it; finalize never asserts it. */
599
+ export async function caseBatchFinalizeCommand(runId, options) {
600
+ requireRunId(runId);
601
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
602
+ throw new CaseBatchRequestValidationError('--repository is required; finalize is scoped to an exact repository.');
603
+ }
604
+ const status = options.status;
605
+ if (typeof status !== 'string' || !FINALIZE_STATUSES.includes(status)) {
606
+ throw new CaseBatchRequestValidationError(`--status must be one of: ${FINALIZE_STATUSES.join(', ')}.`);
607
+ }
608
+ const reason = options.reason;
609
+ if (typeof reason !== 'string' || reason.trim().length === 0 || reason.length > FINALIZE_REASON_MAX || reason.includes('\0')) {
610
+ throw new CaseBatchRequestValidationError(`--reason is required: 1 to ${FINALIZE_REASON_MAX} characters saying why the batch is stranded.`);
611
+ }
612
+ const { repository, auth } = await authFor(options.repository, options.account);
613
+ const path = `${GATEWAY}/${encodeURIComponent(runId)}/finalize?${repositoryQuery(repository.fullName)}`;
614
+ const response = await gatewayFetch(path, auth.token, { method: 'POST', body: JSON.stringify({ status, reason }) });
615
+ if (!response.ok) {
616
+ const copy = response.clone();
617
+ const refusal = await classifyHttpError(response, `Haystack API ${path}`);
618
+ let refusalReason = null;
619
+ try {
620
+ const body = await copy.json();
621
+ if (isJsonObject(body) && typeof body.reason === 'string')
622
+ refusalReason = body.reason;
623
+ }
624
+ catch {
625
+ // Not JSON: the refusal above already carries the status and the readable part of the body.
626
+ }
627
+ throw new CaseBatchFinalizeRefusedError(refusal, refusalReason);
628
+ }
629
+ let body;
630
+ try {
631
+ body = await response.json();
632
+ }
633
+ catch (error) {
634
+ throw new CaseBatchResponseError(`Case batch service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
635
+ }
636
+ if (!isJsonObject(body) || body.runId !== runId || body.terminal !== true || body.status !== status
637
+ || typeof body.cleanupComplete !== 'boolean' || typeof body.generation !== 'number'
638
+ || typeof body.previousStatus !== 'string' || typeof body.lastUpdatedAt !== 'string'
639
+ || (body.eligibility !== 'cancel-requested' && body.eligibility !== 'stale') || typeof body.finalizedAt !== 'string') {
640
+ throw new CaseBatchResponseError('Case batch finalize was not acknowledged for this run.');
641
+ }
642
+ if (options.json) {
643
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
644
+ operation: 'finalize',
645
+ run_id: runId,
646
+ repository: repository.fullName,
647
+ status,
648
+ terminal: true,
649
+ cleanup_complete: body.cleanupComplete,
650
+ generation: body.generation,
651
+ previous_status: body.previousStatus,
652
+ last_updated_at: body.lastUpdatedAt,
653
+ eligibility: body.eligibility,
654
+ reason,
655
+ finalized_at: body.finalizedAt,
656
+ }), null, 2)}\n`);
657
+ return;
658
+ }
659
+ console.log(`${chalk.cyan('•')} Case batch ${chalk.bold(runId)} finalized as ${status} (was ${body.previousStatus}; ${body.eligibility}).`);
660
+ console.log(body.cleanupComplete
661
+ ? ' Its last checkpoint recorded cleanup complete.'
662
+ : chalk.yellow(' Its last checkpoint did NOT record cleanup complete; its VM and lease ownership rows remain for dispatcher recovery.'));
663
+ }
664
+ /** Parallel artifact reads. Bounded so a large bundle does not open hundreds of sockets. */
665
+ const BUNDLE_FETCH_CONCURRENCY = 8;
666
+ /** The bundle route's `reason` (HTTP 409) for a PNG a page lists that the coordinator has not
667
+ * uploaded yet. PNGs upload after the batch is terminal, off its world-pair lock. Mirrors
668
+ * CASE_BATCH_ARTIFACT_PENDING_REASON in infra/lambda/haystack-design-verifier-shared/case_batch_contracts.ts. */
669
+ const ARTIFACT_PENDING_REASON = 'artifact-pending';
670
+ /** Exit status when every other artifact verified and only PNGs are still uploading: EX_TEMPFAIL
671
+ * from sysexits.h, "try again later". Rerun the command, or pass the pending paths to `--only`. */
672
+ export const CASE_BATCH_BUNDLE_PENDING_EXIT = 75;
673
+ /** The bundle route's `reason` (HTTP 410) for a listed PNG whose upload the coordinator gave up on and
674
+ * recorded. Terminal: nothing will upload it. Mirrors CASE_BATCH_ARTIFACT_FAILED_REASON in
675
+ * infra/lambda/haystack-design-verifier-shared/case_batch_contracts.ts. */
676
+ const ARTIFACT_FAILED_REASON = 'artifact-failed';
677
+ /** Exit status when nothing is pending but at least one listed PNG failed to upload: the answer is final and
678
+ * the bundle is incomplete, so a caller must not retry and must not treat it as complete. */
679
+ export const CASE_BATCH_BUNDLE_FAILED_EXIT = 1;
680
+ /** A listed PNG the service reports as failed for good (its recorded upload error). Terminal, never retried. */
681
+ export class CaseBatchArtifactFailedError extends Error {
682
+ path;
683
+ uploadError;
684
+ constructor(path, uploadError) {
685
+ super(`Bundle artifact ${path} failed to upload (${uploadError.errorClass}${uploadError.status === null ? '' : ` HTTP ${uploadError.status}`}: ${uploadError.message}).`);
686
+ this.path = path;
687
+ this.uploadError = uploadError;
688
+ this.name = 'CaseBatchArtifactFailedError';
689
+ }
690
+ }
691
+ /** The recorded upload error when an error body is the failed answer, else null (the caller reports the
692
+ * response like any other refusal). */
693
+ function artifactFailedBody(text) {
694
+ let body;
695
+ try {
696
+ body = JSON.parse(text);
697
+ }
698
+ catch {
699
+ return null;
700
+ }
701
+ if (!isJsonObject(body) || body.reason !== ARTIFACT_FAILED_REASON || !isJsonObject(body.uploadError))
702
+ return null;
703
+ const { errorClass, status, message } = body.uploadError;
704
+ if (typeof errorClass !== 'string' || typeof message !== 'string' || (status !== null && typeof status !== 'number')) {
705
+ throw new CaseBatchResponseError('Bundle route answered artifact-failed without a valid uploadError.');
706
+ }
707
+ return { errorClass, status, message };
708
+ }
709
+ /** A listed PNG the service reports as still uploading. Retryable, never a missing artifact. */
710
+ export class CaseBatchArtifactPendingError extends Error {
711
+ path;
712
+ constructor(path) {
713
+ super(`Bundle artifact ${path} is still uploading; retry it.`);
714
+ this.path = path;
715
+ this.name = 'CaseBatchArtifactPendingError';
716
+ }
717
+ }
718
+ /** True when an error body carries the pending `reason`. A body that is not JSON is not that answer,
719
+ * and the caller reports the response like any other refusal. */
720
+ function isArtifactPendingBody(text) {
721
+ let body;
722
+ try {
723
+ body = JSON.parse(text);
724
+ }
725
+ catch {
726
+ return false;
727
+ }
728
+ return isJsonObject(body) && body.reason === ARTIFACT_PENDING_REASON;
729
+ }
730
+ function safeBundlePath(path) {
731
+ if (typeof path !== 'string' || path.length === 0 || path.length > 512) {
732
+ throw new CaseBatchResponseError('Bundle manifest names an invalid artifact path.');
733
+ }
734
+ if (isAbsolute(path) || path.includes('\\') || path.includes('//')
735
+ // eslint-disable-next-line no-control-regex -- rejecting/stripping control characters is the point
736
+ || /[\u0000-\u001f\u007f]/.test(path)
737
+ || path.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
738
+ throw new CaseBatchResponseError(`Bundle manifest names an unsafe artifact path: ${JSON.stringify(path)}`);
739
+ }
740
+ return path;
741
+ }
742
+ /** Collect every artifact reference in a manifest document. A reference is an
743
+ * object carrying `path`, a 64-hex `sha256` and an integer `bytes` — the shape
744
+ * the publication side already writes for manifest pages
745
+ * (`CombinationSearchPublicManifestPageRef`) and the only shape the bundle
746
+ * route will serve. Nothing else in the document is followed. */
747
+ export function collectArtifactRefs(document, into) {
748
+ const visit = (value, depth) => {
749
+ if (depth > 64)
750
+ return;
751
+ if (Array.isArray(value)) {
752
+ for (const item of value)
753
+ visit(item, depth + 1);
754
+ return;
755
+ }
756
+ if (!isJsonObject(value))
757
+ return;
758
+ if (typeof value.path === 'string' && typeof value.sha256 === 'string'
759
+ && /^[0-9a-f]{64}$/.test(value.sha256)
760
+ && typeof value.bytes === 'number' && Number.isSafeInteger(value.bytes) && value.bytes >= 0) {
761
+ const path = safeBundlePath(value.path);
762
+ if (value.bytes > MAX_ARTIFACT_BYTES) {
763
+ throw new CaseBatchResponseError(`Bundle artifact ${path} declares ${value.bytes} bytes, above the ${MAX_ARTIFACT_BYTES}-byte per-file bound.`);
764
+ }
765
+ const existing = into.get(path);
766
+ if (existing && (existing.sha256 !== value.sha256 || existing.bytes !== value.bytes)) {
767
+ throw new CaseBatchResponseError(`Bundle manifest lists ${path} twice with different digests.`);
768
+ }
769
+ into.set(path, { path, sha256: value.sha256, bytes: value.bytes });
770
+ }
771
+ else if (typeof value.path === 'string' && typeof value.pngSha256 === 'string'
772
+ && /^[0-9a-f]{64}$/.test(value.pngSha256) && Object.keys(value).length === 2) {
773
+ // A page's image reference: the PNG's own sha256; its size is checked against the bound only.
774
+ const path = safeBundlePath(value.path);
775
+ const existing = into.get(path);
776
+ if (existing && existing.sha256 !== value.pngSha256) {
777
+ throw new CaseBatchResponseError(`Bundle manifest lists ${path} twice with different digests.`);
778
+ }
779
+ into.set(path, { path, sha256: value.pngSha256, bytes: existing?.bytes ?? null });
780
+ }
781
+ for (const item of Object.values(value))
782
+ visit(item, depth + 1);
783
+ };
784
+ visit(document, 0);
785
+ }
786
+ const SEALED_ETAG = /^(?:W\/)?"([0-9a-f]{64})"$/;
787
+ /** Reads the sealed SHA-256 out of a bundle response's ETag. The service sets
788
+ * a strong entity tag `"<sha256>"`; an edge that re-encodes the body (Cloudflare
789
+ * serving brotli to a client that accepts it) rewrites it to the weak form
790
+ * `W/"<sha256>"`. Either way the quoted value is the digest of the decoded
791
+ * bytes, which is what the caller hashes. Absent header returns null; any
792
+ * other shape is refused and named. */
793
+ export function sealedDigestFromEtag(relative, header) {
794
+ if (header === null)
795
+ return null;
796
+ const match = SEALED_ETAG.exec(header.trim());
797
+ if (!match) {
798
+ 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>").`);
799
+ }
800
+ return match[1];
801
+ }
802
+ /** `requireSealedDigest` is set for the manifest, the one artifact with no
803
+ * parent digest to check it against: without the service's own sealed digest
804
+ * there is nothing to verify it with, so the fetch fails rather than
805
+ * proceeding while the output claims every digest was verified. */
806
+ async function fetchBundleArtifact(runId, repository, token, relative, requireSealedDigest = false) {
807
+ const encoded = relative.split('/').map(encodeURIComponent).join('/');
808
+ const suffix = relative === 'manifest.json' ? '' : `/${encoded}`;
809
+ const response = await gatewayFetch(`${GATEWAY}/${encodeURIComponent(runId)}/bundle${suffix}?${repositoryQuery(repository)}`, token, { method: 'GET', accept: '*/*' });
810
+ if (response.status === 409) {
811
+ const text = await response.text();
812
+ if (isArtifactPendingBody(text))
813
+ throw new CaseBatchArtifactPendingError(relative);
814
+ throw await classifyHttpError(new Response(text, { status: response.status, headers: response.headers }), `Haystack API ${GATEWAY}/${runId}/bundle${suffix}`);
815
+ }
816
+ if (response.status === 410) {
817
+ const text = await response.text();
818
+ const failure = artifactFailedBody(text);
819
+ if (failure !== null)
820
+ throw new CaseBatchArtifactFailedError(relative, failure);
821
+ throw await classifyHttpError(new Response(text, { status: response.status, headers: response.headers }), `Haystack API ${GATEWAY}/${runId}/bundle${suffix}`);
822
+ }
823
+ if (!response.ok) {
824
+ throw await classifyHttpError(response, `Haystack API ${GATEWAY}/${runId}/bundle${suffix}`);
825
+ }
826
+ const etag = sealedDigestFromEtag(relative, response.headers.get('etag'));
827
+ if (requireSealedDigest && etag === null) {
828
+ 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.`);
829
+ }
830
+ const bytes = Buffer.from(await response.arrayBuffer());
831
+ if (bytes.byteLength > MAX_ARTIFACT_BYTES) {
832
+ throw new CaseBatchResponseError(`Bundle artifact ${relative} exceeded the ${MAX_ARTIFACT_BYTES}-byte per-file bound.`);
833
+ }
834
+ return { bytes, sha256: createHash('sha256').update(bytes).digest('hex'), etag };
835
+ }
836
+ function writeArtifact(outDir, relative, bytes) {
837
+ const destination = resolve(outDir, relative);
838
+ const root = resolve(outDir);
839
+ if (destination !== root && !destination.startsWith(root + sep)) {
840
+ throw new CaseBatchResponseError(`Bundle artifact ${relative} resolves outside --out.`);
841
+ }
842
+ mkdirSync(dirname(destination), { recursive: true });
843
+ writeFileSync(destination, bytes);
844
+ return destination;
845
+ }
846
+ /** `case-batch bundle` as a library call: the same reads and digest checks, returned instead of printed,
847
+ * and no process exit status. A caller in its own process (the product-fuzz page) waits on `pending` and
848
+ * renders `failed` without the PNG. */
849
+ export async function downloadCaseBatchBundle(runId, options) {
850
+ requireRunId(runId);
851
+ if (typeof options.repository !== 'string' || options.repository.length === 0) {
852
+ throw new CaseBatchRequestValidationError('--repository is required; every bundle read is scoped to an exact repository.');
853
+ }
854
+ if (typeof options.out !== 'string' || options.out.length === 0) {
855
+ throw new CaseBatchRequestValidationError('--out is required and must name a directory to write the bundle into.');
856
+ }
857
+ const outDir = resolve(options.out);
858
+ const { repository, auth } = await authFor(options.repository, options.account);
859
+ // Nothing is written before the manifest is verified, so a bundle that fails
860
+ // its first integrity check leaves no directory and no bytes behind.
861
+ const manifest = await fetchBundleArtifact(runId, repository.fullName, auth.token, 'manifest.json', true);
862
+ // The bundle route seals each object with its own digest and serves it as the
863
+ // ETag; a mismatch means the served bytes are not the sealed bytes.
864
+ if (manifest.etag !== manifest.sha256) {
865
+ throw new CaseBatchResponseError(`Bundle manifest bytes hash to ${manifest.sha256}; the service sealed them under ${manifest.etag}.`);
866
+ }
867
+ let manifestDocument;
868
+ try {
869
+ manifestDocument = JSON.parse(manifest.bytes.toString('utf8'));
870
+ }
871
+ catch {
872
+ throw new CaseBatchResponseError('Bundle manifest is not JSON.');
873
+ }
874
+ writeArtifact(outDir, 'manifest.json', manifest.bytes);
875
+ const pending = new Map();
876
+ collectArtifactRefs(manifestDocument, pending);
877
+ const only = (options.only ?? []).map(entry => {
878
+ if (typeof entry !== 'string' || entry.length === 0)
879
+ throw new CaseBatchRequestValidationError('--only names an empty path.');
880
+ return entry;
881
+ });
882
+ // The manifest and its pages are always read: they are what every other artifact is verified against.
883
+ const wanted = (path) => path.startsWith('manifest-pages/') || only.length === 0
884
+ || only.some(entry => entry === path || (entry.endsWith('/') && path.startsWith(entry)));
885
+ const verified = [];
886
+ /** Listed PNGs the service reported as still uploading; nothing was written for them. */
887
+ const uploading = [];
888
+ /** Listed PNGs the service reported as failed for good. */
889
+ const failed = [];
890
+ const done = new Set(['manifest.json']);
891
+ let totalBytes = manifest.bytes.byteLength;
892
+ const fetchOne = async (next) => {
893
+ if (!options.json)
894
+ console.error(chalk.dim(`Fetching ${next.path}…`));
895
+ let artifact;
896
+ try {
897
+ artifact = await fetchBundleArtifact(runId, repository.fullName, auth.token, next.path);
898
+ }
899
+ catch (error) {
900
+ if (error instanceof CaseBatchArtifactFailedError) {
901
+ failed.push({ path: next.path, uploadError: error.uploadError });
902
+ return;
903
+ }
904
+ if (!(error instanceof CaseBatchArtifactPendingError))
905
+ throw error;
906
+ uploading.push(next.path);
907
+ return;
908
+ }
909
+ if (next.bytes !== null && artifact.bytes.byteLength !== next.bytes) {
910
+ throw new CaseBatchResponseError(`Bundle artifact ${next.path} is ${artifact.bytes.byteLength} bytes; the manifest declares ${next.bytes}.`);
911
+ }
912
+ if (artifact.sha256 !== next.sha256) {
913
+ throw new CaseBatchResponseError(`Bundle artifact ${next.path} hashes to ${artifact.sha256}; the manifest declares ${next.sha256}.`);
914
+ }
915
+ if (artifact.etag !== null && artifact.etag !== artifact.sha256) {
916
+ throw new CaseBatchResponseError(`Bundle artifact ${next.path} hashes to ${artifact.sha256}; the service sealed it under ${artifact.etag}.`);
917
+ }
918
+ totalBytes += artifact.bytes.byteLength;
919
+ if (totalBytes > MAX_BUNDLE_BYTES) {
920
+ throw new CaseBatchResponseError(`Bundle exceeded the ${MAX_BUNDLE_BYTES}-byte download bound.`);
921
+ }
922
+ writeArtifact(outDir, next.path, artifact.bytes);
923
+ // A manifest page lists its cases' PNGs and records; follow pages only. Only verified bytes are
924
+ // ever parsed, and a case's records are data, never a list of further artifacts.
925
+ const json = next.path.endsWith('.json');
926
+ if (next.path.startsWith('manifest-pages/')) {
927
+ try {
928
+ collectArtifactRefs(JSON.parse(artifact.bytes.toString('utf8')), pending);
929
+ }
930
+ catch (error) {
931
+ if (error instanceof CaseBatchResponseError)
932
+ throw error;
933
+ throw new CaseBatchResponseError(`Bundle page ${next.path} is not JSON.`);
934
+ }
935
+ }
936
+ verified.push({ path: next.path, sha256: next.sha256, bytes: artifact.bytes.byteLength, json });
937
+ };
938
+ for (;;) {
939
+ const wave = [...pending.values()].filter(ref => !done.has(ref.path) && wanted(ref.path));
940
+ if (wave.length === 0)
941
+ break;
942
+ for (const ref of wave)
943
+ done.add(ref.path);
944
+ if (verified.length + wave.length > MAX_BUNDLE_ARTIFACTS) {
945
+ throw new CaseBatchResponseError(`Bundle lists more than ${MAX_BUNDLE_ARTIFACTS} artifacts.`);
946
+ }
947
+ let cursor = 0;
948
+ await Promise.all(Array.from({ length: Math.min(BUNDLE_FETCH_CONCURRENCY, wave.length) }, async () => {
949
+ for (let index = cursor++; index < wave.length; index = cursor++)
950
+ await fetchOne(wave[index]);
951
+ }));
952
+ }
953
+ const missing = only.filter(entry => !entry.endsWith('/') && !done.has(entry));
954
+ if (missing.length > 0) {
955
+ throw new CaseBatchResponseError(`Bundle does not list ${missing.join(', ')}.`);
956
+ }
957
+ uploading.sort();
958
+ failed.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
959
+ return { repository: repository.fullName, outDir, manifest: { sha256: manifest.sha256, bytes: manifest.bytes.byteLength },
960
+ verified, totalBytes, pending: uploading, failed };
961
+ }
962
+ export async function caseBatchBundleCommand(runId, options) {
963
+ const { repository, outDir, manifest, verified, totalBytes, pending: uploading, failed } = await downloadCaseBatchBundle(runId, options);
964
+ // Pending first: those can still arrive, so the caller retries; failed ones are final either way.
965
+ const exitCode = uploading.length > 0 ? CASE_BATCH_BUNDLE_PENDING_EXIT : failed.length > 0 ? CASE_BATCH_BUNDLE_FAILED_EXIT : 0;
966
+ if (exitCode !== 0)
967
+ process.exitCode = exitCode;
968
+ if (options.json) {
969
+ process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
970
+ operation: 'bundle',
971
+ run_id: runId,
972
+ repository,
973
+ out_dir: outDir,
974
+ manifest: { path: 'manifest.json', sha256: manifest.sha256, bytes: manifest.bytes },
975
+ artifacts: verified.map(ref => ({ path: ref.path, sha256: ref.sha256, bytes: ref.bytes, verified: true })),
976
+ artifact_count: verified.length,
977
+ total_bytes: totalBytes,
978
+ pending: uploading,
979
+ failed: failed.map(entry => ({ path: entry.path, error_class: entry.uploadError.errorClass,
980
+ status: entry.uploadError.status, message: entry.uploadError.message })),
981
+ }), null, 2)}\n`);
982
+ return;
983
+ }
984
+ console.log(`${chalk.cyan('•')} Bundle for case batch ${chalk.bold(runId)} written to ${outDir}`);
985
+ console.log(` ${chalk.dim('manifest.json')} ${manifest.sha256} (${manifest.bytes} bytes)`);
986
+ for (const ref of verified) {
987
+ console.log(` ${chalk.dim(ref.path)} ${ref.sha256} (${ref.bytes} bytes)`);
988
+ }
989
+ console.log(` ${verified.length + 1} file(s), ${totalBytes} bytes, every digest verified against the manifest.`);
990
+ if (uploading.length > 0) {
991
+ console.log(` ${chalk.yellow(`${uploading.length} PNG(s) still uploading`)} (the batch is terminal; its PNGs publish after it):`);
992
+ for (const path of uploading)
993
+ console.log(` ${chalk.dim(path)}`);
994
+ console.log(` Retry: haystack case-batch bundle ${runId} --repository ${repository} --out ${outDir}`
995
+ + `${uploading.map(path => ` --only ${path}`).join('')}`);
996
+ }
997
+ if (failed.length > 0) {
998
+ console.log(` ${chalk.red(`${failed.length} PNG(s) failed to upload`)} (final; the coordinator recorded why):`);
999
+ for (const entry of failed) {
1000
+ console.log(` ${chalk.dim(entry.path)} ${entry.uploadError.errorClass}`
1001
+ + `${entry.uploadError.status === null ? '' : ` HTTP ${entry.uploadError.status}`}: ${entry.uploadError.message}`);
1002
+ }
1003
+ }
1004
+ }
1005
+ export const caseBatchDefaults = {
1006
+ perCaseWallMs: DEFAULT_PER_CASE_WALL_MS,
1007
+ totalBudgetMs: DEFAULT_TOTAL_BUDGET_MS,
1008
+ pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
1009
+ pollMaxWallMinutes: DEFAULT_POLL_MAX_WALL_MINUTES,
1010
+ casePageLimit: CASE_PAGE_LIMIT,
1011
+ };