@haystackeditor/cli 0.18.0 → 0.20.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.
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `haystack verify` — the crawl of the current change (CRAWL-V1, amendment 1).
2
+ * `haystack verify` — the crawl of the current change (CRAWL-V1, amendments 1
3
+ * and 8).
3
4
  *
4
5
  * It captures the checkout (committed and uncommitted) exactly as the stop
5
6
  * hook does, finds the caller's crawl for that exact capture (its code and its
@@ -8,20 +9,31 @@
8
9
  * precompute` sends when there is none, or when the one found ended cancelled
9
10
  * or incomplete or is being cancelled (the service runs it again). It follows
10
11
  * the acknowledged run by its id. It then waits (no wall-time cap: Ctrl-C
11
- * stops waiting, never the crawl) and prints where the change shows up in the
12
- * running app and what the crawl found. The hosted fleet run of pushed
13
- * commits is `haystack verify hosted start|status`.
12
+ * stops waiting, never the crawl), each read held by the service until the
13
+ * crawl changes (amendment 9), printing each bug as the crawl's answers bring
14
+ * it; it stops at the crawl's answer at its budget, or when the crawl ends, and
15
+ * prints where the change shows up in the running app and what the crawl found.
16
+ * The hosted fleet run of pushed commits is `haystack verify hosted start|status`.
17
+ *
18
+ * A base the service has not onboarded yet answers the submission `onboarding`
19
+ * (APP-ONBOARDING-V1): the command follows the onboarding's stage list until
20
+ * it is ready, then submits the capture again, which finds the crawl the
21
+ * service admitted from the first submission (or starts it), and follows that.
22
+ * A blocked onboarding prints why and exactly what would unblock it.
14
23
  */
24
+ import { execFileSync } from 'node:child_process';
15
25
  import chalk from 'chalk';
16
26
  import { withSchema } from '../schema.js';
27
+ import { findGitRoot } from '../utils/hooks.js';
17
28
  import { resolveAuthContext } from '../utils/auth.js';
18
29
  import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
19
- import { gatewayFetch } from './case-batch.js';
20
- import { CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, } from './crawl-contract.js';
30
+ import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
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';
21
32
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
33
+ import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, waitForOnboarding } from './verify-onboarding.js';
22
34
  const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
23
- const DEFAULT_INTERVAL_SECONDS = 5;
24
- const MAX_INTERVAL_SECONDS = 300;
35
+ /** A held read (waitAfter): the service's hold, then the time any read gets. */
36
+ const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
25
37
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
26
38
  const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
27
39
  const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
@@ -71,7 +83,8 @@ const SPOT_WORDS = {
71
83
  pressed: 'ran when its control was pressed',
72
84
  observed: 'a difference seen',
73
85
  confirmed: 'a difference seen and confirmed',
74
- judged: 'judged a bug',
86
+ // The stage, not the verdict: a judged difference may be judged not a bug. The findings carry the verdicts.
87
+ judged: 'a difference seen, confirmed and judged',
75
88
  };
76
89
  const VERDICT_WORDS = {
77
90
  bug: 'Bug',
@@ -136,21 +149,21 @@ function checkSpot(value) {
136
149
  || (reached.page !== null && typeof reached.page !== 'string')))
137
150
  invalid('how a changed spot was reached');
138
151
  }
139
- function checkManifest(value, view) {
152
+ /** The published results' shape, shared by the sealed manifest and (amendment 9) an answer; `what` names which. */
153
+ function checkResults(value, view, what) {
140
154
  if (!isRecord(value) || value.schemaVersion !== 1)
141
- invalid('its results');
142
- // The published results belong to exactly this crawl and this revision.
155
+ invalid(what);
156
+ // Published results belong to exactly this crawl and this revision.
143
157
  if (value.runId !== view.runId || value.repository !== view.repository || value.baseCommit !== view.baseCommit
144
- || value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle
145
- || value.status !== view.status) {
146
- invalid('results that belong to a different crawl');
158
+ || value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle) {
159
+ invalid(`${what} from a different crawl`);
147
160
  }
148
161
  if (typeof value.attemptId !== 'string' || !isCount(value.bugs) || !isCount(value.notFinishedInTime))
149
- invalid('its results');
162
+ invalid(what);
150
163
  if (value.worlds !== null && (!isRecord(value.worlds) || typeof value.worlds.base !== 'string'
151
164
  || typeof value.worlds.head !== 'string'))
152
- invalid('its results');
153
- checkReuse(value.reuse, 'its results');
165
+ invalid(what);
166
+ checkReuse(value.reuse, what);
154
167
  if (!isRecord(value.timings) || !Object.values(value.timings)
155
168
  .every(ms => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0))
156
169
  invalid('its stage timings');
@@ -170,6 +183,18 @@ function checkManifest(value, view) {
170
183
  if (value.error !== null)
171
184
  checkError(value.error, 'its error');
172
185
  }
186
+ function checkManifest(value, view) {
187
+ checkResults(value, view, 'its results');
188
+ if (value.status !== view.status)
189
+ invalid('its results from a different crawl');
190
+ }
191
+ /** Amendment 9: what the crawl found so far, or its answer at the budget: the manifest's shape, of this same crawl. */
192
+ function checkAnswer(value, view) {
193
+ checkResults(value, view, 'its answer');
194
+ if ((value.status !== 'exploring' && value.status !== 'answered') || !Number.isSafeInteger(value.n) || value.n < 1
195
+ || !isCount(value.atSeconds))
196
+ invalid('its answer');
197
+ }
173
198
  /** Accepts only a well-formed crawl of exactly this capture's revision. Another
174
199
  * revision's crawl is refused, never shown. The change title is not checked
175
200
  * here: the newest crawl of a revision may carry another title, which the
@@ -209,7 +234,19 @@ export function parseCrawlView(value, expected) {
209
234
  invalid('results before it finished');
210
235
  checkManifest(value.manifest, value);
211
236
  }
212
- return value;
237
+ // Amendment 8: a service from before it sends no budgetMs, and every crawl it ran was asked for the default (Astra review).
238
+ if (value.budgetMs !== undefined && value.budgetMs !== null && (typeof value.budgetMs !== 'number' || !Number.isInteger(value.budgetMs)
239
+ || value.budgetMs < CRAWL_BUDGET_MIN_MS || value.budgetMs > CRAWL_BUDGET_MAX_MS))
240
+ invalid('its time');
241
+ // Amendment 9: a service from before it sends no answer. An answer stands in for results not yet sealed, never beside them.
242
+ let answer = null;
243
+ if (value.answer !== undefined && value.answer !== null) {
244
+ if (value.manifest !== null)
245
+ invalid('an answer beside its sealed results');
246
+ checkAnswer(value.answer, value);
247
+ answer = value.answer;
248
+ }
249
+ return { ...value, budgetMs: value.budgetMs ?? null, answer };
213
250
  }
214
251
  /* ------------------------------------------------------------------- read */
215
252
  /** The change title is part of a crawl's identity and informs its judge: a
@@ -225,12 +262,19 @@ async function responseBody(response) {
225
262
  throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
226
263
  }
227
264
  }
265
+ /** A read, held by the service when `waitAfter` (the updatedAt the caller holds) is given: until the
266
+ * crawl's updatedAt changes, or CRAWL_WAIT_MAX_MS passes (amendment 9). */
267
+ function read(path, token, waitAfter) {
268
+ return waitAfter === undefined
269
+ ? gatewayFetch(path, token)
270
+ : gatewayFetch(`${path}&waitAfter=${encodeURIComponent(waitAfter)}`, token, { method: 'GET', timeoutMs: HELD_READ_TIMEOUT_MS });
271
+ }
228
272
  /** The caller's newest crawl of this capture's revision, or null. Its change
229
273
  * title may differ from the capture's (sameChange). */
230
- async function readCrawl(identity, token) {
274
+ async function readCrawl(identity, token, waitAfter) {
231
275
  const path = `${CRAWLS_PATH}?repository=${encodeURIComponent(identity.repository)}`
232
276
  + `&workCommit=${encodeURIComponent(identity.workCommit)}`;
233
- const response = await gatewayFetch(path, token);
277
+ const response = await read(path, token, waitAfter);
234
278
  if (!response.ok)
235
279
  throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}`);
236
280
  const body = await responseBody(response);
@@ -239,10 +283,10 @@ async function readCrawl(identity, token) {
239
283
  return body.crawl === null ? null : parseCrawlView(body.crawl, identity);
240
284
  }
241
285
  /** One crawl by its run id; it must be exactly this capture, change title included. */
242
- async function readCrawlRun(runId, identity, token) {
286
+ async function readCrawlRun(runId, identity, token, waitAfter) {
243
287
  if (!RUN_ID.test(runId))
244
288
  invalid('an acknowledged run id');
245
- const response = await gatewayFetch(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token);
289
+ const response = await read(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token, waitAfter);
246
290
  if (!response.ok)
247
291
  throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}/${runId}`);
248
292
  const view = parseCrawlView(await responseBody(response), identity);
@@ -270,14 +314,23 @@ function errorWords(error) {
270
314
  function currentStep(view) {
271
315
  return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
272
316
  }
317
+ /** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
318
+ function results(view) {
319
+ return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
320
+ }
321
+ /** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
322
+ * done; only its shutdown remains) reads as completed. */
323
+ function reportedStatus(view) {
324
+ return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
325
+ }
273
326
  function headline(view) {
274
- const bugs = view.manifest?.bugs ?? view.totals.bugs;
327
+ const bugs = results(view)?.bugs ?? view.totals.bugs;
275
328
  const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
276
- switch (view.status) {
329
+ switch (reportedStatus(view)) {
277
330
  case 'completed':
278
331
  return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
279
332
  case 'incomplete': {
280
- const error = view.manifest?.error ?? view.error;
333
+ const error = results(view)?.error ?? view.error;
281
334
  return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
282
335
  }
283
336
  case 'cancelled':
@@ -294,7 +347,9 @@ function headline(view) {
294
347
  }
295
348
  function spotLines(spot) {
296
349
  const how = spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest];
297
- const lines = [` ${chalk.bold(`${safe(spot.file)}:${spot.line}`)} ${how}`];
350
+ const found = spot.findingIds.length === 0 ? ''
351
+ : ` (${spot.findingIds.length} finding${spot.findingIds.length === 1 ? '' : 's'} below)`;
352
+ const lines = [` ${chalk.bold(`${safe(spot.file)}:${spot.line}`)} ${how}${found}`];
298
353
  if (spot.reachedBy) {
299
354
  lines.push(` reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].map(safe).join(' > ')}`);
300
355
  if (spot.reachedBy.page !== null)
@@ -311,15 +366,17 @@ function findingLines(finding, position) {
311
366
  return lines;
312
367
  }
313
368
  export function formatCrawl(view) {
369
+ const status = reportedStatus(view);
314
370
  const lines = [chalk.bold(headline(view))];
315
- const error = view.manifest?.error ?? view.error;
316
- if (error && view.status !== 'completed')
371
+ const published = results(view);
372
+ const error = published?.error ?? view.error;
373
+ if (error && status !== 'completed')
317
374
  lines.push(chalk.dim(` Details: ${safe(error.message)}`));
318
- lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(` Crawl ${view.runId} of ${safe(view.repository)}: base ${view.baseCommit.slice(0, 12)},`
375
+ lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(` Time: ${view.clocks.crawlBudgetMs > 0 ? `${Math.round(view.clocks.crawlBudgetMs / 1000)} s` : timeWords(view.budgetMs)}`
376
+ + `${view.budgetMs === null ? ' (the default; set another with --minutes or .haystack.json crawl.minutes)' : ' (asked for)'}.`), chalk.dim(` Crawl ${view.runId} of ${safe(view.repository)}: base ${view.baseCommit.slice(0, 12)},`
319
377
  + ` your change ${view.workCommit.slice(0, 12)}.`));
320
- const manifest = view.manifest;
321
- if (manifest === null) {
322
- lines.push('', TERMINAL.has(view.status)
378
+ if (published === null) {
379
+ lines.push('', TERMINAL.has(status)
323
380
  ? 'No results were published for this crawl.'
324
381
  : 'Where your change shows up in the app, and what the crawl found, appear when it finishes.');
325
382
  return lines.join('\n');
@@ -330,55 +387,60 @@ export function formatCrawl(view) {
330
387
  return lines.join('\n');
331
388
  }
332
389
  lines.push('', chalk.bold('Where your change shows up in the app'));
333
- if (manifest.reach.spots.length === 0)
390
+ if (published.reach.spots.length === 0)
334
391
  lines.push(' No changed spots were recorded.');
335
- for (const spot of manifest.reach.spots)
392
+ for (const spot of published.reach.spots)
336
393
  lines.push(...spotLines(spot));
337
394
  lines.push('', chalk.bold('What the crawl found'));
338
395
  // Bugs first, then the undecided, then what the judge called intended.
339
- const findings = VERDICTS.flatMap(verdict => manifest.findings.filter(finding => finding.verdict === verdict));
396
+ const findings = VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict));
340
397
  if (findings.length === 0)
341
398
  lines.push(' No confirmed differences between the app with and without your change.');
342
399
  findings.forEach((finding, index) => lines.push(...findingLines(finding, index + 1)));
343
- if (manifest.notFinishedInTime > 0) {
344
- lines.push(` ${plural(manifest.notFinishedInTime, 'difference')} ${manifest.notFinishedInTime === 1 ? 'was' : 'were'}`
400
+ if (published.notFinishedInTime > 0) {
401
+ lines.push(` ${plural(published.notFinishedInTime, 'difference')} ${published.notFinishedInTime === 1 ? 'was' : 'were'}`
345
402
  + ' still being checked when its time ran out.');
346
403
  }
347
404
  lines.push('', chalk.bold('What the crawl never ran'));
348
- if (manifest.reach.unreachedChangedFiles.length === 0)
405
+ if (published.reach.unreachedChangedFiles.length === 0)
349
406
  lines.push(' Every changed file ran at least once.');
350
407
  else {
351
408
  lines.push(' Changed files no path ran:');
352
- for (const file of manifest.reach.unreachedChangedFiles)
409
+ for (const file of published.reach.unreachedChangedFiles)
353
410
  lines.push(` ${safe(file)}`);
354
411
  }
355
- const blast = manifest.reach.blast;
412
+ const blast = published.reach.blast;
356
413
  if (blast !== null)
357
414
  lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
358
- if (view.totals.cleanupUnproven > 0) {
415
+ // Only a finished crawl has proven its cleanup: an answered one is still shutting its machines down (Bugbot).
416
+ if (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0) {
359
417
  lines.push('', chalk.yellow(` ${plural(view.totals.cleanupUnproven, 'machine')} it used could not be proven shut down.`));
360
418
  }
361
419
  return lines.join('\n');
362
420
  }
363
- /** Case-batch status conventions: 0 finished, or still running with --no-wait;
364
- * 2 ended without finishing (incomplete, cancelled) or cleanup unproven; bugs
421
+ /** Case-batch status conventions: 0 finished (or answered: amendment 9, its work
422
+ * is done and only its shutdown remains), or still running with --no-wait;
423
+ * 2 ended without finishing (incomplete, cancelled) or finished with cleanup
424
+ * unproven (an answered crawl has not finished its cleanup, so it is not judged); bugs
365
425
  * found never change it. The command's own failures exit 1 elsewhere. */
366
426
  export function crawlExitCode(view) {
367
- if (!TERMINAL.has(view.status))
427
+ const status = reportedStatus(view);
428
+ if (!TERMINAL.has(status))
368
429
  return 0;
369
- if (view.status !== 'completed' || view.totals.cleanupUnproven > 0)
430
+ if (status !== 'completed' || (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0))
370
431
  return 2;
371
432
  return 0;
372
433
  }
373
434
  /* ---------------------------------------------------------------- command */
374
- function intervalMs(value) {
435
+ /** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
436
+ function minutesOption(value) {
375
437
  if (value === undefined)
376
- return DEFAULT_INTERVAL_SECONDS * 1000;
377
- const seconds = Number(value);
378
- if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_INTERVAL_SECONDS) {
379
- throw new Error(`--interval must be a positive number no greater than ${MAX_INTERVAL_SECONDS}.`);
438
+ return undefined;
439
+ const minutes = Number(value);
440
+ if (!Number.isInteger(minutes) || minutes * 60_000 < CRAWL_BUDGET_MIN_MS || minutes * 60_000 > CRAWL_BUDGET_MAX_MS) {
441
+ throw new Error(`--minutes must be a whole number from ${CRAWL_BUDGET_MIN_MS / 60_000} to ${CRAWL_BUDGET_MAX_MS / 60_000}.`);
380
442
  }
381
- return seconds * 1000;
443
+ return minutes;
382
444
  }
383
445
  function repositoryOverride(value) {
384
446
  if (value === undefined)
@@ -389,15 +451,39 @@ function repositoryOverride(value) {
389
451
  }
390
452
  return { owner: parts[0], repository: parts[1] };
391
453
  }
392
- function delay(milliseconds) {
393
- return new Promise(resolve => setTimeout(resolve, milliseconds));
454
+ function clock() {
455
+ return new Date().toTimeString().slice(0, 8);
394
456
  }
395
457
  function progress(line) {
396
- console.error(chalk.dim(`${new Date().toTimeString().slice(0, 8)} ${line}`));
458
+ console.error(chalk.dim(`${clock()} ${line}`));
459
+ }
460
+ function clip(text, maximum = 60) {
461
+ return text.length <= maximum ? text : `${text.slice(0, maximum - 1)}…`;
462
+ }
463
+ /** A finding's steps in one line: where it starts, then its steps, the middle of a long path elided. */
464
+ function shortSteps(finding) {
465
+ const steps = finding.steps.map(step => clip(safe(step)));
466
+ const shown = steps.length <= 4 ? steps : [steps[0], `(${steps.length - 3} more steps)`, ...steps.slice(-2)];
467
+ return [`from ${clip(safe(finding.start))}`, ...shown].join(' > ');
397
468
  }
398
- /** Polls the same capture until its crawl is terminal. No wall-time cap: an
399
- * interrupt stops waiting and leaves the crawl running. */
400
- async function waitForCrawl(first, identity, token, everyMs) {
469
+ /** Prints each bug the view's answer brings that was not printed before (by finding id), one line each. */
470
+ function printNewBugs(view, printed) {
471
+ const answer = view.answer;
472
+ if (answer === null)
473
+ return;
474
+ for (const finding of answer.findings) {
475
+ // Keyed by run and attempt too: another run or a retried attempt may reuse a finding id (Astra review).
476
+ const key = `${view.runId}\0${answer.attemptId}\0${finding.id}`;
477
+ if (finding.verdict !== 'bug' || printed.has(key))
478
+ continue;
479
+ printed.add(key);
480
+ console.error(`${chalk.dim(clock())} ${chalk.red(VERDICT_WORDS.bug)}: ${shortSteps(finding)}`);
481
+ }
482
+ }
483
+ /** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
484
+ * 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. */
486
+ async function waitForCrawl(first, identity, token) {
401
487
  const interrupted = () => {
402
488
  console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
403
489
  process.exit(130);
@@ -405,21 +491,27 @@ async function waitForCrawl(first, identity, token, everyMs) {
405
491
  process.once('SIGINT', interrupted);
406
492
  try {
407
493
  let view = first;
494
+ const printed = new Set();
408
495
  progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
409
496
  progress(currentStep(view));
410
- while (!TERMINAL.has(view.status)) {
411
- await delay(everyMs);
412
- let next = await readCrawl(identity, token);
413
- if (next === null)
414
- throw new CrawlResponseError(`The crawl service no longer returns crawl ${view.runId} for this change.`);
415
- // A newer crawl of the same code under another title is another change's: keep following this one.
416
- if (!sameChange(next, identity))
417
- next = await readCrawlRun(view.runId, identity, token);
497
+ printNewBugs(view, printed);
498
+ while (!TERMINAL.has(reportedStatus(view))) {
499
+ // 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
500
+ // 25 s on another title's crawl of the same code).
501
+ let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
502
+ // A newer stop of the same change supersedes this crawl: once it is superseded or being cancelled, the capture's newest crawl
503
+ // is read at once (not held) and followed if it is this same change.
504
+ if (next.supersededBy !== null || next.cancelRequested) {
505
+ const newest = await readCrawl(identity, token);
506
+ if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
507
+ next = newest;
508
+ }
418
509
  if (next.runId !== view.runId)
419
510
  progress(`A newer crawl of this same change took over (${next.runId}).`);
420
- if (!TERMINAL.has(next.status) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
511
+ if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
421
512
  progress(currentStep(next));
422
513
  }
514
+ printNewBugs(next, printed);
423
515
  view = next;
424
516
  }
425
517
  return view;
@@ -428,9 +520,14 @@ async function waitForCrawl(first, identity, token, everyMs) {
428
520
  process.removeListener('SIGINT', interrupted);
429
521
  }
430
522
  }
523
+ /** A found crawl of the same code that is a different crawl (another title, another time): a new one starts, none is run again. */
524
+ const anotherCrawl = (stale) => stale === 'retitled' || stale === 'other-time';
525
+ const timeWords = (budgetMs) => budgetMs === null ? 'the default time' : `${budgetMs / 60_000} min`;
431
526
  function staleCrawl(found, identity) {
432
527
  if (!sameChange(found, identity))
433
528
  return 'retitled';
529
+ if (found.budgetMs !== identity.budgetMs)
530
+ return 'other-time';
434
531
  if (found.status === 'cancelled' || found.status === 'incomplete')
435
532
  return found.status;
436
533
  return !TERMINAL.has(found.status) && found.cancelRequested ? 'cancelling' : null;
@@ -438,6 +535,7 @@ function staleCrawl(found, identity) {
438
535
  function staleWords(found, stale) {
439
536
  switch (stale) {
440
537
  case 'retitled': return `The last crawl of this code ran under another title, "${safe(found.changeTitle)}" (${found.runId})`;
538
+ case 'other-time': return `The last crawl of this code was asked for ${timeWords(found.budgetMs)} (${found.runId})`;
441
539
  case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
442
540
  case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
443
541
  case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
@@ -445,7 +543,8 @@ function staleWords(found, stale) {
445
543
  }
446
544
  /** Submits the capture through the explicit precompute submission when no crawl
447
545
  * of it exists or the one found is stale. Returns the acknowledged run id (null
448
- * when no crawl could be started) and the line to report. */
546
+ * when no crawl could be started), whether the base is being onboarded or its
547
+ * onboarding is blocked, and the line to report. */
449
548
  async function submitCapture(capture, token, found) {
450
549
  let acknowledgment;
451
550
  // A momentary 429/5xx (a server or runner restarting) is asked again, as the stop hook's sender does: admission is
@@ -465,70 +564,129 @@ async function submitCapture(capture, token, found) {
465
564
  const crawl = acknowledgment.crawl;
466
565
  const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
467
566
  if (crawl === undefined) {
468
- return { runId: null, line: `${before}, and the service did not say whether it started ${found === null ? 'one' : 'another'}.` };
567
+ return { runId: null, onboarding: null,
568
+ line: `${before}, and the service did not say whether it started ${found === null ? 'one' : 'another'}.` };
469
569
  }
470
570
  if (crawl.status === 'unavailable') {
471
- return { runId: null, line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
571
+ return { runId: null, onboarding: null,
572
+ line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
573
+ }
574
+ if (crawl.status === 'onboarding') {
575
+ return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded for this base yet: `
576
+ + `the service is preparing it (${crawl.onboardRunId}) and runs the crawl when it is ready.` };
577
+ }
578
+ if (crawl.status === 'onboarding-blocked') {
579
+ return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding for this base is blocked.` };
472
580
  }
473
581
  if (found === null) {
474
- return { runId: crawl.runId, line: crawl.status === 'queued'
582
+ return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
475
583
  ? `No crawl existed for this change yet; started one (${crawl.runId}).`
476
584
  : `The service already had a crawl for this change (${crawl.runId}).` };
477
585
  }
478
- if (found.stale !== 'retitled' && crawl.runId === found.view.runId) {
479
- return { runId: crawl.runId, line: `${before}; asked the service to run it again.` };
586
+ if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
587
+ return { runId: crawl.runId, onboarding: null, line: `${before}; asked the service to run it again.` };
480
588
  }
481
- return { runId: crawl.runId, line: crawl.status === 'queued'
589
+ return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
482
590
  ? `${before}; started one for this change (${crawl.runId}).`
483
591
  : `${before}; the service already had one for this change (${crawl.runId}).` };
484
592
  }
593
+ function checkedChange(baseCommit, workCommit) {
594
+ const gitRoot = findGitRoot();
595
+ if (!gitRoot)
596
+ throw new Error('Run this command from a git repository.');
597
+ const files = execFileSync('git', ['diff', '--name-only', '-z', '--no-renames', baseCommit, workCommit], { cwd: gitRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).split('\0').filter(path => path !== '');
598
+ return { baseCommit, workCommit, files };
599
+ }
600
+ /** One line naming what the crawl checks, so an agent knows which edits it covers. */
601
+ function checkedLine(change) {
602
+ const shown = change.files.slice(0, 5).join(', ');
603
+ const more = change.files.length > 5 ? ` and ${change.files.length - 5} more` : '';
604
+ return `Checking ${change.files.length} changed file${change.files.length === 1 ? '' : 's'} against ${change.baseCommit.slice(0, 12)}`
605
+ + ` (where HEAD meets origin's default branch), committed and uncommitted: ${shown}${more}.`;
606
+ }
607
+ /** Prints the onboarding (and nothing else) as the command's answer: `--json`
608
+ * carries OnboardingStatusV1. A block exits 3: its ask says what to do. */
609
+ function finishWithOnboarding(status, json, change) {
610
+ if (json)
611
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding: status, crawl: null }), null, 2)}\n`);
612
+ else
613
+ console.log(formatOnboarding(status));
614
+ process.exitCode = onboardingExitCode(status);
615
+ }
485
616
  export async function verifyCommand(options) {
486
- const everyMs = intervalMs(options.interval);
487
617
  // In --json mode stdout carries the one document; every other line goes to stderr.
488
618
  const note = (line) => { if (options.json)
489
619
  console.error(line);
490
620
  else
491
621
  console.log(line); };
492
- const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo));
622
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
493
623
  const request = capture.derivation.request;
624
+ const change = checkedChange(request.baseCommit, request.workCommit);
494
625
  if (!request.crawl) {
495
626
  note(`Nothing to verify: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
