@haystackeditor/cli 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ import { dirname, isAbsolute, resolve, sep } from 'node:path';
16
16
  import chalk from 'chalk';
17
17
  import { withSchema } from '../schema.js';
18
18
  import { resolveAuthContext } from '../utils/auth.js';
19
- import { classifyHttpError, haystackApiUrl } from '../utils/haystack-api.js';
19
+ import { classifyHttpError, HaystackApiError, haystackApiUrl } from '../utils/haystack-api.js';
20
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
21
  /** The CLI gateway mount. The same worker handlers are `/v1/case-batches` and
22
22
  * `/api/cloud-verifier/case-batches`; CLI callers use the agent gateway, as
@@ -155,6 +155,9 @@ export function prepareCaseBatchSubmission(options) {
155
155
  const headCommit = parseCaseBatchCommit(options.head, '--head');
156
156
  const baseWorld = options.baseWorld === undefined ? undefined : parseCaseBatchWorld(options.baseWorld, '--base-world');
157
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 };
158
161
  const cases = parseProductCases(readJsonFile(options.cases, '--cases'));
159
162
  const combinations = boundedCaseBatchJson(readJsonFile(options.combinations, '--combinations'), 'combination space');
160
163
  const limits = parseCaseBatchLimits({
@@ -165,6 +168,10 @@ export function prepareCaseBatchSubmission(options) {
165
168
  maxConcurrentCases: flagInteger(options.maxConcurrent, Math.min(cases.length, CASE_BATCH_MAX_CONCURRENT_CASES), '--max-concurrent', 1, CASE_BATCH_MAX_CONCURRENT_CASES),
166
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),
167
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
+ }),
168
175
  });
169
176
  const source = options.sourcePatch === undefined
170
177
  ? undefined
@@ -175,6 +182,7 @@ export function prepareCaseBatchSubmission(options) {
175
182
  headCommit,
176
183
  baseWorld: baseWorld ?? null,
177
184
  headWorld: headWorld ?? null,
185
+ driver: driver ?? null,
178
186
  combinations,
179
187
  cases,
180
188
  limits,
@@ -188,6 +196,7 @@ export function prepareCaseBatchSubmission(options) {
188
196
  idempotencyKey,
189
197
  baseWorld,
190
198
  headWorld,
199
+ driver,
191
200
  combinations: combinations,
192
201
  cases,
193
202
  limits,
@@ -195,24 +204,65 @@ export function prepareCaseBatchSubmission(options) {
195
204
  });
196
205
  return { repository, request, caseCount: cases.length, limits };
197
206
  }
198
- export async function caseBatchSubmitCommand(options) {
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) {
199
223
  // Validate every local input before touching credentials or the network.
200
224
  const prepared = prepareCaseBatchSubmission(options);
201
225
  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…`));
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)}`);
204
247
  }
205
- const { status, body } = await gatewayJson(GATEWAY, auth.token, {
206
- method: 'POST',
207
- body: JSON.stringify(prepared.request),
208
- });
209
248
  if (!isJsonObject(body) || typeof body.runId !== 'string' || !RUN_ID.test(body.runId)) {
210
249
  throw new CaseBatchResponseError('Case batch admission did not return a run id.');
211
250
  }
212
- const runId = body.runId;
213
251
  // The search route answers 202 for a newly admitted run and 200 for an
214
252
  // identical replay of an admitted idempotency key.
215
- const admitted = status === 200 ? 'existing' : 'created';
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);
216
266
  const nextCommand = `haystack case-batch status ${runId} --repository ${prepared.repository.fullName} --watch`;
217
267
  if (options.json) {
218
268
  process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
@@ -228,6 +278,7 @@ export async function caseBatchSubmitCommand(options) {
228
278
  max_concurrent_cases: prepared.limits.maxConcurrentCases,
229
279
  per_case_wall_ms: prepared.limits.perCaseWallMs,
230
280
  total_budget_ms: prepared.limits.totalBudgetMs,
281
+ ...(prepared.limits.warmSettleMs === undefined ? {} : { warm_settle_ms: prepared.limits.warmSettleMs }),
231
282
  },
232
283
  ...(prepared.request.baseWorld ? { base_world: prepared.request.baseWorld } : {}),
233
284
  ...(prepared.request.headWorld ? { head_world: prepared.request.headWorld } : {}),
@@ -334,6 +385,12 @@ function caseJson(record) {
334
385
  case_bundle_path: outcome ? outcome.caseBundlePath : null,
335
386
  differing_outcomes: outcome ? outcome.differingOutcomes : [],
336
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,
337
394
  phases: outcome ? outcome.phases : null,
338
395
  vm_ids: record.vmIds,
339
396
  lease_ids: record.leaseIds,
@@ -520,6 +577,156 @@ export async function caseBatchCancelCommand(runId, options) {
520
577
  console.log(' A claimed batch stays draining until its owner proves physical cleanup and posts a terminal checkpoint.');
521
578
  console.log(` ${chalk.dim('Watch:')} haystack case-batch status ${runId} --repository ${repository.fullName} --watch`);
522
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
+ }
523
730
  function safeBundlePath(path) {
524
731
  if (typeof path !== 'string' || path.length === 0 || path.length > 512) {
525
732
  throw new CaseBatchResponseError('Bundle manifest names an invalid artifact path.');
@@ -561,6 +768,16 @@ export function collectArtifactRefs(document, into) {
561
768
  }
562
769
  into.set(path, { path, sha256: value.sha256, bytes: value.bytes });
563
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
+ }
564
781
  for (const item of Object.values(value))
565
782
  visit(item, depth + 1);
566
783
  };
@@ -590,6 +807,19 @@ async function fetchBundleArtifact(runId, repository, token, relative, requireSe
590
807
  const encoded = relative.split('/').map(encodeURIComponent).join('/');
591
808
  const suffix = relative === 'manifest.json' ? '' : `/${encoded}`;
592
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
+ }
593
823
  if (!response.ok) {
594
824
  throw await classifyHttpError(response, `Haystack API ${GATEWAY}/${runId}/bundle${suffix}`);
595
825
  }
@@ -613,7 +843,10 @@ function writeArtifact(outDir, relative, bytes) {
613
843
  writeFileSync(destination, bytes);
614
844
  return destination;
615
845
  }
616
- export async function caseBatchBundleCommand(runId, options) {
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) {
617
850
  requireRunId(runId);
618
851
  if (typeof options.repository !== 'string' || options.repository.length === 0) {
619
852
  throw new CaseBatchRequestValidationError('--repository is required; every bundle read is scoped to an exact repository.');
@@ -641,21 +874,39 @@ export async function caseBatchBundleCommand(runId, options) {
641
874
  writeArtifact(outDir, 'manifest.json', manifest.bytes);
642
875
  const pending = new Map();
643
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)));
644
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 = [];
645
890
  const done = new Set(['manifest.json']);
646
891
  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
- }
892
+ const fetchOne = async (next) => {
655
893
  if (!options.json)
656
894
  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) {
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) {
659
910
  throw new CaseBatchResponseError(`Bundle artifact ${next.path} is ${artifact.bytes.byteLength} bytes; the manifest declares ${next.bytes}.`);
660
911
  }
661
912
  if (artifact.sha256 !== next.sha256) {
@@ -669,10 +920,10 @@ export async function caseBatchBundleCommand(runId, options) {
669
920
  throw new CaseBatchResponseError(`Bundle exceeded the ${MAX_BUNDLE_BYTES}-byte download bound.`);
670
921
  }
671
922
  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.
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.
674
925
  const json = next.path.endsWith('.json');
675
- if (json) {
926
+ if (next.path.startsWith('manifest-pages/')) {
676
927
  try {
677
928
  collectArtifactRefs(JSON.parse(artifact.bytes.toString('utf8')), pending);
678
929
  }
@@ -682,27 +933,74 @@ export async function caseBatchBundleCommand(runId, options) {
682
933
  throw new CaseBatchResponseError(`Bundle page ${next.path} is not JSON.`);
683
934
  }
684
935
  }
685
- verified.push({ ...next, json });
686
- }
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;
687
968
  if (options.json) {
688
969
  process.stdout.write(`${JSON.stringify(withSchema('case-batch', {
689
970
  operation: 'bundle',
690
971
  run_id: runId,
691
- repository: repository.fullName,
972
+ repository,
692
973
  out_dir: outDir,
693
- manifest: { path: 'manifest.json', sha256: manifest.sha256, bytes: manifest.bytes.byteLength },
974
+ manifest: { path: 'manifest.json', sha256: manifest.sha256, bytes: manifest.bytes },
694
975
  artifacts: verified.map(ref => ({ path: ref.path, sha256: ref.sha256, bytes: ref.bytes, verified: true })),
695
976
  artifact_count: verified.length,
696
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 })),
697
981
  }), null, 2)}\n`);
698
982
  return;
699
983
  }
700
984
  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)`);
985
+ console.log(` ${chalk.dim('manifest.json')} ${manifest.sha256} (${manifest.bytes} bytes)`);
702
986
  for (const ref of verified) {
703
987
  console.log(` ${chalk.dim(ref.path)} ${ref.sha256} (${ref.bytes} bytes)`);
704
988
  }
705
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
+ }
706
1004
  }
707
1005
  export const caseBatchDefaults = {
708
1006
  perCaseWallMs: DEFAULT_PER_CASE_WALL_MS,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Submit command - Create a PR from current changes
3
3
  *
4
- * Runs pre-PR triage (code review, rules, intent drift) via parallel sub-agents,
4
+ * Runs pre-PR triage (code review, rules) on gpt-6-astra in parallel,
5
5
  * then pushes the current branch and creates a PR.
6
6
  *
7
7
  * PRs are routed based on flags:
@@ -141,9 +141,6 @@ function checkerDisplayName(checker) {
141
141
  /**
142
142
  * Merge triage-tuning knobs from two sources, in ascending precedence:
143
143
  * defaults (in runner.ts) < .haystack.json triage.* < CLI flags
144
- *
145
- * --max-turns <N> applies the same number to every checker. Per-checker
146
- * granularity stays in .haystack.json (less common, more verbose).
147
144
  */
