@haystackeditor/cli 0.20.0 → 0.22.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.
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { withSchema } from '../schema.js';
|
|
12
12
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
13
|
-
import { classifyHttpError,
|
|
13
|
+
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
|
|
14
14
|
import { trackOnboardingEvent } from '../utils/telemetry.js';
|
|
15
15
|
import { gatewayFetch } from './case-batch.js';
|
|
16
16
|
import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
|
|
@@ -93,6 +93,8 @@ export function parseOnboardingStatus(value, expected) {
|
|
|
93
93
|
invalid('its data absences');
|
|
94
94
|
if (!isStrings(value.versionChoices))
|
|
95
95
|
invalid('its version choices');
|
|
96
|
+
if (!isStrings(value.standIns) || !isStrings(value.unsetProviders))
|
|
97
|
+
invalid('its stand-ins');
|
|
96
98
|
if (value.block !== null)
|
|
97
99
|
parseOnboardingBlock(value.block);
|
|
98
100
|
const checkpoint = value.checkpoint;
|
|
@@ -128,6 +130,9 @@ async function body(response, what) {
|
|
|
128
130
|
return await response.json();
|
|
129
131
|
}
|
|
130
132
|
catch (error) {
|
|
133
|
+
// A connection that dropped mid-body is not a bad body: a wait reads again.
|
|
134
|
+
if (transientReadFailure(error) !== null)
|
|
135
|
+
throw error;
|
|
131
136
|
throw new OnboardingResponseError(`The onboarding service returned ${what} that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
132
137
|
}
|
|
133
138
|
}
|
|
@@ -159,8 +164,11 @@ const ONBOARDING_READ_EVERY_MS = 15_000;
|
|
|
159
164
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
160
165
|
/** Polls the base's onboarding until it is no longer running. No wall-time cap:
|
|
161
166
|
* an interrupt stops waiting and leaves the onboarding running (`resume` is the
|
|
162
|
-
* command that follows it again). A
|
|
163
|
-
*
|
|
167
|
+
* command that follows it again). A read that may work if asked again (a 429/5xx
|
|
168
|
+
* while a server or runner restarts, or no answer at all: a dropped connection
|
|
169
|
+
* ended a 1 h wait on 2026-10-02 with "fetch failed") is read again at the next
|
|
170
|
+
* poll, said once per run of failures, until the service has given no answer for
|
|
171
|
+
* SERVICE_SILENCE_LIMIT_MS (stall detection). */
|
|
164
172
|
export async function waitForOnboarding(first, token, progress, resume) {
|
|
165
173
|
const interrupted = () => {
|
|
166
174
|
console.error(`\nStopped waiting. The onboarding keeps running; run \`${resume}\` again to follow it.`);
|
|
@@ -171,6 +179,8 @@ export async function waitForOnboarding(first, token, progress, resume) {
|
|
|
171
179
|
let status = first;
|
|
172
180
|
progress(`Waiting for onboarding ${status.onboardRunId ?? ''} (Ctrl-C stops waiting; the onboarding keeps running).`);
|
|
173
181
|
progress(onboardingStep(status));
|
|
182
|
+
let failing = false;
|
|
183
|
+
let answeredAt = Date.now();
|
|
174
184
|
while (status.state === 'onboarding') {
|
|
175
185
|
await delay(ONBOARDING_READ_EVERY_MS);
|
|
176
186
|
let next;
|
|
@@ -178,11 +188,18 @@ export async function waitForOnboarding(first, token, progress, resume) {
|
|
|
178
188
|
next = await readOnboardingStatus(status.repository, status.baseCommit, token);
|
|
179
189
|
}
|
|
180
190
|
catch (error) {
|
|
181
|
-
|
|
191
|
+
const why = transientReadFailure(error);
|
|
192
|
+
if (why === null)
|
|
182
193
|
throw error;
|
|
183
|
-
|
|
194
|
+
if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
|
|
195
|
+
throw new ServiceSilentError('onboarding', resume);
|
|
196
|
+
if (!failing)
|
|
197
|
+
progress(`The onboarding status is unavailable for now (${why}); reading it again.`);
|
|
198
|
+
failing = true;
|
|
184
199
|
continue;
|
|
185
200
|
}
|
|
201
|
+
failing = false;
|
|
202
|
+
answeredAt = Date.now();
|
|
186
203
|
if (onboardingStep(next) !== onboardingStep(status))
|
|
187
204
|
progress(onboardingStep(next));
|
|
188
205
|
status = next;
|
|
@@ -273,6 +290,9 @@ export function formatOnboarding(status) {
|
|
|
273
290
|
lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
|
|
274
291
|
if (status.versionChoices.length)
|
|
275
292
|
lines.push('', ...status.versionChoices.map(choice => chalk.yellow(`Note: ${safe(choice)}`)));
|
|
293
|
+
const outside = [...status.standIns, ...status.unsetProviders];
|
|
294
|
+
if (outside.length)
|
|
295
|
+
lines.push('', ...outside.map(note => chalk.yellow(`Note: ${safe(note)}`)));
|
|
276
296
|
return lines.join('\n');
|
|
277
297
|
}
|
|
278
298
|
/** Telemetry: onboarding started, blocked or ready, once per state a command sees. */
|
|
@@ -351,7 +371,8 @@ export async function verifyOnboardingCommand(options) {
|
|
|
351
371
|
const origin = resolveOriginRepository(gitRoot);
|
|
352
372
|
const repository = `${origin.owner}/${origin.repository}`;
|
|
353
373
|
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: origin.owner, repo: origin.repository });
|
|
354
|
-
|
|
374
|
+
const baseCommit = deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS);
|
|
375
|
+
let status = await readWithRetries(() => readOnboardingStatus(repository, baseCommit, auth.token));
|
|
355
376
|
reportOnboardingState(status);
|
|
356
377
|
if (options.wait && status.state === 'onboarding') {
|
|
357
378
|
status = await waitForOnboarding(status, auth.token, line => console.error(chalk.dim(`${clock()} ${line}`)), 'haystack verify onboarding --wait');
|
|
@@ -262,7 +262,9 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
|
|
|
262
262
|
}
|
|
263
263
|
export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS,
|
|
264
264
|
/** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
|
|
265
|
-
budgetMs
|
|
265
|
+
budgetMs,
|
|
266
|
+
/** Amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' is `haystack verify`. */
|
|
267
|
+
mode = 'crawl') {
|
|
266
268
|
// Capture this before git and compression work so delivery latency cannot
|
|
267
269
|
// make an older stop look newer than a later one.
|
|
268
270
|
const observedAt = Date.now();
|
|
@@ -290,7 +292,7 @@ budgetMs) {
|
|
|
290
292
|
cacheKey,
|
|
291
293
|
patchGzBase64: patchGzip.toString('base64'),
|
|
292
294
|
patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
|
|
293
|
-
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
|
|
295
|
+
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode } }),
|
|
294
296
|
},
|
|
295
297
|
};
|
|
296
298
|
}
|
|
@@ -333,7 +335,7 @@ function readWalkerStatus(value) {
|
|
|
333
335
|
}
|
|
334
336
|
throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
|
|
335
337
|
}
|
|
336
|
-
const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', '
|
|
338
|
+
const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'enqueue-failed'];
|
|
337
339
|
const isOnboardRunId = (value) => typeof value === 'string' && /^ob_[0-9a-f]{48}$/.test(value);
|
|
338
340
|
function readCrawlHookStatus(value) {
|
|
339
341
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
@@ -365,7 +367,6 @@ function readCrawlHookStatus(value) {
|
|
|
365
367
|
export function crawlUnavailableText(reason) {
|
|
366
368
|
switch (reason) {
|
|
367
369
|
case 'runner-unavailable': return 'no machine that can crawl this repository is available right now';
|
|
368
|
-
case 'superseded': return 'a newer stop in this repository came first';
|
|
369
370
|
case 'enqueue-failed': return 'the service could not queue it';
|
|
370
371
|
}
|
|
371
372
|
}
|
|
@@ -455,14 +456,16 @@ function printResult(result) {
|
|
|
455
456
|
: 'Walker unavailable: the enqueue request failed.');
|
|
456
457
|
else
|
|
457
458
|
console.log('Walker status was not reported by the service.');
|
|
459
|
+
// Amendment 12: this capture builds and freezes the app only; `haystack verify` crawls it.
|
|
458
460
|
if (crawl?.status === 'queued')
|
|
459
|
-
console.log(`
|
|
461
|
+
console.log(`Building and freezing the app for this change (${crawl.runId}); run \`haystack verify\` to check it.`);
|
|
460
462
|
else if (crawl?.status === 'existing')
|
|
461
|
-
console.log(`
|
|
463
|
+
console.log(`This change is already built or being built (${crawl.runId}); run \`haystack verify\` to check it.`);
|
|
462
464
|
else if (crawl?.status === 'unavailable')
|
|
463
465
|
console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
|
|
464
466
|
else if (crawl?.status === 'onboarding') {
|
|
465
|
-
console.log(`The app is being onboarded for this base (${crawl.onboardRunId});
|
|
467
|
+
console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
|
|
468
|
+
+ ' Run `haystack verify` to check it.');
|
|
466
469
|
}
|
|
467
470
|
else if (crawl?.status === 'onboarding-blocked')
|
|
468
471
|
console.log(blockLines(crawl.block).join('\n'));
|
|
@@ -485,7 +488,9 @@ function configuredCrawlMinutes(gitRoot) {
|
|
|
485
488
|
}
|
|
486
489
|
return minutes;
|
|
487
490
|
}
|
|
488
|
-
export async function captureCheckout(deadline,
|
|
491
|
+
export async function captureCheckout(deadline,
|
|
492
|
+
/** Amendment 12: the turn-end hook and `verify precompute` ask to prepare (build and freeze); `haystack verify` to crawl. */
|
|
493
|
+
mode, repository,
|
|
489
494
|
/** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
|
|
490
495
|
minutes) {
|
|
491
496
|
const gitRoot = findGitRoot();
|
|
@@ -495,7 +500,7 @@ minutes) {
|
|
|
495
500
|
const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
|
|
496
501
|
const target = repository ?? resolveOriginRepository(gitRoot);
|
|
497
502
|
const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
|
|
498
|
-
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
|
|
503
|
+
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode);
|
|
499
504
|
return { metadataDirectory, derivation };
|
|
500
505
|
}
|
|
501
506
|
/** The explicit (non-hook) submission of a capture: one synchronous
|
|
@@ -533,7 +538,8 @@ export async function verifyPrecomputeCommand(options) {
|
|
|
533
538
|
const deadline = startedAt + totalWallMs;
|
|
534
539
|
let outcome = 'error';
|
|
535
540
|
try {
|
|
536
|
-
|
|
541
|
+
// The hook and an explicit precompute build and freeze the change ahead of `haystack verify`; they never crawl it.
|
|
542
|
+
const capture = await captureCheckout(deadline, 'prepare');
|
|
537
543
|
if (options.hook) {
|
|
538
544
|
remainingMs(deadline);
|
|
539
545
|
await startPrecomputeDelivery(capture.metadataDirectory, capture.derivation.request);
|
package/dist/commands/verify.js
CHANGED
|
@@ -26,7 +26,7 @@ import chalk from 'chalk';
|
|
|
26
26
|
import { withSchema } from '../schema.js';
|
|
27
27
|
import { findGitRoot } from '../utils/hooks.js';
|
|
28
28
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
29
|
-
import { classifyHttpError,
|
|
29
|
+
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
|
|
30
30
|
import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
|
|
31
31
|
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
|
|
32
32
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
@@ -36,8 +36,8 @@ const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
|
|
|
36
36
|
const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
|
|
37
37
|
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
38
38
|
const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
39
|
-
const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
|
|
40
|
-
const TERMINAL = new Set(['completed', 'incomplete', 'cancelled']);
|
|
39
|
+
const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'prepared', 'incomplete', 'cancelled'];
|
|
40
|
+
const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
|
|
41
41
|
const STAGES = ['reconcile', 'source', 'worlds', 'blast', 'knobs', 'prepare', 'crawl', 'publish', 'cleanup'];
|
|
42
42
|
const ERROR_CODES = ['source-mismatch', 'blast-unavailable', 'world-build-failed',
|
|
43
43
|
'prepare-failed', 'crawl-failed', 'no-capacity', 'stalled', 'lease-lost', 'cancelled'];
|
|
@@ -50,6 +50,7 @@ const STATUS_WORDS = {
|
|
|
50
50
|
crawling: 'exploring the app',
|
|
51
51
|
draining: 'finishing the last checks',
|
|
52
52
|
completed: 'finished',
|
|
53
|
+
prepared: 'built and ready to check',
|
|
53
54
|
incomplete: 'stopped before finishing',
|
|
54
55
|
cancelled: 'cancelled',
|
|
55
56
|
};
|
|
@@ -213,7 +214,7 @@ export function parseCrawlView(value, expected) {
|
|
|
213
214
|
|| typeof value.changeTitle !== 'string' || !isCount(value.observedAt)
|
|
214
215
|
|| !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt)
|
|
215
216
|
|| (value.supersededBy !== null && (typeof value.supersededBy !== 'string' || !RUN_ID.test(value.supersededBy)))
|
|
216
|
-
|| typeof value.cancelRequested !== 'boolean')
|
|
217
|
+
|| typeof value.cancelRequested !== 'boolean' || (value.mode !== 'prepare' && value.mode !== 'crawl'))
|
|
217
218
|
invalid('its status');
|
|
218
219
|
const clocks = value.clocks;
|
|
219
220
|
if (!isRecord(clocks) || !['startedAt', 'stageStartedAt', 'crawlStartedAt', 'finishedAt']
|
|
@@ -259,6 +260,9 @@ async function responseBody(response) {
|
|
|
259
260
|
return await response.json();
|
|
260
261
|
}
|
|
261
262
|
catch (error) {
|
|
263
|
+
// A connection that dropped mid-body is not a bad body: a wait reads again.
|
|
264
|
+
if (transientReadFailure(error) !== null)
|
|
265
|
+
throw error;
|
|
262
266
|
throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
263
267
|
}
|
|
264
268
|
}
|
|
@@ -333,13 +337,15 @@ function headline(view) {
|
|
|
333
337
|
const error = results(view)?.error ?? view.error;
|
|
334
338
|
return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
|
|
335
339
|
}
|
|
340
|
+
case 'prepared':
|
|
341
|
+
// Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
|
|
342
|
+
return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
|
|
336
343
|
case 'cancelled':
|
|
337
|
-
return `The crawl was cancelled
|
|
344
|
+
return `The crawl was cancelled.${found}`;
|
|
338
345
|
default: {
|
|
339
346
|
const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
|
|
340
347
|
if (view.cancelRequested) {
|
|
341
|
-
return `Being cancelled${view
|
|
342
|
-
+ `${currentStep(view)}.${so}`;
|
|
348
|
+
return `Being cancelled: ${currentStep(view)}.${so}`;
|
|
343
349
|
}
|
|
344
350
|
return `Still running: ${currentStep(view)}.${so}`;
|
|
345
351
|
}
|
|
@@ -482,7 +488,11 @@ function printNewBugs(view, printed) {
|
|
|
482
488
|
}
|
|
483
489
|
/** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
|
|
484
490
|
* service until the crawl changes, so there is no pause between reads. No wall-time cap: an interrupt stops waiting
|
|
485
|
-
* and leaves the crawl running.
|
|
491
|
+
* and leaves the crawl running. A read that may work if asked again (transientReadFailure) is asked again after
|
|
492
|
+
* TRANSIENT_READ_PAUSE_MS, said once per run of failures: one dropped connection used to end the command. Only a
|
|
493
|
+
* service that has given no answer for SERVICE_SILENCE_LIMIT_MS stops the wait (stall detection). */
|
|
494
|
+
/** The pause before a followed crawl is read again after a read that may work if asked again. */
|
|
495
|
+
const TRANSIENT_READ_PAUSE_MS = 5_000;
|
|
486
496
|
async function waitForCrawl(first, identity, token) {
|
|
487
497
|
const interrupted = () => {
|
|
488
498
|
console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
|
|
@@ -495,17 +505,36 @@ async function waitForCrawl(first, identity, token) {
|
|
|
495
505
|
progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
|
|
496
506
|
progress(currentStep(view));
|
|
497
507
|
printNewBugs(view, printed);
|
|
508
|
+
let failing = false;
|
|
509
|
+
let answeredAt = Date.now();
|
|
498
510
|
while (!TERMINAL.has(reportedStatus(view))) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (
|
|
507
|
-
|
|
511
|
+
let next;
|
|
512
|
+
try {
|
|
513
|
+
// The followed crawl's own read is held, so only a change to it wakes the wait (Astra review: a capture read could sit up
|
|
514
|
+
// to 25 s on another title's crawl of the same code).
|
|
515
|
+
next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
|
|
516
|
+
// Once this crawl is being cancelled, the capture's newest crawl is read at once (not held) and followed if it is this
|
|
517
|
+
// same change (amendment 12: no crawl replaces another, but one can be cancelled and asked for again).
|
|
518
|
+
if (next.cancelRequested) {
|
|
519
|
+
const newest = await readCrawl(identity, token);
|
|
520
|
+
if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
|
|
521
|
+
next = newest;
|
|
522
|
+
}
|
|
508
523
|
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
const why = transientReadFailure(error);
|
|
526
|
+
if (why === null)
|
|
527
|
+
throw error;
|
|
528
|
+
if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
|
|
529
|
+
throw new ServiceSilentError('crawl', 'haystack verify');
|
|
530
|
+
if (!failing)
|
|
531
|
+
progress(`The crawl's status is unavailable for now (${why}); reading it again.`);
|
|
532
|
+
failing = true;
|
|
533
|
+
await new Promise(resolve => setTimeout(resolve, TRANSIENT_READ_PAUSE_MS));
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
failing = false;
|
|
537
|
+
answeredAt = Date.now();
|
|
509
538
|
if (next.runId !== view.runId)
|
|
510
539
|
progress(`A newer crawl of this same change took over (${next.runId}).`);
|
|
511
540
|
if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
|
|
@@ -530,7 +559,10 @@ function staleCrawl(found, identity) {
|
|
|
530
559
|
return 'other-time';
|
|
531
560
|
if (found.status === 'cancelled' || found.status === 'incomplete')
|
|
532
561
|
return found.status;
|
|
533
|
-
|
|
562
|
+
if (!TERMINAL.has(found.status) && found.cancelRequested)
|
|
563
|
+
return 'cancelling';
|
|
564
|
+
// A `prepared` run is asked for its crawl whatever its mode (a cancel can leave a widened one prepared).
|
|
565
|
+
return found.mode === 'prepare' || found.status === 'prepared' ? 'prepare-only' : null;
|
|
534
566
|
}
|
|
535
567
|
function staleWords(found, stale) {
|
|
536
568
|
switch (stale) {
|
|
@@ -539,6 +571,9 @@ function staleWords(found, stale) {
|
|
|
539
571
|
case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
|
|
540
572
|
case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
|
|
541
573
|
case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
|
|
574
|
+
case 'prepare-only': return found.status === 'prepared'
|
|
575
|
+
? `Your change was built and frozen ahead of time (${found.runId})`
|
|
576
|
+
: `Your change is being built and frozen ahead of time (${found.runId})`;
|
|
542
577
|
}
|
|
543
578
|
}
|
|
544
579
|
/** Submits the capture through the explicit precompute submission when no crawl
|
|
@@ -546,21 +581,9 @@ function staleWords(found, stale) {
|
|
|
546
581
|
* when no crawl could be started), whether the base is being onboarded or its
|
|
547
582
|
* onboarding is blocked, and the line to report. */
|
|
548
583
|
async function submitCapture(capture, token, found) {
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
|
|
552
|
-
const pauses = [2_000, 5_000, 10_000];
|
|
553
|
-
for (let attempt = 0; acknowledgment === undefined; attempt += 1) {
|
|
554
|
-
try {
|
|
555
|
-
acknowledgment = await postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS);
|
|
556
|
-
}
|
|
557
|
-
catch (error) {
|
|
558
|
-
const transient = error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status);
|
|
559
|
-
if (!transient || attempt >= pauses.length)
|
|
560
|
-
throw normalModeFailure(error);
|
|
561
|
-
await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
|
|
562
|
-
}
|
|
563
|
-
}
|
|
584
|
+
// A momentary 429/5xx or a dropped connection (a server or runner restarting) is asked again, as the stop hook's
|
|
585
|
+
// sender does: admission is idempotent for one capture, so a repeat can only find what the first created.
|
|
586
|
+
const acknowledgment = await readWithRetries(() => postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS)).catch((error) => { throw normalModeFailure(error); });
|
|
564
587
|
const crawl = acknowledgment.crawl;
|
|
565
588
|
const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
|
|
566
589
|
if (crawl === undefined) {
|
|
@@ -584,7 +607,8 @@ async function submitCapture(capture, token, found) {
|
|
|
584
607
|
: `The service already had a crawl for this change (${crawl.runId}).` };
|
|
585
608
|
}
|
|
586
609
|
if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
|
|
587
|
-
return { runId: crawl.runId, onboarding: null, line:
|
|
610
|
+
return { runId: crawl.runId, onboarding: null, line: found.stale === 'prepare-only'
|
|
611
|
+
? `${before}; checking it now, reusing what was built.` : `${before}; asked the service to run it again.` };
|
|
588
612
|
}
|
|
589
613
|
return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
|
|
590
614
|
? `${before}; started one for this change (${crawl.runId}).`
|
|
@@ -619,7 +643,7 @@ export async function verifyCommand(options) {
|
|
|
619
643
|
console.error(line);
|
|
620
644
|
else
|
|
621
645
|
console.log(line); };
|
|
622
|
-
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
|
|
646
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes));
|
|
623
647
|
const request = capture.derivation.request;
|
|
624
648
|
const change = checkedChange(request.baseCommit, request.workCommit);
|
|
625
649
|
if (!request.crawl) {
|
|
@@ -641,7 +665,8 @@ export async function verifyCommand(options) {
|
|
|
641
665
|
budgetMs: request.crawl.budgetMs ?? null,
|
|
642
666
|
};
|
|
643
667
|
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
|
|
644
|
-
|
|
668
|
+
// One-shot reads ask again after a failure that may work if asked again, as the waits do.
|
|
669
|
+
let view = await readWithRetries(() => readCrawl(identity, auth.token));
|
|
645
670
|
let onboarding = null;
|
|
646
671
|
const stale = view === null ? null : staleCrawl(view, identity);
|
|
647
672
|
if (view === null || stale !== null) {
|
|
@@ -649,7 +674,7 @@ export async function verifyCommand(options) {
|
|
|
649
674
|
let submitted = await submitCapture(capture, auth.token, found);
|
|
650
675
|
note(submitted.line);
|
|
651
676
|
if (submitted.onboarding !== null) {
|
|
652
|
-
onboarding = await readOnboardingStatus(identity.repository, identity.baseCommit, auth.token);
|
|
677
|
+
onboarding = await readWithRetries(() => readOnboardingStatus(identity.repository, identity.baseCommit, auth.token));
|
|
653
678
|
reportOnboardingState(onboarding);
|
|
654
679
|
if (options.wait !== false && onboarding.state === 'onboarding') {
|
|
655
680
|
onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack verify');
|
|
@@ -671,7 +696,8 @@ export async function verifyCommand(options) {
|
|
|
671
696
|
process.exitCode = 1;
|
|
672
697
|
return;
|
|
673
698
|
}
|
|
674
|
-
|
|
699
|
+
const runId = submitted.runId;
|
|
700
|
+
view = await readWithRetries(() => readCrawlRun(runId, identity, auth.token));
|
|
675
701
|
if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
|
|
676
702
|
note('The service did not run it again; its last result follows.');
|
|
677
703
|
}
|
|
@@ -683,7 +709,8 @@ export async function verifyCommand(options) {
|
|
|
683
709
|
process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: view }), null, 2)}\n`);
|
|
684
710
|
else {
|
|
685
711
|
console.log(formatCrawl(view));
|
|
686
|
-
const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? [])]
|
|
712
|
+
const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? []), ...(onboarding?.standIns ?? []),
|
|
713
|
+
...(onboarding?.unsetProviders ?? [])];
|
|
687
714
|
if (notes.length)
|
|
688
715
|
console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
|
|
689
716
|
}
|
package/dist/index.js
CHANGED
|
@@ -153,7 +153,8 @@ repository already keeps working. init shows each change as a diff, then makes
|
|
|
153
153
|
it with --yes (or a yes at the prompt):
|
|
154
154
|
• Claude Code's Stop hook (\`haystack verify precompute --hook\`) in the
|
|
155
155
|
per-developer .claude/settings.local.json, kept out of commits, when
|
|
156
|
-
Claude Code is in use here
|
|
156
|
+
Claude Code is in use here: it builds and freezes the changed app ahead
|
|
157
|
+
of time, so \`haystack verify\` starts from there
|
|
157
158
|
• a short note in AGENTS.md (and CLAUDE.md when the repository keeps one)
|
|
158
159
|
telling coding agents to run \`haystack verify\` after a change
|
|
159
160
|
Then it starts onboarding the app, so it is usually ready by the first
|
|
@@ -264,10 +265,9 @@ Exit codes:
|
|
|
264
265
|
0 the crawl answered or finished (the bugs it found are in the output), or
|
|
265
266
|
it is still running and --no-wait was given
|
|
266
267
|
1 the command failed, or no crawl could be started for this change
|
|
267
|
-
2 the crawl ended without finishing: it stopped early or was cancelled
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
before finishing
|
|
268
|
+
2 the crawl ended without finishing: it stopped early or was cancelled,
|
|
269
|
+
or the machines it used could not be proven shut down; or onboarding
|
|
270
|
+
stopped before finishing
|
|
271
271
|
3 onboarding is blocked: the output says what would unblock it
|
|
272
272
|
|
|
273
273
|
The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
|
|
@@ -367,7 +367,7 @@ Examples:
|
|
|
367
367
|
});
|
|
368
368
|
verify
|
|
369
369
|
.command('precompute')
|
|
370
|
-
.description('
|
|
370
|
+
.description('Build and freeze the current working tree\'s app ahead of `haystack verify` (no crawl)')
|
|
371
371
|
.option('--hook', 'Run silently as a best-effort repository hook')
|
|
372
372
|
.action(async (options) => {
|
|
373
373
|
await runPublicCommand(async () => {
|
|
@@ -1359,7 +1359,8 @@ hooks
|
|
|
1359
1359
|
Configures your coding CLI to run \`haystack triage --hook\` on session start.
|
|
1360
1360
|
This shows pending PR analysis results when you open a new terminal session.
|
|
1361
1361
|
Claude Code also runs \`haystack verify precompute --hook\` when a session stops,
|
|
1362
|
-
which
|
|
1362
|
+
which builds and freezes the changed app in the background so that
|
|
1363
|
+
\`haystack verify\` starts from there. It never crawls; \`haystack verify\` does.
|
|
1363
1364
|
|
|
1364
1365
|
Claude Code: Native SessionStart and Stop hooks (.claude/settings.local.json;
|
|
1365
1366
|
--shared writes the committed .claude/settings.json)
|
|
@@ -25,6 +25,70 @@ export class HaystackApiError extends Error {
|
|
|
25
25
|
return this.status === 401 || this.status === 403;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/** The network failures fetch (undici) reports as its TypeError's `cause.code`: the connection refused, reset, dropped,
|
|
29
|
+
* unreachable or timed out, or the name not resolved for now. A request it could not even build (ERR_INVALID_URL) is not one. */
|
|
30
|
+
const TRANSPORT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EPIPE', 'ETIMEDOUT', 'ENETUNREACH',
|
|
31
|
+
'ENETDOWN', 'EHOSTUNREACH', 'EHOSTDOWN', 'EAI_AGAIN', 'ENOTFOUND', 'UND_ERR_SOCKET', 'UND_ERR_CLOSED',
|
|
32
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']);
|
|
33
|
+
/**
|
|
34
|
+
* Why a read failed in a way that asking again may fix, or null when it did not: the service or a runner restarting
|
|
35
|
+
* (429, 502, 503, 504), no answer at all (fetch's TypeError whose cause is one of TRANSPORT_FAILURE_CODES), or a request
|
|
36
|
+
* past its own deadline (TimeoutError). A wait loop reads again on these; anything else (sign-in, a bad request, a request
|
|
37
|
+
* that could not be built, a bug) is the caller's to report.
|
|
38
|
+
*/
|
|
39
|
+
export function transientReadFailure(error) {
|
|
40
|
+
if (error instanceof HaystackApiError)
|
|
41
|
+
return [429, 502, 503, 504].includes(error.status) ? `HTTP ${error.status}` : null;
|
|
42
|
+
if (error instanceof Error && error.name === 'TimeoutError')
|
|
43
|
+
return 'no answer in time';
|
|
44
|
+
return error instanceof TypeError ? transportCause(error.cause, 0) : null;
|
|
45
|
+
}
|
|
46
|
+
/** The transport failure somewhere in a fetch TypeError's cause chain. A connection dropped while the body is read fails as
|
|
47
|
+
* TypeError "terminated", whose cause may be the socket error or the request's TimeoutError (Bugbot, #3760); several
|
|
48
|
+
* addresses tried at once (IPv4 and IPv6) fail as one AggregateError. */
|
|
49
|
+
function transportCause(cause, depth) {
|
|
50
|
+
if (!(cause instanceof Error) || depth > 4)
|
|
51
|
+
return null;
|
|
52
|
+
if (cause.name === 'TimeoutError')
|
|
53
|
+
return 'no answer in time';
|
|
54
|
+
const code = cause.code;
|
|
55
|
+
if (typeof code === 'string' && TRANSPORT_FAILURE_CODES.has(code))
|
|
56
|
+
return `no answer: ${code}`;
|
|
57
|
+
if (cause instanceof AggregateError) {
|
|
58
|
+
for (const inner of cause.errors) {
|
|
59
|
+
const found = transportCause(inner, depth + 1);
|
|
60
|
+
if (found !== null)
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return transportCause(cause.cause, depth + 1);
|
|
65
|
+
}
|
|
66
|
+
/** How long a wait keeps asking while the service gives no answer, counted from its last answer. Stall detection, not a
|
|
67
|
+
* cap on the work: a service that answers is followed for as long as the work runs. */
|
|
68
|
+
export const SERVICE_SILENCE_LIMIT_MS = 10 * 60_000;
|
|
69
|
+
/** A wait stopped because the service gave no answer for SERVICE_SILENCE_LIMIT_MS (a dead network, a wrong
|
|
70
|
+
* HAYSTACK_API_BASE, a long outage); the work it was following keeps running on the service. */
|
|
71
|
+
export class ServiceSilentError extends Error {
|
|
72
|
+
constructor(work, resume) {
|
|
73
|
+
super(`Haystack has not answered for ${SERVICE_SILENCE_LIMIT_MS / 60_000} minutes; the ${work} keeps running on the service. `
|
|
74
|
+
+ `Run \`${resume}\` again to follow it.`);
|
|
75
|
+
this.name = 'ServiceSilentError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** A one-shot read, asked again after a failure that may work if asked again (transientReadFailure), at most three more times. */
|
|
79
|
+
export async function readWithRetries(read) {
|
|
80
|
+
const pauses = [2_000, 5_000, 10_000];
|
|
81
|
+
for (let attempt = 0;; attempt += 1) {
|
|
82
|
+
try {
|
|
83
|
+
return await read();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (transientReadFailure(error) === null || attempt >= pauses.length)
|
|
87
|
+
throw error;
|
|
88
|
+
await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
28
92
|
function headers(token, extra) {
|
|
29
93
|
const result = new Headers(extra);
|
|
30
94
|
result.set('Authorization', `Bearer ${token}`);
|