@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.
- package/README.md +31 -2
- package/dist/commands/case-batch-contract.js +93 -7
- package/dist/commands/case-batch.js +327 -29
- package/dist/commands/submit.js +32 -69
- package/dist/commands/verify-precompute.js +7 -1
- package/dist/index.js +56 -20
- package/dist/schema.js +2 -2
- package/dist/triage/astra.js +202 -0
- package/dist/triage/prompts.js +145 -180
- package/dist/triage/runner.js +99 -381
- package/dist/triage/types.js +2 -2
- package/dist/types.js +2 -6
- package/dist/utils/haystack-api.js +12 -0
- package/package.json +2 -1
- package/schemas/case-batch.v1.json +54 -3
- package/schemas/submit.v1.json +4 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
|
673
|
-
//
|
|
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 (
|
|
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({
|
|
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
|
|
972
|
+
repository,
|
|
692
973
|
out_dir: outDir,
|
|
693
|
-
manifest: { path: 'manifest.json', sha256: manifest.sha256, bytes: manifest.bytes
|
|
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
|
|
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,
|
package/dist/commands/submit.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
209
|
-
|
|
210
|
-
console.log(chalk.
|
|
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
|
-
|
|
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
|
|
347
|
-
//
|
|
348
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
}
|