148
145
  async function resolveTriageOptions(options) {
149
146
  let config = null;
@@ -158,30 +155,16 @@ async function resolveTriageOptions(options) {
158
155
  console.warn(chalk.dim(' Triage will use CLI flags / defaults — fix the config to apply project overrides.'));
159
156
  }
160
157
  const triageConfig = config?.triage ?? {};
161
- const maxTurns = { ...(triageConfig.maxTurns ?? {}) };
162
- if (typeof options.maxTurns === 'number' && options.maxTurns > 0) {
163
- for (const name of ['code-review', 'rules-validator']) {
164
- maxTurns[name] = options.maxTurns;
165
- }
166
- }
167
158
  // loadConfig does a raw `as HaystackConfig` cast (no Zod runtime parse), so
168
159
  // malformed .haystack.json values can reach us as strings/negatives/NaN.
169
- // Mirror the positive-number guard we already apply to maxTurns before
170
- // letting this flow into setTimeout.
160
+ // Guard before letting this flow into setTimeout.
171
161
  const configTimeoutMs = typeof triageConfig.timeoutMs === 'number' && triageConfig.timeoutMs > 0
172
162
  ? triageConfig.timeoutMs
173
163
  : undefined;
174
164
  const timeoutMs = typeof options.triageTimeout === 'number' && options.triageTimeout > 0
175
165
  ? options.triageTimeout * 1000
176
166
  : configTimeoutMs;
177
- return {
178
- maxTurns: Object.keys(maxTurns).length > 0 ? maxTurns : undefined,
179
- timeoutMs,
180
- };
181
- }
182
- function isCodexNetworkDisabledTriageError(err) {
183
- const message = err instanceof Error ? err.message : String(err);
184
- return message.includes('CODEX_SANDBOX_NETWORK_DISABLED=1');
167
+ return { timeoutMs };
185
168
  }
186
169
  function printTriageSummary(result) {
187
170
  console.log(chalk.bold('\n' + '━'.repeat(50)));
@@ -200,27 +183,21 @@ function printTriageSummary(result) {
200
183
  : issue.file;
201
184
  const ruleTag = issue.rule ? chalk.dim(` [${issue.rule}]`) : '';
202
185
  const patternTag = issue.pattern ? chalk.dim(` (${issue.pattern})`) : '';
203
- console.log(` ${severityIcon(issue.severity)} ${chalk.dim(location)} ${severityColor(issue.severity)(issue.message)}${ruleTag}${patternTag}`);
186
+ const categoryTag = issue.category ? chalk.dim(` {${issue.category}}`) : '';
187
+ console.log(` ${severityIcon(issue.severity)} ${chalk.dim(location)} ${severityColor(issue.severity)(issue.message)}${ruleTag}${patternTag}${categoryTag}`);
188
+ if (issue.failureScenario) {
189
+ console.log(chalk.dim(` ${issue.failureScenario}`));
190
+ }
204
191
  }
205
192
  console.log('');
206
193
  }
207
194
  }
208
- // Hard errors from sub-agent failures (auth, crashes) — these block submit.
209
- if (result.errors.length > 0) {
210
- console.log(chalk.red(' Checker errors:'));
211
- for (const err of result.errors) {
212
- console.log(chalk.red(` ✗ ${err}`));
213
- }
214
- console.log('');
195
+ // Checkers that could not run (API error, missing key, stall). Non-blocking.
196
+ for (const err of result.errors) {
197
+ console.log(chalk.yellow(` ! ${err}`));
215
198
  }
216
- // Soft failures (max turns, timeout) — non-blocking, purely informational.
217
- if (result.warnings.length > 0) {
218
- console.log(chalk.yellow(' Checker warnings (non-blocking):'));
219
- for (const warn of result.warnings) {
220
- console.log(chalk.yellow(` ! ${warn}`));
221
- }
199
+ if (result.errors.length > 0)
222
200
  console.log('');
223
- }
224
201
  // Total summary
225
202
  const totalIssues = result.results.reduce((sum, r) => sum + r.issues.length, 0);
226
203
  const errorCount = result.results.reduce((sum, r) => sum + r.issues.filter((i) => i.severity === 'error').length, 0);
@@ -343,49 +320,35 @@ export async function submitCommand(options) {
343
320
  const triageOptions = await resolveTriageOptions(options);
344
321
  const triageResult = await runTriage(gitRoot, baseBranch, triageOptions);
345
322
  printTriageSummary(triageResult);
346
- // No valid results means we got zero signal from triage. That's
347
- // "unavailable" whether the cause was a hard error (auth crash) or a
348
- // soft cap (every checker hit max turns). Either way don't pretend it
349
- // passed — print the warning and continue.
350
- const triageInfraFailure = triageResult.results.length === 0 &&
351
- (triageResult.errors.length > 0 || triageResult.warnings.length > 0);
323
+ // No valid results means we got zero signal from triage: every
324
+ // checker failed to run. Don't pretend it passed — warn and continue.
325
+ const triageInfraFailure = triageResult.results.length === 0 && triageResult.errors.length > 0;
352
326
  if (triageInfraFailure) {
353
327
  console.log(chalk.yellow('\n⚠ Triage unavailable (no checkers produced results). Continuing with submit.\n'));
354
328
  }
329
+ // Only findings with severity "error" block; a failed checker never does.
355
330
  if (!triageResult.passed) {
356
- if (triageInfraFailure) {
357
- // Infrastructure failure should not block submission.
358
- // We already warned above and continue.
359
- }
360
- else {
361
- fail('Triage failed. Fix the issues above or re-run with --force.', {
362
- triage: {
363
- status: 'failed',
364
- issues: triageResult.results.flatMap((r) => r.issues.map((issue) => ({
365
- checker: r.checker,
366
- severity: issue.severity,
367
- file: issue.file,
368
- line: issue.line ?? null,
369
- message: issue.message,
370
- rule: issue.rule ?? null,
371
- }))),
372
- },
373
- });
374
- }
331
+ fail('Triage failed. Fix the issues above or re-run with --force.', {
332
+ triage: {
333
+ status: 'failed',
334
+ issues: triageResult.results.flatMap((r) => r.issues.map((issue) => ({
335
+ checker: r.checker,
336
+ severity: issue.severity,
337
+ file: issue.file,
338
+ line: issue.line ?? null,
339
+ message: issue.message,
340
+ rule: issue.rule ?? null,
341
+ category: issue.category ?? null,
342
+ failure_scenario: issue.failureScenario ?? null,
343
+ }))),
344
+ },
345
+ });
375
346
  }
376
347
  triageStatus = triageInfraFailure ? 'unavailable' : 'passed';
377
348
  }
378
349
  catch (err) {
379
350
  // Triage failure should not block submission
380
- if (isCodexNetworkDisabledTriageError(err)) {
381
- console.log(chalk.yellow('\n⚠ Triage skipped in Codex app shell.'));
382
- console.log(chalk.dim(' Nested Codex sub-agents cannot access model endpoints from this shell.'));
383
- console.log(chalk.dim(' Submit will continue normally.'));
384
- console.log(chalk.dim(' For full triage, run `haystack submit` from a regular terminal.\n'));
385
- }
386
- else {
387
- console.log(chalk.yellow(`\n⚠ Triage skipped: ${err.message}`));
388
- }
351
+ console.log(chalk.yellow(`\n⚠ Triage skipped: ${err.message}`));
389
352
  triageStatus = 'unavailable';
390
353
  }
391
354
  }