@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
- export async function readOnboardingStatus(repository, baseCommit, token) {
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
- next = await readOnboardingStatus(status.repository, status.baseCommit, token);
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
- : current === undefined ? 'queued' : STAGE_WORDS[current];
230
- return `Onboarding: ${doing}${done.length ? ` (done: ${done.join(', ')})` : ''}.`;
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
@@ -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.`, ` 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.');
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
- if (view === null || stale !== null) {
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {