@haystackeditor/cli 0.23.1 → 0.23.3
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.
|
@@ -4,6 +4,7 @@ export const CRAWL_BUDGET_MIN_MS = 60_000;
|
|
|
4
4
|
export const CRAWL_BUDGET_MAX_MS = 30 * 60_000;
|
|
5
5
|
export const CRAWL_MAX_FINDINGS = 50;
|
|
6
6
|
export const CRAWL_MAX_FINDING_STEPS = 64;
|
|
7
|
+
export const CRAWL_POOLS = ['fleet', 'freestyle', 'auto'];
|
|
7
8
|
/** Amendment 9: what a crawl has found so far (`exploring`) or its answer at the budget (`answered`), before the sealed manifest;
|
|
8
9
|
* findings carry no images. A read may pass `waitAfter=<updatedAt>` to be held until the crawl changes (at most CRAWL_WAIT_MAX_MS). */
|
|
9
10
|
export const CRAWL_WAIT_MAX_MS = 25_000;
|
|
@@ -14,7 +14,7 @@ import chalk from 'chalk';
|
|
|
14
14
|
import { DATABASE_PROFILE_UPLOAD_REQUEST_VERSION, parseDatabaseProfileUpload, } from './db-profile-contract.js';
|
|
15
15
|
import { readDatabaseProfileFile } from './db-profile-show.js';
|
|
16
16
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
17
|
-
import { classifyHttpError, haystackApiUrl } from '../utils/haystack-api.js';
|
|
17
|
+
import { classifyHttpError, haystackApiUrl, readWithRetries, transientReadFailure } from '../utils/haystack-api.js';
|
|
18
18
|
export const DATABASE_PROFILES_PATH = '/api/agent/cloud-verifier/database-profiles';
|
|
19
19
|
/** GitHub's owner and repository name alphabets. */
|
|
20
20
|
const REPOSITORY = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9_.-]{1,100})$/u;
|
|
@@ -43,7 +43,23 @@ export function buildDatabaseProfileUploadRequest(file, options) {
|
|
|
43
43
|
},
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
+
// The service stores a profile by its content hash, so sending the same
|
|
47
|
+
// request again after a dropped connection or a restarting service stores
|
|
48
|
+
// nothing new: the upload is asked again on the same transient failures a
|
|
49
|
+
// read is (readWithRetries: three more tries, 2 s, 5 s, 10 s apart).
|
|
46
50
|
export async function uploadDatabaseProfile(request, token) {
|
|
51
|
+
try {
|
|
52
|
+
return await readWithRetries(() => uploadDatabaseProfileOnce(request, token));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const transient = transientReadFailure(error);
|
|
56
|
+
if (transient === null)
|
|
57
|
+
throw error;
|
|
58
|
+
throw new Error(`Haystack API ${DATABASE_PROFILES_PATH} did not store the profile after 4 tries (${transient}). `
|
|
59
|
+
+ 'Nothing was stored twice: run the same command again once the network or service is back.', { cause: error });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function uploadDatabaseProfileOnce(request, token) {
|
|
47
63
|
const response = await fetch(haystackApiUrl(DATABASE_PROFILES_PATH), {
|
|
48
64
|
method: 'POST',
|
|
49
65
|
headers: {
|
|
@@ -62,6 +78,9 @@ export async function uploadDatabaseProfile(request, token) {
|
|
|
62
78
|
body = await response.json();
|
|
63
79
|
}
|
|
64
80
|
catch (error) {
|
|
81
|
+
// A connection dropped while the body is read is a transport failure, so the upload is asked again.
|
|
82
|
+
if (transientReadFailure(error) !== null)
|
|
83
|
+
throw error;
|
|
65
84
|
throw new Error(`Haystack API ${DATABASE_PROFILES_PATH} returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
85
|
}
|
|
67
86
|
const upload = parseDatabaseProfileUpload(body);
|
|
@@ -95,6 +95,8 @@ export function parseOnboardingStatus(value, expected) {
|
|
|
95
95
|
invalid('its version choices');
|
|
96
96
|
if (!isStrings(value.standIns) || !isStrings(value.unsetProviders) || !isStrings(value.standInGaps))
|
|
97
97
|
invalid('its stand-ins');
|
|
98
|
+
if (!(value.worldMemoryMib === null || (typeof value.worldMemoryMib === 'number' && Number.isInteger(value.worldMemoryMib))))
|
|
99
|
+
invalid('its world memory');
|
|
98
100
|
if (value.block !== null)
|
|
99
101
|
parseOnboardingBlock(value.block);
|
|
100
102
|
const checkpoint = value.checkpoint;
|
|
@@ -136,11 +138,13 @@ async function body(response, what) {
|
|
|
136
138
|
throw new OnboardingResponseError(`The onboarding service returned ${what} that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
|
-
/** The onboarding of `repository` at `baseCommit`, now
|
|
140
|
-
|
|
141
|
+
/** The onboarding of `repository` at `baseCommit`, now; with `onboardRunId`, that run's (or the run a derivation change moved
|
|
142
|
+
* it to), whatever derivation is live: what a waiting command follows. */
|
|
143
|
+
export async function readOnboardingStatus(repository, baseCommit, token, onboardRunId = null) {
|
|
141
144
|
if (!COMMIT.test(baseCommit))
|
|
142
145
|
throw new Error(`Invalid base commit ${baseCommit}.`);
|
|
143
|
-
const path = `${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}&baseCommit=${baseCommit}
|
|
146
|
+
const path = `${ONBOARDING_PATH}?repository=${encodeURIComponent(repository)}&baseCommit=${baseCommit}`
|
|
147
|
+
+ (onboardRunId === null ? '' : `&onboardRunId=${encodeURIComponent(onboardRunId)}`);
|
|
144
148
|
const response = await gatewayFetch(path, token);
|
|
145
149
|
if (!response.ok)
|
|
146
150
|
throw await classifyHttpError(response, `Haystack API ${ONBOARDING_PATH}`);
|
|
@@ -185,7 +189,9 @@ export async function waitForOnboarding(first, token, progress, resume) {
|
|
|
185
189
|
await delay(ONBOARDING_READ_EVERY_MS);
|
|
186
190
|
let next;
|
|
187
191
|
try {
|
|
188
|
-
|
|
192
|
+
// The run this wait follows, not the base under whatever derivation is live: a release swap mid-wait otherwise reads
|
|
193
|
+
// not-started while the run goes on to admit this command's crawl (galaxy's n8n replays, 2026-10-03).
|
|
194
|
+
next = await readOnboardingStatus(status.repository, status.baseCommit, token, status.onboardRunId);
|
|
189
195
|
}
|
|
190
196
|
catch (error) {
|
|
191
197
|
const why = transientReadFailure(error);
|
|
@@ -225,9 +231,15 @@ export function onboardingStep(status) {
|
|
|
225
231
|
const stages = status.checkpoint?.execution.stages ?? [];
|
|
226
232
|
const done = stages.filter(stage => stage.status === 'ok' || stage.status === 'reused').map(stage => stage.stage);
|
|
227
233
|
const current = status.checkpoint?.stage;
|
|
234
|
+
// A deferred run's checkpoint is its last attempt's (stage cleanup): it is waiting, not cleaning up (2026-10-05, Excalidraw
|
|
235
|
+
// showed "cleaning up" for ten minutes behind a duplicate onboarding of the same commit).
|
|
228
236
|
const doing = status.state !== 'onboarding' ? status.state
|
|
229
|
-
:
|
|
230
|
-
|
|
237
|
+
: status.checkpoint?.error?.code === 'deferred' ? 'waiting for an earlier onboarding of this commit to finish, then reusing it'
|
|
238
|
+
: current === undefined ? 'queued' : STAGE_WORDS[current];
|
|
239
|
+
// A world larger than the first size is what onboarding found the app needs; every crawl's VMs are that size.
|
|
240
|
+
const memory = status.state === 'ready' && status.worldMemoryMib !== null && status.worldMemoryMib > 4096
|
|
241
|
+
? ` Its copies run with ${status.worldMemoryMib} MiB of memory: it ran out at 4096.` : '';
|
|
242
|
+
return `Onboarding: ${doing}${done.length ? ` (done: ${done.join(', ')})` : ''}.${memory}`;
|
|
231
243
|
}
|
|
232
244
|
/** The stage list, one line per stage the run has reached. */
|
|
233
245
|
export function onboardingStageLines(status) {
|
|
@@ -264,7 +264,9 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
|
|
|
264
264
|
/** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
|
|
265
265
|
budgetMs,
|
|
266
266
|
/** Amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' is `haystack verify`. */
|
|
267
|
-
mode = 'crawl'
|
|
267
|
+
mode = 'crawl',
|
|
268
|
+
/** Amendment 16: `haystack verify --pool`; undefined: the service decides ('auto'). */
|
|
269
|
+
pool) {
|
|
268
270
|
// Capture this before git and compression work so delivery latency cannot
|
|
269
271
|
// make an older stop look newer than a later one.
|
|
270
272
|
const observedAt = Date.now();
|
|
@@ -292,7 +294,7 @@ mode = 'crawl') {
|
|
|
292
294
|
cacheKey,
|
|
293
295
|
patchGzBase64: patchGzip.toString('base64'),
|
|
294
296
|
patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
|
|
295
|
-
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode } }),
|
|
297
|
+
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode, ...(pool === undefined ? {} : { pool }) } }),
|
|
296
298
|
},
|
|
297
299
|
};
|
|
298
300
|
}
|
|
@@ -519,7 +521,9 @@ export async function captureCheckout(deadline,
|
|
|
519
521
|
/** Amendment 12: the turn-end hook and `verify precompute` ask to prepare (build and freeze); `haystack verify` to crawl. */
|
|
520
522
|
mode, repository,
|
|
521
523
|
/** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
|
|
522
|
-
minutes
|
|
524
|
+
minutes,
|
|
525
|
+
/** `haystack verify --pool`; undefined: the service decides. */
|
|
526
|
+
pool) {
|
|
523
527
|
const gitRoot = findGitRoot();
|
|
524
528
|
if (!gitRoot) {
|
|
525
529
|
throw new PrecomputeFailure('Run this command from a git repository.');
|
|
@@ -527,7 +531,7 @@ minutes) {
|
|
|
527
531
|
const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
|
|
528
532
|
const target = repository ?? resolveOriginRepository(gitRoot);
|
|
529
533
|
const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
|
|
530
|
-
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode);
|
|
534
|
+
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode, pool);
|
|
531
535
|
return { metadataDirectory, derivation };
|
|
532
536
|
}
|
|
533
537
|
/** The explicit (non-hook) submission of a capture: one synchronous
|
package/dist/commands/verify.js
CHANGED
|
@@ -28,7 +28,7 @@ import { findGitRoot } from '../utils/hooks.js';
|
|
|
28
28
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
29
29
|
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
|
|
30
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_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
|
|
31
|
+
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
|
|
32
32
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
33
33
|
import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, standInGapNotes, waitForOnboarding } from './verify-onboarding.js';
|
|
34
34
|
const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
|
|
@@ -172,6 +172,14 @@ function checkPlanningCoverage(value) {
|
|
|
172
172
|
invalid('its unresolved changes');
|
|
173
173
|
changes.add(item.changeKey);
|
|
174
174
|
}
|
|
175
|
+
if ('incidentExecution' in value) {
|
|
176
|
+
if (value.incidentExecution !== 'incident-exploration-v1' || !isCount(value.coveredSituations) || !isCount(value.unfinishedSituations)
|
|
177
|
+
|| value.completedSituations !== value.coveredSituations || value.unrealizedSituations !== 0
|
|
178
|
+
|| value.coveredSituations + value.blockedSituations + value.unfinishedSituations !== value.selectedSituations)
|
|
179
|
+
invalid('its exploration coverage');
|
|
180
|
+
}
|
|
181
|
+
else if ('coveredSituations' in value || 'unfinishedSituations' in value)
|
|
182
|
+
invalid('exploration counts without execution mode');
|
|
175
183
|
}
|
|
176
184
|
/** The published results' shape, shared by the sealed manifest and (amendment 9) an answer; `what` names which. */
|
|
177
185
|
function checkResults(value, view, what) {
|
|
@@ -456,7 +464,13 @@ export function formatCrawl(view) {
|
|
|
456
464
|
lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
|
|
457
465
|
const planning = published.reach.planning;
|
|
458
466
|
if (planning !== undefined) {
|
|
459
|
-
lines.push('', chalk.bold('Situations considered'), ` Considered ${plural(planning.groups, 'group')} of related behavior, with ${plural(planning.selectedIncidentPairs, 'possible failure pattern')} selected across them.`, ` ${planning.selectedSituations} of ${plural(planning.proposedSituations, 'proposed situation')} selected; ${planning.deferredSituations} deferred
|
|
467
|
+
lines.push('', chalk.bold('Situations considered'), ` Considered ${plural(planning.groups, 'group')} of related behavior, with ${plural(planning.selectedIncidentPairs, 'possible failure pattern')} selected across them.`, ` ${planning.selectedSituations} of ${plural(planning.proposedSituations, 'proposed situation')} selected; ${planning.deferredSituations} deferred.`);
|
|
468
|
+
if (planning.incidentExecution === 'incident-exploration-v1') {
|
|
469
|
+
lines.push(` Browser evidence covers ${plural(planning.coveredSituations, 'exploration goal')}; ${planning.blockedSituations} blocked; ${planning.unfinishedSituations} unfinished.`, ` Could not plan goals for ${plural(planning.unplannedIncidentPairs, 'selected failure pattern')}.`, ' Covered goals were exercised and reviewed; coverage does not mean the product passed.');
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
lines.push(` Actions completed for ${plural(planning.completedSituations, 'situation')}; ${planning.blockedSituations} blocked during execution.`, ` Could not plan checks for ${plural(planning.unplannedIncidentPairs, 'selected failure pattern')}; ${plural(planning.unrealizedSituations, 'selected situation')} had no executable route.`, ' Completed actions do not establish that a situation is free of bugs.');
|
|
473
|
+
}
|
|
460
474
|
if (planning.sourceGraphTruncated)
|
|
461
475
|
lines.push(' The recorded code relationships were incomplete.');
|
|
462
476
|
if (planning.unresolvedChanges.length) {
|
|
@@ -492,6 +506,14 @@ export function crawlExitCode(view) {
|
|
|
492
506
|
return 0;
|
|
493
507
|
}
|
|
494
508
|
/* ---------------------------------------------------------------- command */
|
|
509
|
+
/** --pool: fleet, freestyle or auto (CRAWL-V1 amendment 16). */
|
|
510
|
+
function poolOption(value) {
|
|
511
|
+
if (value === undefined)
|
|
512
|
+
return undefined;
|
|
513
|
+
if (!CRAWL_POOLS.includes(value))
|
|
514
|
+
throw new Error(`--pool must be one of ${CRAWL_POOLS.join(', ')}.`);
|
|
515
|
+
return value;
|
|
516
|
+
}
|
|
495
517
|
/** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
|
|
496
518
|
function minutesOption(value) {
|
|
497
519
|
if (value === undefined)
|
|
@@ -697,7 +719,7 @@ export async function verifyCommand(options) {
|
|
|
697
719
|
console.error(line);
|
|
698
720
|
else
|
|
699
721
|
console.log(line); };
|
|
700
|
-
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes));
|
|
722
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes), poolOption(options.pool));
|
|
701
723
|
const request = capture.derivation.request;
|
|
702
724
|
const change = checkedChange(request.baseCommit, request.workCommit);
|
|
703
725
|
if (!request.crawl) {
|
|
@@ -723,7 +745,8 @@ export async function verifyCommand(options) {
|
|
|
723
745
|
let view = await readWithRetries(() => readCrawl(identity, auth.token));
|
|
724
746
|
let onboarding = null;
|
|
725
747
|
const stale = view === null ? null : staleCrawl(view, identity);
|
|
726
|
-
|
|
748
|
+
// A pinned pool is its own crawl (amendment 16), which the capture read cannot tell apart: always submit, and follow that run.
|
|
749
|
+
if (view === null || stale !== null || options.pool !== undefined) {
|
|
727
750
|
const found = view === null || stale === null ? null : { view, stale };
|
|
728
751
|
let submitted = await submitCapture(capture, auth.token, found);
|
|
729
752
|
note(submitted.line);
|
package/dist/index.js
CHANGED
|
@@ -230,6 +230,7 @@ const verify = program
|
|
|
230
230
|
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
231
231
|
.option('--no-wait', 'Print the crawl\'s current state and return without waiting')
|
|
232
232
|
.option('--minutes <n>', 'How long the crawl may take, 1-30 (default: .haystack.json crawl.minutes, else 3)')
|
|
233
|
+
.addOption(new Option('--pool <pool>', 'Where the crawl\'s clones are created: fleet, freestyle or auto (default auto)').hideHelp())
|
|
233
234
|
.option('--json', 'The crawl as one JSON document on stdout (see `haystack schema verify`)')
|
|
234
235
|
.addHelpText('after', `
|
|
235
236
|
Run inside a git checkout; nothing needs to be committed or pushed. It captures
|