@haystackeditor/cli 0.19.0 → 0.21.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 +38 -35
- 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/setup.js +1 -2
- package/dist/commands/verify-onboarding.js +365 -0
- package/dist/commands/verify-precompute.js +37 -14
- package/dist/commands/verify.js +108 -25
- package/dist/index.js +99 -26
- package/dist/schema.js +5 -1
- 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 +270 -2
- package/dist/utils/detect.js +0 -507
|
@@ -13,6 +13,7 @@ import { trackPrecomputeEvent } from '../utils/telemetry.js';
|
|
|
13
13
|
import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
|
|
14
14
|
import { startPrecomputeDelivery } from './precompute-delivery.js';
|
|
15
15
|
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
|
|
16
|
+
import { blockLines, parseOnboardingBlock } from './verify-onboarding.js';
|
|
16
17
|
// --hook has five seconds to capture the exact source and publish local custody.
|
|
17
18
|
// Its detached sender owns the bounded network delivery and acknowledgement.
|
|
18
19
|
// An explicit invocation still waits synchronously for the server response.
|
|
@@ -66,7 +67,7 @@ function gitOutput(args, gitRoot, deadline, options = {}) {
|
|
|
66
67
|
throw new PrecomputeFailure('Could not derive the repository snapshot.');
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
|
-
function resolveOriginRepository(gitRoot) {
|
|
70
|
+
export function resolveOriginRepository(gitRoot) {
|
|
70
71
|
try {
|
|
71
72
|
const remote = parseRemoteUrl('origin');
|
|
72
73
|
if (!isRepositoryComponent(remote.owner)
|
|
@@ -80,8 +81,8 @@ function resolveOriginRepository(gitRoot) {
|
|
|
80
81
|
throw new PrecomputeFailure('No supported origin repository was found.');
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
|
-
/** The shared base rule (utils/verify-base.ts), bounded by the
|
|
84
|
-
function deriveBaseSha(gitRoot, deadline) {
|
|
84
|
+
/** The shared base rule (utils/verify-base.ts), bounded by the caller's deadline. */
|
|
85
|
+
export function deriveBaseSha(gitRoot, deadline) {
|
|
85
86
|
try {
|
|
86
87
|
return resolveVerifyBaseSha('HEAD', {
|
|
87
88
|
run: (args, options) => gitOutput(args, gitRoot, deadline, options?.remote
|
|
@@ -261,7 +262,9 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
|
|
|
261
262
|
}
|
|
262
263
|
export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS,
|
|
263
264
|
/** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
|
|
264
|
-
budgetMs
|
|
265
|
+
budgetMs,
|
|
266
|
+
/** Amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' is `haystack verify`. */
|
|
267
|
+
mode = 'crawl') {
|
|
265
268
|
// Capture this before git and compression work so delivery latency cannot
|
|
266
269
|
// make an older stop look newer than a later one.
|
|
267
270
|
const observedAt = Date.now();
|
|
@@ -289,7 +292,7 @@ budgetMs) {
|
|
|
289
292
|
cacheKey,
|
|
290
293
|
patchGzBase64: patchGzip.toString('base64'),
|
|
291
294
|
patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
|
|
292
|
-
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
|
|
295
|
+
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode } }),
|
|
293
296
|
},
|
|
294
297
|
};
|
|
295
298
|
}
|
|
@@ -332,7 +335,8 @@ function readWalkerStatus(value) {
|
|
|
332
335
|
}
|
|
333
336
|
throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
|
|
334
337
|
}
|
|
335
|
-
const CRAWL_UNAVAILABLE_REASONS = ['
|
|
338
|
+
const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'enqueue-failed'];
|
|
339
|
+
const isOnboardRunId = (value) => typeof value === 'string' && /^ob_[0-9a-f]{48}$/.test(value);
|
|
336
340
|
function readCrawlHookStatus(value) {
|
|
337
341
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
338
342
|
const record = value;
|
|
@@ -343,6 +347,18 @@ function readCrawlHookStatus(value) {
|
|
|
343
347
|
const reason = CRAWL_UNAVAILABLE_REASONS.find(known => known === record.reason);
|
|
344
348
|
if (record.status === 'unavailable' && reason !== undefined)
|
|
345
349
|
return { status: record.status, reason };
|
|
350
|
+
// Crawl amendment 8: the base has no published onboarding record yet.
|
|
351
|
+
if (record.status === 'onboarding' && isOnboardRunId(record.onboardRunId))
|
|
352
|
+
return { status: 'onboarding', onboardRunId: record.onboardRunId };
|
|
353
|
+
}
|
|
354
|
+
if (Object.keys(record).length === 3 && record.status === 'onboarding-blocked'
|
|
355
|
+
&& (record.onboardRunId === null || isOnboardRunId(record.onboardRunId))) {
|
|
356
|
+
try {
|
|
357
|
+
return { status: 'onboarding-blocked', onboardRunId: record.onboardRunId, block: parseOnboardingBlock(record.block) };
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
throw new PrecomputeFailure(`The precompute service returned an invalid onboarding block (${error instanceof Error ? error.message : String(error)}).`);
|
|
361
|
+
}
|
|
346
362
|
}
|
|
347
363
|
}
|
|
348
364
|
throw new PrecomputeFailure('The precompute service returned an invalid crawl status.');
|
|
@@ -350,10 +366,7 @@ function readCrawlHookStatus(value) {
|
|
|
350
366
|
/** Why the service could not start a crawl, in plain words. */
|
|
351
367
|
export function crawlUnavailableText(reason) {
|
|
352
368
|
switch (reason) {
|
|
353
|
-
case 'not-configured': return 'this repository is not set up for crawling yet';
|
|
354
369
|
case 'runner-unavailable': return 'no machine that can crawl this repository is available right now';
|
|
355
|
-
case 'profile-ambiguous': return 'the machines that crawl this repository disagree about how to run it';
|
|
356
|
-
case 'superseded': return 'a newer stop in this repository came first';
|
|
357
370
|
case 'enqueue-failed': return 'the service could not queue it';
|
|
358
371
|
}
|
|
359
372
|
}
|
|
@@ -443,12 +456,19 @@ function printResult(result) {
|
|
|
443
456
|
: 'Walker unavailable: the enqueue request failed.');
|
|
444
457
|
else
|
|
445
458
|
console.log('Walker status was not reported by the service.');
|
|
459
|
+
// Amendment 12: this capture builds and freezes the app only; `haystack verify` crawls it.
|
|
446
460
|
if (crawl?.status === 'queued')
|
|
447
|
-
console.log(`
|
|
461
|
+
console.log(`Building and freezing the app for this change (${crawl.runId}); run \`haystack verify\` to check it.`);
|
|
448
462
|
else if (crawl?.status === 'existing')
|
|
449
|
-
console.log(`
|
|
463
|
+
console.log(`This change is already built or being built (${crawl.runId}); run \`haystack verify\` to check it.`);
|
|
450
464
|
else if (crawl?.status === 'unavailable')
|
|
451
465
|
console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
|
|
466
|
+
else if (crawl?.status === 'onboarding') {
|
|
467
|
+
console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); this change is built and frozen when it is ready.`
|
|
468
|
+
+ ' Run `haystack verify` to check it.');
|
|
469
|
+
}
|
|
470
|
+
else if (crawl?.status === 'onboarding-blocked')
|
|
471
|
+
console.log(blockLines(crawl.block).join('\n'));
|
|
452
472
|
else
|
|
453
473
|
console.log('Crawl status was not reported by the service.');
|
|
454
474
|
}
|
|
@@ -468,7 +488,9 @@ function configuredCrawlMinutes(gitRoot) {
|
|
|
468
488
|
}
|
|
469
489
|
return minutes;
|
|
470
490
|
}
|
|
471
|
-
export async function captureCheckout(deadline,
|
|
491
|
+
export async function captureCheckout(deadline,
|
|
492
|
+
/** Amendment 12: the turn-end hook and `verify precompute` ask to prepare (build and freeze); `haystack verify` to crawl. */
|
|
493
|
+
mode, repository,
|
|
472
494
|
/** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
|
|
473
495
|
minutes) {
|
|
474
496
|
const gitRoot = findGitRoot();
|
|
@@ -478,7 +500,7 @@ minutes) {
|
|
|
478
500
|
const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
|
|
479
501
|
const target = repository ?? resolveOriginRepository(gitRoot);
|
|
480
502
|
const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
|
|
481
|
-
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
|
|
503
|
+
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode);
|
|
482
504
|
return { metadataDirectory, derivation };
|
|
483
505
|
}
|
|
484
506
|
/** The explicit (non-hook) submission of a capture: one synchronous
|
|
@@ -516,7 +538,8 @@ export async function verifyPrecomputeCommand(options) {
|
|
|
516
538
|
const deadline = startedAt + totalWallMs;
|
|
517
539
|
let outcome = 'error';
|
|
518
540
|
try {
|
|
519
|
-
|
|
541
|
+
// The hook and an explicit precompute build and freeze the change ahead of `haystack verify`; they never crawl it.
|
|
542
|
+
const capture = await captureCheckout(deadline, 'prepare');
|
|
520
543
|
if (options.hook) {
|
|
521
544
|
remainingMs(deadline);
|
|
522
545
|
await startPrecomputeDelivery(capture.metadataDirectory, capture.derivation.request);
|
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
|
|
@@ -13,21 +14,30 @@
|
|
|
13
14
|
* it; it stops at the crawl's answer at its budget, or when the crawl ends, and
|
|
14
15
|
* prints where the change shows up in the running app and what the crawl found.
|
|
15
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.
|
|
16
23
|
*/
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
17
25
|
import chalk from 'chalk';
|
|
18
26
|
import { withSchema } from '../schema.js';
|
|
27
|
+
import { findGitRoot } from '../utils/hooks.js';
|
|
19
28
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
20
29
|
import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
|
|
21
30
|
import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
|
|
22
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';
|
|
23
32
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
33
|
+
import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, waitForOnboarding } from './verify-onboarding.js';
|
|
24
34
|
const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
|
|
25
35
|
/** A held read (waitAfter): the service's hold, then the time any read gets. */
|
|
26
36
|
const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
|
|
27
37
|
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
28
38
|
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']);
|
|
39
|
+
const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'prepared', 'incomplete', 'cancelled'];
|
|
40
|
+
const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
|
|
31
41
|
const STAGES = ['reconcile', 'source', 'worlds', 'blast', 'knobs', 'prepare', 'crawl', 'publish', 'cleanup'];
|
|
32
42
|
const ERROR_CODES = ['source-mismatch', 'blast-unavailable', 'world-build-failed',
|
|
33
43
|
'prepare-failed', 'crawl-failed', 'no-capacity', 'stalled', 'lease-lost', 'cancelled'];
|
|
@@ -40,6 +50,7 @@ const STATUS_WORDS = {
|
|
|
40
50
|
crawling: 'exploring the app',
|
|
41
51
|
draining: 'finishing the last checks',
|
|
42
52
|
completed: 'finished',
|
|
53
|
+
prepared: 'built and ready to check',
|
|
43
54
|
incomplete: 'stopped before finishing',
|
|
44
55
|
cancelled: 'cancelled',
|
|
45
56
|
};
|
|
@@ -73,7 +84,8 @@ const SPOT_WORDS = {
|
|
|
73
84
|
pressed: 'ran when its control was pressed',
|
|
74
85
|
observed: 'a difference seen',
|
|
75
86
|
confirmed: 'a difference seen and confirmed',
|
|
76
|
-
|
|
87
|
+
// The stage, not the verdict: a judged difference may be judged not a bug. The findings carry the verdicts.
|
|
88
|
+
judged: 'a difference seen, confirmed and judged',
|
|
77
89
|
};
|
|
78
90
|
const VERDICT_WORDS = {
|
|
79
91
|
bug: 'Bug',
|
|
@@ -202,7 +214,7 @@ export function parseCrawlView(value, expected) {
|
|
|
202
214
|
|| typeof value.changeTitle !== 'string' || !isCount(value.observedAt)
|
|
203
215
|
|| !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt)
|
|
204
216
|
|| (value.supersededBy !== null && (typeof value.supersededBy !== 'string' || !RUN_ID.test(value.supersededBy)))
|
|
205
|
-
|| typeof value.cancelRequested !== 'boolean')
|
|
217
|
+
|| typeof value.cancelRequested !== 'boolean' || (value.mode !== 'prepare' && value.mode !== 'crawl'))
|
|
206
218
|
invalid('its status');
|
|
207
219
|
const clocks = value.clocks;
|
|
208
220
|
if (!isRecord(clocks) || !['startedAt', 'stageStartedAt', 'crawlStartedAt', 'finishedAt']
|
|
@@ -322,13 +334,15 @@ function headline(view) {
|
|
|
322
334
|
const error = results(view)?.error ?? view.error;
|
|
323
335
|
return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
|
|
324
336
|
}
|
|
337
|
+
case 'prepared':
|
|
338
|
+
// Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
|
|
339
|
+
return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
|
|
325
340
|
case 'cancelled':
|
|
326
|
-
return `The crawl was cancelled
|
|
341
|
+
return `The crawl was cancelled.${found}`;
|
|
327
342
|
default: {
|
|
328
343
|
const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
|
|
329
344
|
if (view.cancelRequested) {
|
|
330
|
-
return `Being cancelled${view
|
|
331
|
-
+ `${currentStep(view)}.${so}`;
|
|
345
|
+
return `Being cancelled: ${currentStep(view)}.${so}`;
|
|
332
346
|
}
|
|
333
347
|
return `Still running: ${currentStep(view)}.${so}`;
|
|
334
348
|
}
|
|
@@ -336,7 +350,9 @@ function headline(view) {
|
|
|
336
350
|
}
|
|
337
351
|
function spotLines(spot) {
|
|
338
352
|
const how = spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest];
|
|
339
|
-
const
|
|
353
|
+
const found = spot.findingIds.length === 0 ? ''
|
|
354
|
+
: ` (${spot.findingIds.length} finding${spot.findingIds.length === 1 ? '' : 's'} below)`;
|
|
355
|
+
const lines = [` ${chalk.bold(`${safe(spot.file)}:${spot.line}`)} ${how}${found}`];
|
|
340
356
|
if (spot.reachedBy) {
|
|
341
357
|
lines.push(` reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].map(safe).join(' > ')}`);
|
|
342
358
|
if (spot.reachedBy.page !== null)
|
|
@@ -486,9 +502,9 @@ async function waitForCrawl(first, identity, token) {
|
|
|
486
502
|
// The followed crawl's own read is held, so only a change to it wakes the wait (Astra review: a capture read could sit up to
|
|
487
503
|
// 25 s on another title's crawl of the same code).
|
|
488
504
|
let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
if (next.
|
|
505
|
+
// Once this crawl is being cancelled, the capture's newest crawl is read at once (not held) and followed if it is this same
|
|
506
|
+
// change (amendment 12: no crawl replaces another, but one can be cancelled and asked for again).
|
|
507
|
+
if (next.cancelRequested) {
|
|
492
508
|
const newest = await readCrawl(identity, token);
|
|
493
509
|
if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
|
|
494
510
|
next = newest;
|
|
@@ -517,7 +533,10 @@ function staleCrawl(found, identity) {
|
|
|
517
533
|
return 'other-time';
|
|
518
534
|
if (found.status === 'cancelled' || found.status === 'incomplete')
|
|
519
535
|
return found.status;
|
|
520
|
-
|
|
536
|
+
if (!TERMINAL.has(found.status) && found.cancelRequested)
|
|
537
|
+
return 'cancelling';
|
|
538
|
+
// A `prepared` run is asked for its crawl whatever its mode (a cancel can leave a widened one prepared).
|
|
539
|
+
return found.mode === 'prepare' || found.status === 'prepared' ? 'prepare-only' : null;
|
|
521
540
|
}
|
|
522
541
|
function staleWords(found, stale) {
|
|
523
542
|
switch (stale) {
|
|
@@ -526,11 +545,15 @@ function staleWords(found, stale) {
|
|
|
526
545
|
case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
|
|
527
546
|
case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
|
|
528
547
|
case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
|
|
548
|
+
case 'prepare-only': return found.status === 'prepared'
|
|
549
|
+
? `Your change was built and frozen ahead of time (${found.runId})`
|
|
550
|
+
: `Your change is being built and frozen ahead of time (${found.runId})`;
|
|
529
551
|
}
|
|
530
552
|
}
|
|
531
553
|
/** Submits the capture through the explicit precompute submission when no crawl
|
|
532
554
|
* of it exists or the one found is stale. Returns the acknowledged run id (null
|
|
533
|
-
* when no crawl could be started)
|
|
555
|
+
* when no crawl could be started), whether the base is being onboarded or its
|
|
556
|
+
* onboarding is blocked, and the line to report. */
|
|
534
557
|
async function submitCapture(capture, token, found) {
|
|
535
558
|
let acknowledgment;
|
|
536
559
|
// A momentary 429/5xx (a server or runner restarting) is asked again, as the stop hook's sender does: admission is
|
|
@@ -550,37 +573,75 @@ async function submitCapture(capture, token, found) {
|
|
|
550
573
|
const crawl = acknowledgment.crawl;
|
|
551
574
|
const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
|
|
552
575
|
if (crawl === undefined) {
|
|
553
|
-
return { runId: null,
|
|
576
|
+
return { runId: null, onboarding: null,
|
|
577
|
+
line: `${before}, and the service did not say whether it started ${found === null ? 'one' : 'another'}.` };
|
|
554
578
|
}
|
|
555
579
|
if (crawl.status === 'unavailable') {
|
|
556
|
-
return { runId: null,
|
|
580
|
+
return { runId: null, onboarding: null,
|
|
581
|
+
line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
|
|
582
|
+
}
|
|
583
|
+
if (crawl.status === 'onboarding') {
|
|
584
|
+
return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded for this base yet: `
|
|
585
|
+
+ `the service is preparing it (${crawl.onboardRunId}) and runs the crawl when it is ready.` };
|
|
586
|
+
}
|
|
587
|
+
if (crawl.status === 'onboarding-blocked') {
|
|
588
|
+
return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding for this base is blocked.` };
|
|
557
589
|
}
|
|
558
590
|
if (found === null) {
|
|
559
|
-
return { runId: crawl.runId, line: crawl.status === 'queued'
|
|
591
|
+
return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
|
|
560
592
|
? `No crawl existed for this change yet; started one (${crawl.runId}).`
|
|
561
593
|
: `The service already had a crawl for this change (${crawl.runId}).` };
|
|
562
594
|
}
|
|
563
595
|
if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
|
|
564
|
-
return { runId: crawl.runId,
|
|
596
|
+
return { runId: crawl.runId, onboarding: null, line: found.stale === 'prepare-only'
|
|
597
|
+
? `${before}; checking it now, reusing what was built.` : `${before}; asked the service to run it again.` };
|
|
565
598
|
}
|
|
566
|
-
return { runId: crawl.runId, line: crawl.status === 'queued'
|
|
599
|
+
return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
|
|
567
600
|
? `${before}; started one for this change (${crawl.runId}).`
|
|
568
601
|
: `${before}; the service already had one for this change (${crawl.runId}).` };
|
|
569
602
|
}
|
|
603
|
+
function checkedChange(baseCommit, workCommit) {
|
|
604
|
+
const gitRoot = findGitRoot();
|
|
605
|
+
if (!gitRoot)
|
|
606
|
+
throw new Error('Run this command from a git repository.');
|
|
607
|
+
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 !== '');
|
|
608
|
+
return { baseCommit, workCommit, files };
|
|
609
|
+
}
|
|
610
|
+
/** One line naming what the crawl checks, so an agent knows which edits it covers. */
|
|
611
|
+
function checkedLine(change) {
|
|
612
|
+
const shown = change.files.slice(0, 5).join(', ');
|
|
613
|
+
const more = change.files.length > 5 ? ` and ${change.files.length - 5} more` : '';
|
|
614
|
+
return `Checking ${change.files.length} changed file${change.files.length === 1 ? '' : 's'} against ${change.baseCommit.slice(0, 12)}`
|
|
615
|
+
+ ` (where HEAD meets origin's default branch), committed and uncommitted: ${shown}${more}.`;
|
|
616
|
+
}
|
|
617
|
+
/** Prints the onboarding (and nothing else) as the command's answer: `--json`
|
|
618
|
+
* carries OnboardingStatusV1. A block exits 3: its ask says what to do. */
|
|
619
|
+
function finishWithOnboarding(status, json, change) {
|
|
620
|
+
if (json)
|
|
621
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding: status, crawl: null }), null, 2)}\n`);
|
|
622
|
+
else
|
|
623
|
+
console.log(formatOnboarding(status));
|
|
624
|
+
process.exitCode = onboardingExitCode(status);
|
|
625
|
+
}
|
|
570
626
|
export async function verifyCommand(options) {
|
|
571
627
|
// In --json mode stdout carries the one document; every other line goes to stderr.
|
|
572
628
|
const note = (line) => { if (options.json)
|
|
573
629
|
console.error(line);
|
|
574
630
|
else
|
|
575
631
|
console.log(line); };
|
|
576
|
-
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
|
|
632
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes));
|
|
577
633
|
const request = capture.derivation.request;
|
|
634
|
+
const change = checkedChange(request.baseCommit, request.workCommit);
|
|
578
635
|
if (!request.crawl) {
|
|
579
636
|
note(`Nothing to verify: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
|
|
580
637
|
if (options.json)
|
|
581
|
-
process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
|
|
638
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding: null, crawl: null }), null, 2)}\n`);
|
|
582
639
|
return;
|
|
583
640
|
}
|
|
641
|
+
note(checkedLine(change));
|
|
642
|
+
// Measured: a 3-minute crawl answered 13 minutes after the command started (building and preparing both apps).
|
|
643
|
+
note('A crawl often takes 10 to 15 minutes. If this command times out or is stopped, the crawl keeps going:'
|
|
644
|
+
+ ' run `haystack verify` again to pick up the same crawl.');
|
|
584
645
|
const identity = {
|
|
585
646
|
repository: `${request.owner}/${request.repository}`,
|
|
586
647
|
baseCommit: request.baseCommit,
|
|
@@ -591,14 +652,32 @@ export async function verifyCommand(options) {
|
|
|
591
652
|
};
|
|
592
653
|
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
|
|
593
654
|
let view = await readCrawl(identity, auth.token);
|
|
655
|
+
let onboarding = null;
|
|
594
656
|
const stale = view === null ? null : staleCrawl(view, identity);
|
|
595
657
|
if (view === null || stale !== null) {
|
|
596
658
|
const found = view === null || stale === null ? null : { view, stale };
|
|
597
|
-
|
|
659
|
+
let submitted = await submitCapture(capture, auth.token, found);
|
|
598
660
|
note(submitted.line);
|
|
661
|
+
if (submitted.onboarding !== null) {
|
|
662
|
+
onboarding = await readOnboardingStatus(identity.repository, identity.baseCommit, auth.token);
|
|
663
|
+
reportOnboardingState(onboarding);
|
|
664
|
+
if (options.wait !== false && onboarding.state === 'onboarding') {
|
|
665
|
+
onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack verify');
|
|
666
|
+
reportOnboardingState(onboarding);
|
|
667
|
+
}
|
|
668
|
+
if (onboarding.state !== 'ready') {
|
|
669
|
+
finishWithOnboarding(onboarding, options.json, change);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// Ready: the service admitted the waiting crawl when the record published; this
|
|
673
|
+
// submission of the same capture finds it (or starts it, when a newer stop replaced it).
|
|
674
|
+
note('The app is onboarded for this base.');
|
|
675
|
+
submitted = await submitCapture(capture, auth.token, found);
|
|
676
|
+
note(submitted.line);
|
|
677
|
+
}
|
|
599
678
|
if (submitted.runId === null) {
|
|
600
679
|
if (options.json)
|
|
601
|
-
process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
|
|
680
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: null }), null, 2)}\n`);
|
|
602
681
|
process.exitCode = 1;
|
|
603
682
|
return;
|
|
604
683
|
}
|
|
@@ -611,9 +690,13 @@ export async function verifyCommand(options) {
|
|
|
611
690
|
view = await waitForCrawl(view, identity, auth.token);
|
|
612
691
|
}
|
|
613
692
|
if (options.json)
|
|
614
|
-
process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: view }), null, 2)}\n`);
|
|
615
|
-
else
|
|
693
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: view }), null, 2)}\n`);
|
|
694
|
+
else {
|
|
616
695
|
console.log(formatCrawl(view));
|
|
696
|
+
const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? [])];
|
|
697
|
+
if (notes.length)
|
|
698
|
+
console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
|
|
699
|
+
}
|
|
617
700
|
const exitCode = crawlExitCode(view);
|
|
618
701
|
if (exitCode !== 0)
|
|
619
702
|
process.exitCode = exitCode;
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Automated PR review, triage, and merge queue for AI-assisted development.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* npx @haystackeditor/cli init # Set up
|
|
9
|
+
* npx @haystackeditor/cli init # Set this repository up for `haystack verify`
|
|
10
10
|
* npx @haystackeditor/cli setup # Interactive onboarding wizard
|
|
11
11
|
* npx @haystackeditor/cli status # Check configuration
|
|
12
12
|
* npx @haystackeditor/cli login # Authenticate with GitHub
|
|
@@ -133,7 +133,7 @@ function getVersion() {
|
|
|
133
133
|
const program = new Command();
|
|
134
134
|
program
|
|
135
135
|
.name('haystack')
|
|
136
|
-
.description('Haystack CLI —
|
|
136
|
+
.description('Haystack CLI — haystack verify runs your app with and without a change and shows what it broke')
|
|
137
137
|
.version(getVersion())
|
|
138
138
|
// commander 13 made surplus positional arguments an error; the CLI has always
|
|
139
139
|
// ignored them. Every command created below inherits this setting.
|
|
@@ -144,18 +144,39 @@ program
|
|
|
144
144
|
.action((name) => (name ? schemaCommand(name) : listSchemas()));
|
|
145
145
|
program
|
|
146
146
|
.command('init')
|
|
147
|
-
.description('
|
|
148
|
-
.option('-
|
|
147
|
+
.description('Set this repository up for `haystack verify` and start onboarding the app')
|
|
148
|
+
.option('-y, --yes', 'Make the changes without asking')
|
|
149
|
+
.option('--json', 'The result as one JSON document on stdout (see `haystack schema init`)')
|
|
149
150
|
.addHelpText('after', `
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
•
|
|
151
|
+
There is no setup file: Haystack works out how to run the app from what the
|
|
152
|
+
repository already keeps working. init shows each change as a diff, then makes
|
|
153
|
+
it with --yes (or a yes at the prompt):
|
|
154
|
+
• Claude Code's Stop hook (\`haystack verify precompute --hook\`) in the
|
|
155
|
+
per-developer .claude/settings.local.json, kept out of commits, when
|
|
156
|
+
Claude Code is in use here: it builds and freezes the changed app ahead
|
|
157
|
+
of time, so \`haystack verify\` starts from there
|
|
158
|
+
• a short note in AGENTS.md (and CLAUDE.md when the repository keeps one)
|
|
159
|
+
telling coding agents to run \`haystack verify\` after a change
|
|
160
|
+
Then it starts onboarding the app, so it is usually ready by the first
|
|
161
|
+
\`haystack verify\`. Running it again changes only what is missing and shows
|
|
162
|
+
where onboarding is (\`haystack verify onboarding\` shows that too).
|
|
154
163
|
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
Exit codes:
|
|
165
|
+
0 set up: onboarding is running or the app is ready
|
|
166
|
+
1 the command failed
|
|
167
|
+
2 changes were shown but not made: run \`haystack init --yes\`
|
|
168
|
+
3 onboarding is blocked: the output says what would unblock it
|
|
169
|
+
4 not logged in: run \`haystack login\`
|
|
170
|
+
5 the Haystack GitHub App is not installed on the repository's owner:
|
|
171
|
+
the output links to it
|
|
172
|
+
6 set up, but onboarding stopped before finishing: run \`haystack init\` again
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
haystack init
|
|
176
|
+
haystack init --yes
|
|
177
|
+
haystack init --yes --json
|
|
157
178
|
`)
|
|
158
|
-
.action(initCommand);
|
|
179
|
+
.action(options => runPublicCommand(() => initCommand(options), options.json));
|
|
159
180
|
program
|
|
160
181
|
.command('setup')
|
|
161
182
|
.description('Onboarding wizard — scan repos for rules/policies and write Haystack config')
|
|
@@ -233,13 +254,21 @@ changed spot, how far the crawl got with it, and the steps that reached it),
|
|
|
233
254
|
what the crawl found (bugs first, with the steps to see each), and the changed
|
|
234
255
|
code it never ran.
|
|
235
256
|
|
|
257
|
+
The first verify on a base the service has not onboarded yet prepares the app
|
|
258
|
+
(reads the repository, plans how to run it, builds its runtime, starts it,
|
|
259
|
+
prepares its data and test accounts, proves a workflow); the command shows each
|
|
260
|
+
step, and the crawl runs once the app is ready. When onboarding cannot go on it
|
|
261
|
+
says why and what would unblock it, for example a question to answer with
|
|
262
|
+
\`haystack verify answer <question-id> <choice>\`.
|
|
263
|
+
|
|
236
264
|
Exit codes:
|
|
237
265
|
0 the crawl answered or finished (the bugs it found are in the output), or
|
|
238
266
|
it is still running and --no-wait was given
|
|
239
267
|
1 the command failed, or no crawl could be started for this change
|
|
240
|
-
2 the crawl ended without finishing: it stopped early or was cancelled
|
|
241
|
-
|
|
242
|
-
|
|
268
|
+
2 the crawl ended without finishing: it stopped early or was cancelled,
|
|
269
|
+
or the machines it used could not be proven shut down; or onboarding
|
|
270
|
+
stopped before finishing
|
|
271
|
+
3 onboarding is blocked: the output says what would unblock it
|
|
243
272
|
|
|
244
273
|
The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
|
|
245
274
|
|
|
@@ -273,6 +302,49 @@ function verifyCommandOptions(command) {
|
|
|
273
302
|
}
|
|
274
303
|
return options;
|
|
275
304
|
}
|
|
305
|
+
verify
|
|
306
|
+
.command('answer')
|
|
307
|
+
.description('Answer a question onboarding asked about your app, then run `haystack init` to continue')
|
|
308
|
+
.argument('<question-id>', 'The question id onboarding printed')
|
|
309
|
+
.argument('<choice>', 'One of the question\'s choices, exactly as printed')
|
|
310
|
+
.option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
|
|
311
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
312
|
+
.option('--json', 'The repository\'s onboarding facts after the answer, as one JSON document')
|
|
313
|
+
.addHelpText('after', `
|
|
314
|
+
An answer is a fact about your app (never setup); onboarding plans from it the
|
|
315
|
+
next time it runs, so \`haystack init\` or a new \`haystack verify\` continues
|
|
316
|
+
from there.
|
|
317
|
+
|
|
318
|
+
Example:
|
|
319
|
+
haystack verify answer q_<id> "postgres 15 (terraform/db.tf)"
|
|
320
|
+
`)
|
|
321
|
+
.action(async (questionId, choice, _options, cmd) => {
|
|
322
|
+
const options = verifyCommandOptions(cmd);
|
|
323
|
+
const { verifyAnswerCommand } = await import('./commands/verify-onboarding.js');
|
|
324
|
+
return runPublicCommand(() => verifyAnswerCommand(questionId, choice, options), options.json);
|
|
325
|
+
});
|
|
326
|
+
verify
|
|
327
|
+
.command('onboarding')
|
|
328
|
+
.description('Show where onboarding of this checkout\'s base is, without starting anything')
|
|
329
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
330
|
+
.option('--wait', 'Follow onboarding until it is ready, blocked or stopped')
|
|
331
|
+
.option('--json', 'The onboarding as one JSON document (see `haystack schema verify-onboarding`)')
|
|
332
|
+
.addHelpText('after', `
|
|
333
|
+
Reads the onboarding of the base \`haystack verify\` would use for this
|
|
334
|
+
checkout. \`haystack init\` starts it; so does the first \`haystack verify\`.
|
|
335
|
+
|
|
336
|
+
Exit codes:
|
|
337
|
+
0 the app is ready, or onboarding is running
|
|
338
|
+
1 the command failed
|
|
339
|
+
2 onboarding has not started, stopped before finishing, or was cancelled:
|
|
340
|
+
\`haystack init\` starts it again
|
|
341
|
+
3 onboarding is blocked: the output says what would unblock it
|
|
342
|
+
`)
|
|
343
|
+
.action(async (_options, cmd) => {
|
|
344
|
+
const options = verifyCommandOptions(cmd);
|
|
345
|
+
const { verifyOnboardingCommand } = await import('./commands/verify-onboarding.js');
|
|
346
|
+
return runPublicCommand(() => verifyOnboardingCommand(options), options.json);
|
|
347
|
+
});
|
|
276
348
|
verify
|
|
277
349
|
.command('history')
|
|
278
350
|
.description('Read your recent finished hosted verification runs without starting a run')
|
|
@@ -295,7 +367,7 @@ Examples:
|
|
|
295
367
|
});
|
|
296
368
|
verify
|
|
297
369
|
.command('precompute')
|
|
298
|
-
.description('
|
|
370
|
+
.description('Build and freeze the current working tree\'s app ahead of `haystack verify` (no crawl)')
|
|
299
371
|
.option('--hook', 'Run silently as a best-effort repository hook')
|
|
300
372
|
.action(async (options) => {
|
|
301
373
|
await runPublicCommand(async () => {
|
|
@@ -570,6 +642,15 @@ program
|
|
|
570
642
|
.option('--label <name>', '(--headless) Label to display in the dashboard for this token')
|
|
571
643
|
.option('--scope <repos>', '(--headless) Comma-separated repos to scope the token to (or "all")', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
|
|
572
644
|
.option('--token <hsk_live_...>', '(--headless) Skip the browser handshake — paste a token minted in the dashboard')
|
|
645
|
+
.option('--no-wait', 'Print the sign-in URL and code and return; the next `haystack login` finishes signing in')
|
|
646
|
+
.option('--json', 'The sign-in (URL and code, or the account signed in) as one JSON document (see `haystack schema login`)')
|
|
647
|
+
.addHelpText('after', `
|
|
648
|
+
Signing in uses GitHub's device flow: open the URL, enter the code. A coding
|
|
649
|
+
agent runs \`haystack login --no-wait\`, shows the person the URL and code, and
|
|
650
|
+
then runs \`haystack login\`, which waits until the code is entered. A login
|
|
651
|
+
stopped while it waits is finished the same way, with the same code, until
|
|
652
|
+
GitHub expires it.
|
|
653
|
+
`)
|
|
573
654
|
.action(async (opts) => {
|
|
574
655
|
if (opts.headless) {
|
|
575
656
|
try {
|
|
@@ -581,7 +662,7 @@ program
|
|
|
581
662
|
}
|
|
582
663
|
return;
|
|
583
664
|
}
|
|
584
|
-
return loginCommand();
|
|
665
|
+
return loginCommand({ wait: opts.wait, json: opts.json });
|
|
585
666
|
});
|
|
586
667
|
program
|
|
587
668
|
.command('logout [login]')
|
|
@@ -1278,7 +1359,8 @@ hooks
|
|
|
1278
1359
|
Configures your coding CLI to run \`haystack triage --hook\` on session start.
|
|
1279
1360
|
This shows pending PR analysis results when you open a new terminal session.
|
|
1280
1361
|
Claude Code also runs \`haystack verify precompute --hook\` when a session stops,
|
|
1281
|
-
which
|
|
1362
|
+
which builds and freezes the changed app in the background so that
|
|
1363
|
+
\`haystack verify\` starts from there. It never crawls; \`haystack verify\` does.
|
|
1282
1364
|
|
|
1283
1365
|
Claude Code: Native SessionStart and Stop hooks (.claude/settings.local.json;
|
|
1284
1366
|
--shared writes the committed .claude/settings.json)
|
|
@@ -1531,15 +1613,6 @@ fleetPolicy
|
|
|
1531
1613
|
const { fleetPolicyHistoryCommand } = await import('./commands/fleet-policy.js');
|
|
1532
1614
|
await runAdminCommand(() => fleetPolicyHistoryCommand(repository, options));
|
|
1533
1615
|
});
|
|
1534
|
-
fleetPolicy
|
|
1535
|
-
.command('import-kv')
|
|
1536
|
-
.description('One time: import the KV policies and tenant overrides into D1')
|
|
1537
|
-
.option('--dry-run', 'Report what would be imported without writing')
|
|
1538
|
-
.option(workerUrlOption, workerUrlHelp)
|
|
1539
|
-
.action(async (options) => {
|
|
1540
|
-
const { fleetPolicyImportKvCommand } = await import('./commands/fleet-policy.js');
|
|
1541
|
-
await runAdminCommand(() => fleetPolicyImportKvCommand(options));
|
|
1542
|
-
});
|
|
1543
1616
|
// ─── rules ───────────────────────────────────────────────────────────────────
|
|
1544
1617
|
const rules = program.command('rules').description('Manage .haystack/pr-rules.yml');
|
|
1545
1618
|
rules
|
package/dist/schema.js
CHANGED
|
@@ -20,7 +20,11 @@ export const SCHEMA_VERSIONS = {
|
|
|
20
20
|
action: '1.0.0',
|
|
21
21
|
'cloud-verifier': '1.0.0',
|
|
22
22
|
'case-batch': '1.0.1',
|
|
23
|
-
verify: '1.0.
|
|
23
|
+
verify: '1.0.2',
|
|
24
|
+
'verify-answer': '1.0.0',
|
|
25
|
+
'verify-onboarding': '1.0.0',
|
|
26
|
+
init: '1.0.0',
|
|
27
|
+
login: '1.0.0',
|
|
24
28
|
error: '1.0.0',
|
|
25
29
|
};
|
|
26
30
|
/** Wrap a payload with the `schema_version` envelope. */
|
package/dist/utils/config.js
CHANGED
|
@@ -42,22 +42,6 @@ export async function loadConfig(configPath) {
|
|
|
42
42
|
throw new Error(`Failed to parse ${resolvedPath}: ${err.message}`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Save config to file
|
|
47
|
-
*/
|
|
48
|
-
export async function saveConfig(config, configPath) {
|
|
49
|
-
const resolvedPath = configPath || path.join(process.cwd(), CONFIG_FILENAME);
|
|
50
|
-
const content = JSON.stringify(config, null, 2);
|
|
51
|
-
await fs.writeFile(resolvedPath, content, 'utf-8');
|
|
52
|
-
return resolvedPath;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Check if config exists
|
|
56
|
-
*/
|
|
57
|
-
export async function configExists(startDir) {
|
|
58
|
-
const configPath = await findConfigPath(startDir);
|
|
59
|
-
return configPath !== null;
|
|
60
|
-
}
|
|
61
45
|
/**
|
|
62
46
|
* Get the project root (directory containing .haystack.json or current dir)
|
|
63
47
|
*/
|