@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.
- package/README.md +52 -45
- package/dist/commands/case-batch.js +3 -1
- package/dist/commands/crawl-contract.js +6 -0
- package/dist/commands/fleet-policy.js +1 -22
- package/dist/commands/init.js +376 -129
- package/dist/commands/install-session-hooks.js +27 -10
- package/dist/commands/login.js +133 -34
- package/dist/commands/onboarding-contract.js +6 -0
- package/dist/commands/precompute-delivery.js +6 -3
- package/dist/commands/setup.js +1 -2
- package/dist/commands/verify-onboarding.js +365 -0
- package/dist/commands/verify-precompute.js +48 -12
- package/dist/commands/verify.js +242 -84
- package/dist/index.js +108 -30
- package/dist/schema.js +5 -1
- package/dist/types.js +3 -0
- package/dist/utils/config.js +0 -16
- package/dist/utils/haystack-api.js +12 -1
- package/dist/utils/secrets.js +0 -51
- package/dist/utils/telemetry.js +32 -0
- package/package.json +2 -2
- package/schemas/init.v1.json +35 -0
- package/schemas/login.v1.json +17 -0
- package/schemas/verify-answer.v1.json +86 -0
- package/schemas/verify-onboarding.v1.json +13 -0
- package/schemas/verify.v1.json +296 -8
- package/dist/utils/detect.js +0 -507
package/dist/commands/verify.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `haystack verify` — the crawl of the current change (CRAWL-V1,
|
|
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)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
24
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
142
|
-
//
|
|
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
|
-
|
|
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(
|
|
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(
|
|
153
|
-
checkReuse(value.reuse,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
316
|
-
|
|
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(`
|
|
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
|
-
|
|
321
|
-
|
|
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 (
|
|
390
|
+
if (published.reach.spots.length === 0)
|
|
334
391
|
lines.push(' No changed spots were recorded.');
|
|
335
|
-
for (const spot of
|
|
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 =>
|
|
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 (
|
|
344
|
-
lines.push(` ${plural(
|
|
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 (
|
|
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
|
|
409
|
+
for (const file of published.reach.unreachedChangedFiles)
|
|
353
410
|
lines.push(` ${safe(file)}`);
|
|
354
411
|
}
|
|
355
|
-
const 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
|
-
|
|
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
|
|
364
|
-
*
|
|
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
|
-
|
|
427
|
+
const status = reportedStatus(view);
|
|
428
|
+
if (!TERMINAL.has(status))
|
|
368
429
|
return 0;
|
|
369
|
-
if (
|
|
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
|
-
|
|
435
|
+
/** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
|
|
436
|
+
function minutesOption(value) {
|
|
375
437
|
if (value === undefined)
|
|
376
|
-
return
|
|
377
|
-
const
|
|
378
|
-
if (!Number.
|
|
379
|
-
throw new Error(`--
|
|
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
|
|
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
|
|
393
|
-
return new
|
|
454
|
+
function clock() {
|
|
455
|
+
return new Date().toTimeString().slice(0, 8);
|
|
394
456
|
}
|
|
395
457
|
function progress(line) {
|
|
396
|
-
console.error(chalk.dim(`${
|
|
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
|
-
/**
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
// A newer
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
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)
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
526
|
-
view = await waitForCrawl(view, identity, auth.token
|
|
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;
|