@haystackeditor/cli 0.21.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, HaystackApiError } from '../utils/haystack-api.js';
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 momentary 429/5xx (a server or runner
163
- * restarting) is read again at the next poll. */
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
- if (!(error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status)))
191
+ const why = transientReadFailure(error);
192
+ if (why === null)
182
193
  throw error;
183
- progress(`The onboarding status is unavailable for now (${error.status}); reading it again.`);
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
- let status = await readOnboardingStatus(repository, deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS), auth.token);
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');
@@ -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, HaystackApiError } from '../utils/haystack-api.js';
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';
@@ -260,6 +260,9 @@ async function responseBody(response) {
260
260
  return await response.json();
261
261
  }
262
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;
263
266
  throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
264
267
  }
265
268
  }
@@ -485,7 +488,11 @@ function printNewBugs(view, printed) {
485
488
  }
486
489
  /** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
487
490
  * service until the crawl changes, so there is no pause between reads. No wall-time cap: an interrupt stops waiting
488
- * 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;
489
496
  async function waitForCrawl(first, identity, token) {
490
497
  const interrupted = () => {
491
498
  console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
@@ -498,17 +505,36 @@ async function waitForCrawl(first, identity, token) {
498
505
  progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
499
506
  progress(currentStep(view));
500
507
  printNewBugs(view, printed);
508
+ let failing = false;
509
+ let answeredAt = Date.now();
501
510
  while (!TERMINAL.has(reportedStatus(view))) {
502
- // 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 to
503
- // 25 s on another title's crawl of the same code).
504
- let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
505
- // Once this crawl is being cancelled, the capture's newest crawl is read at once (not held) and followed if it is this same
506
- // change (amendment 12: no crawl replaces another, but one can be cancelled and asked for again).
507
- if (next.cancelRequested) {
508
- const newest = await readCrawl(identity, token);
509
- if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
510
- next = newest;
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
+ }
511
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();
512
538
  if (next.runId !== view.runId)
513
539
  progress(`A newer crawl of this same change took over (${next.runId}).`);
514
540
  if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
@@ -555,21 +581,9 @@ function staleWords(found, stale) {
555
581
  * when no crawl could be started), whether the base is being onboarded or its
556
582
  * onboarding is blocked, and the line to report. */
557
583
  async function submitCapture(capture, token, found) {
558
- let acknowledgment;
559
- // A momentary 429/5xx (a server or runner restarting) is asked again, as the stop hook's sender does: admission is
560
- // idempotent for one capture, so a repeat can only find what the first created.
561
- const pauses = [2_000, 5_000, 10_000];
562
- for (let attempt = 0; acknowledgment === undefined; attempt += 1) {
563
- try {
564
- acknowledgment = await postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS);
565
- }
566
- catch (error) {
567
- const transient = error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status);
568
- if (!transient || attempt >= pauses.length)
569
- throw normalModeFailure(error);
570
- await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
571
- }
572
- }
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); });
573
587
  const crawl = acknowledgment.crawl;
574
588
  const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
575
589
  if (crawl === undefined) {
@@ -651,7 +665,8 @@ export async function verifyCommand(options) {
651
665
  budgetMs: request.crawl.budgetMs ?? null,
652
666
  };
653
667
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
654
- let view = await readCrawl(identity, auth.token);
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));
655
670
  let onboarding = null;
656
671
  const stale = view === null ? null : staleCrawl(view, identity);
657
672
  if (view === null || stale !== null) {
@@ -659,7 +674,7 @@ export async function verifyCommand(options) {
659
674
  let submitted = await submitCapture(capture, auth.token, found);
660
675
  note(submitted.line);
661
676
  if (submitted.onboarding !== null) {
662
- onboarding = await readOnboardingStatus(identity.repository, identity.baseCommit, auth.token);
677
+ onboarding = await readWithRetries(() => readOnboardingStatus(identity.repository, identity.baseCommit, auth.token));
663
678
  reportOnboardingState(onboarding);
664
679
  if (options.wait !== false && onboarding.state === 'onboarding') {
665
680
  onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack verify');
@@ -681,7 +696,8 @@ export async function verifyCommand(options) {
681
696
  process.exitCode = 1;
682
697
  return;
683
698
  }
684
- view = await readCrawlRun(submitted.runId, identity, auth.token);
699
+ const runId = submitted.runId;
700
+ view = await readWithRetries(() => readCrawlRun(runId, identity, auth.token));
685
701
  if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
686
702
  note('The service did not run it again; its last result follows.');
687
703
  }
@@ -693,7 +709,8 @@ export async function verifyCommand(options) {
693
709
  process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: view }), null, 2)}\n`);
694
710
  else {
695
711
  console.log(formatCrawl(view));
696
- const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? [])];
712
+ const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? []), ...(onboarding?.standIns ?? []),
713
+ ...(onboarding?.unsetProviders ?? [])];
697
714
  if (notes.length)
698
715
  console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
699
716
  }
@@ -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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {