@haystackeditor/cli 0.17.1 → 0.19.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,64 +1,620 @@
1
- import { execFileSync } from 'node:child_process';
2
- import { parseHostedRepository, verifyHostedStartCommand } from './verify-hosted.js';
3
- function git(args, message) {
1
+ /**
2
+ * `haystack verify` — the crawl of the current change (CRAWL-V1, amendment 1).
3
+ *
4
+ * It captures the checkout (committed and uncommitted) exactly as the stop
5
+ * hook does, finds the caller's crawl for that exact capture (its code and its
6
+ * change title, which is part of a crawl's identity and informs the judge), and
7
+ * submits the capture through the same front-half request `haystack verify
8
+ * precompute` sends when there is none, or when the one found ended cancelled
9
+ * or incomplete or is being cancelled (the service runs it again). It follows
10
+ * the acknowledged run by its id. It then waits (no wall-time cap: Ctrl-C
11
+ * stops waiting, never the crawl), each read held by the service until the
12
+ * crawl changes (amendment 9), printing each bug as the crawl's answers bring
13
+ * it; it stops at the crawl's answer at its budget, or when the crawl ends, and
14
+ * prints where the change shows up in the running app and what the crawl found.
15
+ * The hosted fleet run of pushed commits is `haystack verify hosted start|status`.
16
+ */
17
+ import chalk from 'chalk';
18
+ import { withSchema } from '../schema.js';
19
+ import { resolveAuthContext } from '../utils/auth.js';
20
+ import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
21
+ import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
22
+ import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
23
+ import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
24
+ const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
25
+ /** A held read (waitAfter): the service's hold, then the time any read gets. */
26
+ const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
27
+ const RUN_ID = /^cv_[0-9a-f]{48}$/;
28
+ const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
29
+ const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
30
+ const TERMINAL = new Set(['completed', 'incomplete', 'cancelled']);
31
+ const STAGES = ['reconcile', 'source', 'worlds', 'blast', 'knobs', 'prepare', 'crawl', 'publish', 'cleanup'];
32
+ const ERROR_CODES = ['source-mismatch', 'blast-unavailable', 'world-build-failed',
33
+ 'prepare-failed', 'crawl-failed', 'no-capacity', 'stalled', 'lease-lost', 'cancelled'];
34
+ const VERDICTS = ['bug', 'cant-tell', 'being-judged', 'not-a-bug'];
35
+ const SPOT_STAGES = ['resolved', 'loaded', 'exposed', 'activated', 'pressed', 'observed', 'confirmed', 'judged'];
36
+ const REUSE = ['hit', 'miss', null];
37
+ const STATUS_WORDS = {
38
+ queued: 'waiting for a machine',
39
+ preparing: 'getting ready',
40
+ crawling: 'exploring the app',
41
+ draining: 'finishing the last checks',
42
+ completed: 'finished',
43
+ incomplete: 'stopped before finishing',
44
+ cancelled: 'cancelled',
45
+ };
46
+ const STAGE_WORDS = {
47
+ reconcile: 'cleaning up after an earlier try',
48
+ source: 'rebuilding your change',
49
+ worlds: 'building and starting the app with and without your change',
50
+ blast: 'waiting for the analysis of what your change touches',
51
+ knobs: "reading the app's settings",
52
+ prepare: 'setting up starting points next to your change',
53
+ crawl: 'exploring the app',
54
+ publish: 'saving the results',
55
+ cleanup: 'shutting down the machines it used',
56
+ };
57
+ const ERROR_WORDS = {
58
+ 'source-mismatch': 'your change could not be rebuilt exactly as it was captured',
59
+ 'blast-unavailable': 'the analysis of what your change touches was not available',
60
+ 'world-build-failed': 'the app did not build or start, with or without your change',
61
+ 'prepare-failed': 'setting up starting points next to your change failed',
62
+ 'crawl-failed': 'exploring the app failed',
63
+ 'no-capacity': 'there was no room on the machines for it',
64
+ stalled: 'it stopped making progress',
65
+ 'lease-lost': 'the machine running it stopped responding',
66
+ cancelled: 'it was cancelled',
67
+ };
68
+ const SPOT_WORDS = {
69
+ resolved: 'never reached',
70
+ loaded: 'loaded but never ran',
71
+ exposed: 'shown on screen',
72
+ activated: 'ran',
73
+ pressed: 'ran when its control was pressed',
74
+ observed: 'a difference seen',
75
+ confirmed: 'a difference seen and confirmed',
76
+ judged: 'judged a bug',
77
+ };
78
+ const VERDICT_WORDS = {
79
+ bug: 'Bug',
80
+ 'cant-tell': "Can't tell",
81
+ 'being-judged': 'Still being judged',
82
+ 'not-a-bug': 'Not a bug',
83
+ };
84
+ /** A response the CLI will not print: malformed, contradictory, or another revision's. */
85
+ export class CrawlResponseError extends Error {
86
+ constructor(message) {
87
+ super(message);
88
+ this.name = 'CrawlResponseError';
89
+ }
90
+ }
91
+ function isRecord(value) {
92
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
93
+ }
94
+ function isCount(value) {
95
+ return Number.isSafeInteger(value) && value >= 0;
96
+ }
97
+ function isTimestamp(value) {
98
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
99
+ }
100
+ function isMember(value, members) {
101
+ return members.includes(value);
102
+ }
103
+ function isStrings(value, maximum = Infinity) {
104
+ return Array.isArray(value) && value.length <= maximum && value.every(item => typeof item === 'string');
105
+ }
106
+ function invalid(what) {
107
+ throw new CrawlResponseError(`The crawl service returned an invalid crawl (${what}); nothing is shown.`);
108
+ }
109
+ function checkError(value, what) {
110
+ if (!isRecord(value) || !isMember(value.code, ERROR_CODES) || !isMember(value.stage, STAGES)
111
+ || typeof value.message !== 'string')
112
+ invalid(what);
113
+ }
114
+ function checkReuse(value, what) {
115
+ if (!isRecord(value) || !['baseWorld', 'headWorld', 'prepare', 'routes']
116
+ .every(key => isMember(value[key], REUSE)))
117
+ invalid(what);
118
+ }
119
+ function checkFinding(value) {
120
+ if (!isRecord(value) || typeof value.id !== 'string' || !isMember(value.verdict, VERDICTS)
121
+ || (value.why !== null && typeof value.why !== 'string') || typeof value.start !== 'string'
122
+ || !isStrings(value.steps, CRAWL_MAX_FINDING_STEPS) || !isRecord(value.observed) || !isRecord(value.codeThatRan)
123
+ || typeof value.confirmedAtSeconds !== 'number' || !Number.isFinite(value.confirmedAtSeconds)
124
+ || !isRecord(value.images)
125
+ || (value.images.old !== null && typeof value.images.old !== 'string')
126
+ || (value.images.new !== null && typeof value.images.new !== 'string'))
127
+ invalid('a finding');
128
+ }
129
+ function checkSpot(value) {
130
+ if (!isRecord(value) || typeof value.id !== 'string' || typeof value.file !== 'string'
131
+ || !Number.isSafeInteger(value.line) || value.line < 1
132
+ || (value.furthest !== null && !isMember(value.furthest, SPOT_STAGES))
133
+ || !isStrings(value.shows) || !isStrings(value.findingIds))
134
+ invalid('a changed spot');
135
+ const reached = value.reachedBy;
136
+ if (reached !== null && (!isRecord(reached) || !isMember(reached.stage, SPOT_STAGES)
137
+ || typeof reached.start !== 'string' || !isStrings(reached.steps)
138
+ || (reached.page !== null && typeof reached.page !== 'string')))
139
+ invalid('how a changed spot was reached');
140
+ }
141
+ /** The published results' shape, shared by the sealed manifest and (amendment 9) an answer; `what` names which. */
142
+ function checkResults(value, view, what) {
143
+ if (!isRecord(value) || value.schemaVersion !== 1)
144
+ invalid(what);
145
+ // Published results belong to exactly this crawl and this revision.
146
+ if (value.runId !== view.runId || value.repository !== view.repository || value.baseCommit !== view.baseCommit
147
+ || value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle) {
148
+ invalid(`${what} from a different crawl`);
149
+ }
150
+ if (typeof value.attemptId !== 'string' || !isCount(value.bugs) || !isCount(value.notFinishedInTime))
151
+ invalid(what);
152
+ if (value.worlds !== null && (!isRecord(value.worlds) || typeof value.worlds.base !== 'string'
153
+ || typeof value.worlds.head !== 'string'))
154
+ invalid(what);
155
+ checkReuse(value.reuse, what);
156
+ if (!isRecord(value.timings) || !Object.values(value.timings)
157
+ .every(ms => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0))
158
+ invalid('its stage timings');
159
+ if (!Array.isArray(value.findings) || value.findings.length > CRAWL_MAX_FINDINGS)
160
+ invalid('its findings');
161
+ for (const finding of value.findings)
162
+ checkFinding(finding);
163
+ const reach = value.reach;
164
+ if (!isRecord(reach) || !Array.isArray(reach.spots) || !isStrings(reach.unreachedChangedFiles))
165
+ invalid('its reach');
166
+ for (const spot of reach.spots)
167
+ checkSpot(spot);
168
+ const blast = reach.blast;
169
+ if (blast !== null && (!isRecord(blast) || !isCount(blast.functions) || !isCount(blast.ran)
170
+ || !isCount(blast.unreached) || !isStrings(blast.nearestUnreached)))
171
+ invalid('its affected functions');
172
+ if (value.error !== null)
173
+ checkError(value.error, 'its error');
174
+ }
175
+ function checkManifest(value, view) {
176
+ checkResults(value, view, 'its results');
177
+ if (value.status !== view.status)
178
+ invalid('its results from a different crawl');
179
+ }
180
+ /** Amendment 9: what the crawl found so far, or its answer at the budget: the manifest's shape, of this same crawl. */
181
+ function checkAnswer(value, view) {
182
+ checkResults(value, view, 'its answer');
183
+ if ((value.status !== 'exploring' && value.status !== 'answered') || !Number.isSafeInteger(value.n) || value.n < 1
184
+ || !isCount(value.atSeconds))
185
+ invalid('its answer');
186
+ }
187
+ /** Accepts only a well-formed crawl of exactly this capture's revision. Another
188
+ * revision's crawl is refused, never shown. The change title is not checked
189
+ * here: the newest crawl of a revision may carry another title, which the
190
+ * caller compares (sameChange). */
191
+ export function parseCrawlView(value, expected) {
192
+ if (!isRecord(value) || typeof value.runId !== 'string' || !RUN_ID.test(value.runId))
193
+ invalid('its run id');
194
+ // GitHub repository names are case-insensitive; commits and trees are exact.
195
+ if (typeof value.repository !== 'string'
196
+ || value.repository.toLowerCase() !== expected.repository.toLowerCase()
197
+ || value.baseCommit !== expected.baseCommit || value.workCommit !== expected.workCommit
198
+ || value.treeSha !== expected.treeSha) {
199
+ throw new CrawlResponseError('The crawl service returned a crawl of a different revision; it is not shown.');
200
+ }
201
+ if (!isMember(value.status, STATUSES) || !isMember(value.stage, STAGES)
202
+ || typeof value.changeTitle !== 'string' || !isCount(value.observedAt)
203
+ || !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt)
204
+ || (value.supersededBy !== null && (typeof value.supersededBy !== 'string' || !RUN_ID.test(value.supersededBy)))
205
+ || typeof value.cancelRequested !== 'boolean')
206
+ invalid('its status');
207
+ const clocks = value.clocks;
208
+ if (!isRecord(clocks) || !['startedAt', 'stageStartedAt', 'crawlStartedAt', 'finishedAt']
209
+ .every(key => clocks[key] === null || isTimestamp(clocks[key])) || !isCount(clocks.crawlBudgetMs))
210
+ invalid('its clocks');
211
+ checkReuse(value.reuse, 'what it reused');
212
+ if (!isRecord(value.worlds) || (value.worlds.base !== null && typeof value.worlds.base !== 'string')
213
+ || (value.worlds.head !== null && typeof value.worlds.head !== 'string'))
214
+ invalid('its app builds');
215
+ const totals = value.totals;
216
+ if (!isRecord(totals) || !['clicks', 'differing', 'confirmed', 'bugs', 'vmsLaunched', 'cleanupProven', 'cleanupUnproven']
217
+ .every(key => isCount(totals[key])))
218
+ invalid('its totals');
219
+ if (value.error !== undefined)
220
+ checkError(value.error, 'its error');
221
+ if (value.manifest !== null) {
222
+ if (!TERMINAL.has(value.status))
223
+ invalid('results before it finished');
224
+ checkManifest(value.manifest, value);
225
+ }
226
+ // Amendment 8: a service from before it sends no budgetMs, and every crawl it ran was asked for the default (Astra review).
227
+ if (value.budgetMs !== undefined && value.budgetMs !== null && (typeof value.budgetMs !== 'number' || !Number.isInteger(value.budgetMs)
228
+ || value.budgetMs < CRAWL_BUDGET_MIN_MS || value.budgetMs > CRAWL_BUDGET_MAX_MS))
229
+ invalid('its time');
230
+ // Amendment 9: a service from before it sends no answer. An answer stands in for results not yet sealed, never beside them.
231
+ let answer = null;
232
+ if (value.answer !== undefined && value.answer !== null) {
233
+ if (value.manifest !== null)
234
+ invalid('an answer beside its sealed results');
235
+ checkAnswer(value.answer, value);
236
+ answer = value.answer;
237
+ }
238
+ return { ...value, budgetMs: value.budgetMs ?? null, answer };
239
+ }
240
+ /* ------------------------------------------------------------------- read */
241
+ /** The change title is part of a crawl's identity and informs its judge: a
242
+ * crawl of the same code under another title is not this change's crawl. */
243
+ function sameChange(view, identity) {
244
+ return view.changeTitle === identity.changeTitle;
245
+ }
246
+ async function responseBody(response) {
4
247
  try {
5
- return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
248
+ return await response.json();
6
249
  }
7
- catch {
8
- throw new Error(message);
250
+ catch (error) {
251
+ throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
252
+ }
253
+ }
254
+ /** A read, held by the service when `waitAfter` (the updatedAt the caller holds) is given: until the
255
+ * crawl's updatedAt changes, or CRAWL_WAIT_MAX_MS passes (amendment 9). */
256
+ function read(path, token, waitAfter) {
257
+ return waitAfter === undefined
258
+ ? gatewayFetch(path, token)
259
+ : gatewayFetch(`${path}&waitAfter=${encodeURIComponent(waitAfter)}`, token, { method: 'GET', timeoutMs: HELD_READ_TIMEOUT_MS });
260
+ }
261
+ /** The caller's newest crawl of this capture's revision, or null. Its change
262
+ * title may differ from the capture's (sameChange). */
263
+ async function readCrawl(identity, token, waitAfter) {
264
+ const path = `${CRAWLS_PATH}?repository=${encodeURIComponent(identity.repository)}`
265
+ + `&workCommit=${encodeURIComponent(identity.workCommit)}`;
266
+ const response = await read(path, token, waitAfter);
267
+ if (!response.ok)
268
+ throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}`);
269
+ const body = await responseBody(response);
270
+ if (!isRecord(body) || !Object.hasOwn(body, 'crawl'))
271
+ invalid('no crawl field');
272
+ return body.crawl === null ? null : parseCrawlView(body.crawl, identity);
273
+ }
274
+ /** One crawl by its run id; it must be exactly this capture, change title included. */
275
+ async function readCrawlRun(runId, identity, token, waitAfter) {
276
+ if (!RUN_ID.test(runId))
277
+ invalid('an acknowledged run id');
278
+ const response = await read(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token, waitAfter);
279
+ if (!response.ok)
280
+ throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}/${runId}`);
281
+ const view = parseCrawlView(await responseBody(response), identity);
282
+ if (view.runId !== runId || !sameChange(view, identity)) {
283
+ throw new CrawlResponseError(`The crawl service returned another crawl for ${runId}; it is not shown.`);
284
+ }
285
+ return view;
286
+ }
287
+ /* ----------------------------------------------------------------- output */
288
+ function safe(value) {
289
+ // Server text never moves the cursor or rewrites the terminal.
290
+ // eslint-disable-next-line no-control-regex -- replacing control characters is the point
291
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
292
+ }
293
+ function plural(count, noun) {
294
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
295
+ }
296
+ function errorWords(error) {
297
+ return error.code === 'stalled'
298
+ ? `${ERROR_WORDS.stalled} while ${STAGE_WORDS[error.stage]}`
299
+ : ERROR_WORDS[error.code];
300
+ }
301
+ /** What a running crawl is doing now: its stage, except while it waits for a
302
+ * machine or finishes its last checks, which its status says better. */
303
+ function currentStep(view) {
304
+ return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
305
+ }
306
+ /** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
307
+ function results(view) {
308
+ return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
309
+ }
310
+ /** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
311
+ * done; only its shutdown remains) reads as completed. */
312
+ function reportedStatus(view) {
313
+ return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
314
+ }
315
+ function headline(view) {
316
+ const bugs = results(view)?.bugs ?? view.totals.bugs;
317
+ const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
318
+ switch (reportedStatus(view)) {
319
+ case 'completed':
320
+ return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
321
+ case 'incomplete': {
322
+ const error = results(view)?.error ?? view.error;
323
+ return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
324
+ }
325
+ case 'cancelled':
326
+ return `The crawl was cancelled${view.supersededBy ? ': a newer stop in this repository replaced it' : ''}.${found}`;
327
+ default: {
328
+ const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
329
+ if (view.cancelRequested) {
330
+ return `Being cancelled${view.supersededBy ? ' (a newer stop in this repository replaced it)' : ''}: `
331
+ + `${currentStep(view)}.${so}`;
332
+ }
333
+ return `Still running: ${currentStep(view)}.${so}`;
334
+ }
9
335
  }
10
336
  }
11
- function originRepository() {
12
- const remote = git(['remote', 'get-url', 'origin'], 'Could not read origin; pass --repo owner/repo.');
13
- let path;
337
+ function spotLines(spot) {
338
+ const how = spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest];
339
+ const lines = [` ${chalk.bold(`${safe(spot.file)}:${spot.line}`)} ${how}`];
340
+ if (spot.reachedBy) {
341
+ lines.push(` reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].map(safe).join(' > ')}`);
342
+ if (spot.reachedBy.page !== null)
343
+ lines.push(` page: ${safe(spot.reachedBy.page)}`);
344
+ }
345
+ return lines;
346
+ }
347
+ function findingLines(finding, position) {
348
+ const verdict = VERDICT_WORDS[finding.verdict];
349
+ const label = finding.verdict === 'bug' ? chalk.red(verdict) : verdict;
350
+ const lines = [` ${position}. ${label}: ${finding.why === null ? 'no reason was recorded.' : safe(finding.why)}`,
351
+ ` Starting from ${safe(finding.start)}:`];
352
+ finding.steps.forEach((step, index) => lines.push(` ${index + 1}. ${safe(step)}`));
353
+ return lines;
354
+ }
355
+ export function formatCrawl(view) {
356
+ const status = reportedStatus(view);
357
+ const lines = [chalk.bold(headline(view))];
358
+ const published = results(view);
359
+ const error = published?.error ?? view.error;
360
+ if (error && status !== 'completed')
361
+ lines.push(chalk.dim(` Details: ${safe(error.message)}`));
362
+ lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(` Time: ${view.clocks.crawlBudgetMs > 0 ? `${Math.round(view.clocks.crawlBudgetMs / 1000)} s` : timeWords(view.budgetMs)}`
363
+ + `${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)},`
364
+ + ` your change ${view.workCommit.slice(0, 12)}.`));
365
+ if (published === null) {
366
+ lines.push('', TERMINAL.has(status)
367
+ ? 'No results were published for this crawl.'
368
+ : 'Where your change shows up in the app, and what the crawl found, appear when it finishes.');
369
+ return lines.join('\n');
370
+ }
371
+ // A crawl that stopped before exploring measured nothing: its empty reach and findings are not "nothing found".
372
+ if (view.clocks.crawlStartedAt === null) {
373
+ lines.push('', 'It stopped before exploring the app, so nothing about your change was measured.');
374
+ return lines.join('\n');
375
+ }
376
+ lines.push('', chalk.bold('Where your change shows up in the app'));
377
+ if (published.reach.spots.length === 0)
378
+ lines.push(' No changed spots were recorded.');
379
+ for (const spot of published.reach.spots)
380
+ lines.push(...spotLines(spot));
381
+ lines.push('', chalk.bold('What the crawl found'));
382
+ // Bugs first, then the undecided, then what the judge called intended.
383
+ const findings = VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict));
384
+ if (findings.length === 0)
385
+ lines.push(' No confirmed differences between the app with and without your change.');
386
+ findings.forEach((finding, index) => lines.push(...findingLines(finding, index + 1)));
387
+ if (published.notFinishedInTime > 0) {
388
+ lines.push(` ${plural(published.notFinishedInTime, 'difference')} ${published.notFinishedInTime === 1 ? 'was' : 'were'}`
389
+ + ' still being checked when its time ran out.');
390
+ }
391
+ lines.push('', chalk.bold('What the crawl never ran'));
392
+ if (published.reach.unreachedChangedFiles.length === 0)
393
+ lines.push(' Every changed file ran at least once.');
394
+ else {
395
+ lines.push(' Changed files no path ran:');
396
+ for (const file of published.reach.unreachedChangedFiles)
397
+ lines.push(` ${safe(file)}`);
398
+ }
399
+ const blast = published.reach.blast;
400
+ if (blast !== null)
401
+ lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
402
+ // Only a finished crawl has proven its cleanup: an answered one is still shutting its machines down (Bugbot).
403
+ if (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0) {
404
+ lines.push('', chalk.yellow(` ${plural(view.totals.cleanupUnproven, 'machine')} it used could not be proven shut down.`));
405
+ }
406
+ return lines.join('\n');
407
+ }
408
+ /** Case-batch status conventions: 0 finished (or answered: amendment 9, its work
409
+ * is done and only its shutdown remains), or still running with --no-wait;
410
+ * 2 ended without finishing (incomplete, cancelled) or finished with cleanup
411
+ * unproven (an answered crawl has not finished its cleanup, so it is not judged); bugs
412
+ * found never change it. The command's own failures exit 1 elsewhere. */
413
+ export function crawlExitCode(view) {
414
+ const status = reportedStatus(view);
415
+ if (!TERMINAL.has(status))
416
+ return 0;
417
+ if (status !== 'completed' || (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0))
418
+ return 2;
419
+ return 0;
420
+ }
421
+ /* ---------------------------------------------------------------- command */
422
+ /** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
423
+ function minutesOption(value) {
424
+ if (value === undefined)
425
+ return undefined;
426
+ const minutes = Number(value);
427
+ if (!Number.isInteger(minutes) || minutes * 60_000 < CRAWL_BUDGET_MIN_MS || minutes * 60_000 > CRAWL_BUDGET_MAX_MS) {
428
+ throw new Error(`--minutes must be a whole number from ${CRAWL_BUDGET_MIN_MS / 60_000} to ${CRAWL_BUDGET_MAX_MS / 60_000}.`);
429
+ }
430
+ return minutes;
431
+ }
432
+ function repositoryOverride(value) {
433
+ if (value === undefined)
434
+ return undefined;
435
+ const parts = value.split('/');
436
+ if (parts.length !== 2 || !parts.every(part => REPOSITORY_COMPONENT.test(part) && part !== '.' && part !== '..')) {
437
+ throw new Error(`Invalid repository "${value}". Use an exact GitHub owner/repository name.`);
438
+ }
439
+ return { owner: parts[0], repository: parts[1] };
440
+ }
441
+ function clock() {
442
+ return new Date().toTimeString().slice(0, 8);
443
+ }
444
+ function progress(line) {
445
+ console.error(chalk.dim(`${clock()} ${line}`));
446
+ }
447
+ function clip(text, maximum = 60) {
448
+ return text.length <= maximum ? text : `${text.slice(0, maximum - 1)}…`;
449
+ }
450
+ /** A finding's steps in one line: where it starts, then its steps, the middle of a long path elided. */
451
+ function shortSteps(finding) {
452
+ const steps = finding.steps.map(step => clip(safe(step)));
453
+ const shown = steps.length <= 4 ? steps : [steps[0], `(${steps.length - 3} more steps)`, ...steps.slice(-2)];
454
+ return [`from ${clip(safe(finding.start))}`, ...shown].join(' > ');
455
+ }
456
+ /** Prints each bug the view's answer brings that was not printed before (by finding id), one line each. */
457
+ function printNewBugs(view, printed) {
458
+ const answer = view.answer;
459
+ if (answer === null)
460
+ return;
461
+ for (const finding of answer.findings) {
462
+ // Keyed by run and attempt too: another run or a retried attempt may reuse a finding id (Astra review).
463
+ const key = `${view.runId}\0${answer.attemptId}\0${finding.id}`;
464
+ if (finding.verdict !== 'bug' || printed.has(key))
465
+ continue;
466
+ printed.add(key);
467
+ console.error(`${chalk.dim(clock())} ${chalk.red(VERDICT_WORDS.bug)}: ${shortSteps(finding)}`);
468
+ }
469
+ }
470
+ /** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
471
+ * service until the crawl changes, so there is no pause between reads. No wall-time cap: an interrupt stops waiting
472
+ * and leaves the crawl running. */
473
+ async function waitForCrawl(first, identity, token) {
474
+ const interrupted = () => {
475
+ console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
476
+ process.exit(130);
477
+ };
478
+ process.once('SIGINT', interrupted);
14
479
  try {
15
- path = remote.includes('://')
16
- ? new URL(remote).pathname.replace(/^\//, '')
17
- : remote.match(/^[^@/]+@[^:]+:(.+)$/)?.[1] ?? '';
18
- return parseHostedRepository(path.replace(/\.git$/, '')).fullName;
19
- }
20
- catch {
21
- throw new Error('Could not determine owner/repo from origin; pass --repo owner/repo.');
22
- }
23
- }
24
- export function resolveVerifyRepository(options) {
25
- const repo = options.repo ?? originRepository();
26
- let head = options.head;
27
- if (head === undefined) {
28
- head = git(['rev-parse', '--verify', 'HEAD^{commit}'], 'Could not read HEAD; run inside a git checkout or pass --head.');
29
- // The fleet fetches from the GitHub origin, so only origin's branches
30
- // prove the commit is reachable there; another remote does not.
31
- const originBranches = git(['for-each-ref', '--format=%(refname)', '--contains', head, 'refs/remotes/origin/'], 'Could not check origin branches for HEAD.');
32
- if (!originBranches)
33
- throw new Error('Push HEAD to origin before running haystack verify.');
34
- }
35
- let base = options.base;
36
- if (base === undefined) {
37
- let defaultBranch = 'origin/main';
480
+ let view = first;
481
+ const printed = new Set();
482
+ progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
483
+ progress(currentStep(view));
484
+ printNewBugs(view, printed);
485
+ while (!TERMINAL.has(reportedStatus(view))) {
486
+ // 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
487
+ // 25 s on another title's crawl of the same code).
488
+ let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
489
+ // A newer stop of the same change supersedes this crawl: once it is superseded or being cancelled, the capture's newest crawl
490
+ // is read at once (not held) and followed if it is this same change.
491
+ if (next.supersededBy !== null || next.cancelRequested) {
492
+ const newest = await readCrawl(identity, token);
493
+ if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
494
+ next = newest;
495
+ }
496
+ if (next.runId !== view.runId)
497
+ progress(`A newer crawl of this same change took over (${next.runId}).`);
498
+ if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
499
+ progress(currentStep(next));
500
+ }
501
+ printNewBugs(next, printed);
502
+ view = next;
503
+ }
504
+ return view;
505
+ }
506
+ finally {
507
+ process.removeListener('SIGINT', interrupted);
508
+ }
509
+ }
510
+ /** A found crawl of the same code that is a different crawl (another title, another time): a new one starts, none is run again. */
511
+ const anotherCrawl = (stale) => stale === 'retitled' || stale === 'other-time';
512
+ const timeWords = (budgetMs) => budgetMs === null ? 'the default time' : `${budgetMs / 60_000} min`;
513
+ function staleCrawl(found, identity) {
514
+ if (!sameChange(found, identity))
515
+ return 'retitled';
516
+ if (found.budgetMs !== identity.budgetMs)
517
+ return 'other-time';
518
+ if (found.status === 'cancelled' || found.status === 'incomplete')
519
+ return found.status;
520
+ return !TERMINAL.has(found.status) && found.cancelRequested ? 'cancelling' : null;
521
+ }
522
+ function staleWords(found, stale) {
523
+ switch (stale) {
524
+ case 'retitled': return `The last crawl of this code ran under another title, "${safe(found.changeTitle)}" (${found.runId})`;
525
+ case 'other-time': return `The last crawl of this code was asked for ${timeWords(found.budgetMs)} (${found.runId})`;
526
+ case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
527
+ case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
528
+ case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
529
+ }
530
+ }
531
+ /** Submits the capture through the explicit precompute submission when no crawl
532
+ * of it exists or the one found is stale. Returns the acknowledged run id (null
533
+ * when no crawl could be started) and the line to report. */
534
+ async function submitCapture(capture, token, found) {
535
+ let acknowledgment;
536
+ // A momentary 429/5xx (a server or runner restarting) is asked again, as the stop hook's sender does: admission is
537
+ // idempotent for one capture, so a repeat can only find what the first created.
538
+ const pauses = [2_000, 5_000, 10_000];
539
+ for (let attempt = 0; acknowledgment === undefined; attempt += 1) {
38
540
  try {
39
- defaultBranch = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], 'Could not read origin/HEAD.');
541
+ acknowledgment = await postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS);
40
542
  }
41
- catch {
42
- // Repositories without origin/HEAD use origin/main.
543
+ catch (error) {
544
+ const transient = error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status);
545
+ if (!transient || attempt >= pauses.length)
546
+ throw normalModeFailure(error);
547
+ await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
43
548
  }
44
- // The base belongs to the head being verified, which may be an explicit
45
- // --head rather than the working tree.
46
- base = git(['merge-base', head, defaultBranch], `Could not find the merge base of ${head} with ${defaultBranch}; fetch origin or pass --base.`);
47
549
  }
48
- return { repo, base, head };
550
+ const crawl = acknowledgment.crawl;
551
+ const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
552
+ if (crawl === undefined) {
553
+ return { runId: null, line: `${before}, and the service did not say whether it started ${found === null ? 'one' : 'another'}.` };
554
+ }
555
+ if (crawl.status === 'unavailable') {
556
+ return { runId: null, line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
557
+ }
558
+ if (found === null) {
559
+ return { runId: crawl.runId, line: crawl.status === 'queued'
560
+ ? `No crawl existed for this change yet; started one (${crawl.runId}).`
561
+ : `The service already had a crawl for this change (${crawl.runId}).` };
562
+ }
563
+ if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
564
+ return { runId: crawl.runId, line: `${before}; asked the service to run it again.` };
565
+ }
566
+ return { runId: crawl.runId, line: crawl.status === 'queued'
567
+ ? `${before}; started one for this change (${crawl.runId}).`
568
+ : `${before}; the service already had one for this change (${crawl.runId}).` };
49
569
  }
50
570
  export async function verifyCommand(options) {
51
- const { repo, base, head } = resolveVerifyRepository(options);
52
- await verifyHostedStartCommand(repo, {
53
- provider: 'haystack',
54
- base,
55
- head,
56
- intentFile: options.intentFile,
57
- idempotencyKey: options.idempotencyKey,
58
- account: options.account,
59
- wait: options.wait,
60
- interval: options.interval,
61
- timeout: options.timeout,
62
- json: options.json,
63
- });
571
+ // In --json mode stdout carries the one document; every other line goes to stderr.
572
+ const note = (line) => { if (options.json)
573
+ console.error(line);
574
+ else
575
+ console.log(line); };
576
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
577
+ const request = capture.derivation.request;
578
+ if (!request.crawl) {
579
+ note(`Nothing to verify: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
580
+ if (options.json)
581
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
582
+ return;
583
+ }
584
+ const identity = {
585
+ repository: `${request.owner}/${request.repository}`,
586
+ baseCommit: request.baseCommit,
587
+ workCommit: request.workCommit,
588
+ treeSha: request.crawl.treeSha,
589
+ changeTitle: request.crawl.changeTitle,
590
+ budgetMs: request.crawl.budgetMs ?? null,
591
+ };
592
+ const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
593
+ let view = await readCrawl(identity, auth.token);
594
+ const stale = view === null ? null : staleCrawl(view, identity);
595
+ if (view === null || stale !== null) {
596
+ const found = view === null || stale === null ? null : { view, stale };
597
+ const submitted = await submitCapture(capture, auth.token, found);
598
+ note(submitted.line);
599
+ if (submitted.runId === null) {
600
+ if (options.json)
601
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
602
+ process.exitCode = 1;
603
+ return;
604
+ }
605
+ view = await readCrawlRun(submitted.runId, identity, auth.token);
606
+ if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
607
+ note('The service did not run it again; its last result follows.');
608
+ }
609
+ }
610
+ if (options.wait !== false && !TERMINAL.has(reportedStatus(view))) {
611
+ view = await waitForCrawl(view, identity, auth.token);
612
+ }
613
+ if (options.json)
614
+ process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: view }), null, 2)}\n`);
615
+ else
616
+ console.log(formatCrawl(view));
617
+ const exitCode = crawlExitCode(view);
618
+ if (exitCode !== 0)
619
+ process.exitCode = exitCode;
64
620
  }