496
627
  if (options.json)
497
- process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
628
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding: null, crawl: null }), null, 2)}\n`);
498
629
  return;
499
630
  }
631
+ note(checkedLine(change));
632
+ // Measured: a 3-minute crawl answered 13 minutes after the command started (building and preparing both apps).
633
+ note('A crawl often takes 10 to 15 minutes. If this command times out or is stopped, the crawl keeps going:'
634
+ + ' run `haystack verify` again to pick up the same crawl.');
500
635
  const identity = {
501
636
  repository: `${request.owner}/${request.repository}`,
502
637
  baseCommit: request.baseCommit,
503
638
  workCommit: request.workCommit,
504
639
  treeSha: request.crawl.treeSha,
505
640
  changeTitle: request.crawl.changeTitle,
641
+ budgetMs: request.crawl.budgetMs ?? null,
506
642
  };
507
643
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
508
644
  let view = await readCrawl(identity, auth.token);
645
+ let onboarding = null;
509
646
  const stale = view === null ? null : staleCrawl(view, identity);
510
647
  if (view === null || stale !== null) {
511
648
  const found = view === null || stale === null ? null : { view, stale };
512
- const submitted = await submitCapture(capture, auth.token, found);
649
+ let submitted = await submitCapture(capture, auth.token, found);
513
650
  note(submitted.line);
651
+ if (submitted.onboarding !== null) {
652
+ onboarding = await readOnboardingStatus(identity.repository, identity.baseCommit, auth.token);
653
+ reportOnboardingState(onboarding);
654
+ if (options.wait !== false && onboarding.state === 'onboarding') {
655
+ onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack verify');
656
+ reportOnboardingState(onboarding);
657
+ }
658
+ if (onboarding.state !== 'ready') {
659
+ finishWithOnboarding(onboarding, options.json, change);
660
+ return;
661
+ }
662
+ // Ready: the service admitted the waiting crawl when the record published; this
663
+ // submission of the same capture finds it (or starts it, when a newer stop replaced it).
664
+ note('The app is onboarded for this base.');
665
+ submitted = await submitCapture(capture, auth.token, found);
666
+ note(submitted.line);
667
+ }
514
668
  if (submitted.runId === null) {
515
669
  if (options.json)
516
- process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
670
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: null }), null, 2)}\n`);
517
671
  process.exitCode = 1;
518
672
  return;
519
673
  }
520
674
  view = await readCrawlRun(submitted.runId, identity, auth.token);
521
- if (found !== null && found.stale !== 'retitled' && view.runId === found.view.runId && TERMINAL.has(view.status)) {
675
+ if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
522
676
  note('The service did not run it again; its last result follows.');
523
677
  }
524
678
  }
525
- if (options.wait !== false && !TERMINAL.has(view.status)) {
526
- view = await waitForCrawl(view, identity, auth.token, everyMs);
679
+ if (options.wait !== false && !TERMINAL.has(reportedStatus(view))) {
680
+ view = await waitForCrawl(view, identity, auth.token);
527
681
  }
528
682
  if (options.json)
529
- process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: view }), null, 2)}\n`);
530
- else
683
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: view }), null, 2)}\n`);
684
+ else {
531
685
  console.log(formatCrawl(view));
686
+ const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? [])];
687
+ if (notes.length)
688
+ console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
689
+ }
532
690
  const exitCode = crawlExitCode(view);
533
691
  if (exitCode !== 0)
534
692
  process.exitCode = exitCode;