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