@haystackeditor/cli 0.18.0 → 0.19.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 CHANGED
@@ -106,8 +106,11 @@ acknowledged; it never shows a crawl of another revision or another title.
106
106
  A crawl builds the app with and without your change, starts from the changed
107
107
  code, reaches it in the running app, explores outward with both builds side by
108
108
  side, and double-checks and judges every difference. `haystack verify` waits
109
- until the crawl finishes, printing each step as it happens; there is no time
110
- limit, and Ctrl-C stops waiting, never the crawl. It then prints:
109
+ for the crawl, printing each step as it happens and each bug the moment the
110
+ crawl finds it (each read is held by the service until the crawl changes, so
111
+ nothing waits on a polling interval). It finishes when the crawl answers, as
112
+ soon as its time is up, without waiting for its machines to shut down; there
113
+ is no time limit, and Ctrl-C stops waiting, never the crawl. It then prints:
111
114
 
112
115
  - a headline: how many bugs it found, none, or why the crawl could not finish;
113
116
  - **Where your change shows up in the app**: every changed spot as
@@ -120,14 +123,15 @@ limit, and Ctrl-C stops waiting, never the crawl. It then prints:
120
123
  - **What the crawl never ran**: changed files no path ran, and how many of the
121
124
  functions your change affects ran.
122
125
 
123
- `--no-wait` prints the crawl's current state and returns. `--interval
124
- <seconds>` sets the polling interval (default 5), `--account <login>` picks a
125
- saved account, and `--repo owner/repo` names the repository when `origin` does
126
- not. `--json` prints one document, `{ "schema_version", "crawl" }`, where
127
- `crawl` is the crawl as the service returns it (or `null` when no crawl of the
128
- capture could be started); `haystack schema verify` prints its schema. Exit codes
129
- follow `haystack case-batch status`: 0 when the crawl finished (the bugs it
130
- found are in the output) or is still running under `--no-wait`; 2 when it ended
126
+ `--no-wait` prints the crawl's current state and returns. `--account <login>`
127
+ picks a saved account, and `--repo owner/repo` names the repository when
128
+ `origin` does not. `--json` prints one document, `{ "schema_version", "crawl" }`,
129
+ where `crawl` is the crawl as the service returns it, with its `answer` (what it
130
+ found so far, or its answer once its time is up) while no sealed `manifest`
131
+ exists (or `null` when no crawl of the capture could be started);
132
+ `haystack schema verify` prints its schema. Exit codes follow
133
+ `haystack case-batch status`: 0 when the crawl answered or finished (the bugs
134
+ it found are in the output) or is still running under `--no-wait`; 2 when it ended
131
135
  without finishing (stopped early, or cancelled because a newer stop in the
132
136
  repository replaced it) or the machines it used could not be proven shut down;
133
137
  1 when the command failed or no crawl could be started.
@@ -90,6 +90,8 @@ function requireRunId(runId) {
90
90
  }
91
91
  return runId;
92
92
  }
93
+ /** How long any gateway request may take unless its caller says otherwise. */
94
+ export const GATEWAY_TIMEOUT_MS = 120_000;
93
95
  /** Every outbound request. The header set is built here and nowhere else, so
94
96
  * the credential surface of this command is one line: the login bearer.
95
97
  * `haystack verify` reads crawls through this same client. */
@@ -104,7 +106,7 @@ export async function gatewayFetch(path, token, init = { method: 'GET' }) {
104
106
  method: init.method,
105
107
  headers,
106
108
  ...(init.body === undefined ? {} : { body: init.body }),
107
- signal: AbortSignal.timeout(120_000),
109
+ signal: AbortSignal.timeout(init.timeoutMs ?? GATEWAY_TIMEOUT_MS),
108
110
  });
109
111
  }
110
112
  async function gatewayJson(path, token, init = { method: 'GET' }) {
@@ -1,3 +1,9 @@
1
1
  export const CRAWL_TITLE_MAX_CHARS = 200;
2
+ /** Amendment 8: the time a crawl may be asked to take, whole seconds from 1 to 30 minutes. */
3
+ export const CRAWL_BUDGET_MIN_MS = 60_000;
4
+ export const CRAWL_BUDGET_MAX_MS = 30 * 60_000;
2
5
  export const CRAWL_MAX_FINDINGS = 50;
3
6
  export const CRAWL_MAX_FINDING_STEPS = 64;
7
+ /** Amendment 9: what a crawl has found so far (`exploring`) or its answer at the budget (`answered`), before the sealed manifest;
8
+ * findings carry no images. A read may pass `waitAfter=<updatedAt>` to be held until the crawl changes (at most CRAWL_WAIT_MAX_MS). */
9
+ export const CRAWL_WAIT_MAX_MS = 25_000;
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
6
+ import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
7
7
  const requestFile = 'request.json';
8
8
  const receiptFile = 'receipt.json';
9
9
  const deliverySchema = 'precompute-delivery-v1';
@@ -150,10 +150,13 @@ function validateRequest(value) {
150
150
  throw new Error('Invalid captured crawl submission.');
151
151
  return;
152
152
  }
153
- if (!object(crawl) || !onlyKeys(crawl, ['treeSha', 'changeTitle'])
153
+ // Amendment 8: budgetMs only when the capture asked for a time, whole seconds from 1 to 30 minutes.
154
+ if (!object(crawl) || !onlyKeys(crawl, 'budgetMs' in crawl ? ['treeSha', 'changeTitle', 'budgetMs'] : ['treeSha', 'changeTitle'])
154
155
  || !characters(crawl.treeSha, '0123456789abcdef', 40)
155
156
  || request.cacheKey.split('-')[2] !== crawl.treeSha.slice(0, 16)
156
- || typeof crawl.changeTitle !== 'string' || crawl.changeTitle.length > CRAWL_TITLE_MAX_CHARS) {
157
+ || typeof crawl.changeTitle !== 'string' || crawl.changeTitle.length > CRAWL_TITLE_MAX_CHARS
158
+ || ('budgetMs' in crawl && (typeof crawl.budgetMs !== 'number' || !Number.isInteger(crawl.budgetMs) || crawl.budgetMs % 1000 !== 0
159
+ || crawl.budgetMs < CRAWL_BUDGET_MIN_MS || crawl.budgetMs > CRAWL_BUDGET_MAX_MS))) {
157
160
  throw new Error('Invalid captured crawl submission.');
158
161
  }
159
162
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn, execFileSync } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { closeSync, constants, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { closeSync, constants, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { createGzip } from 'node:zlib';
5
5
  import { isAbsolute, join, resolve } from 'node:path';
6
6
  import { tmpdir } from 'node:os';
@@ -12,7 +12,7 @@ import { HaystackApiError, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_RE
12
12
  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
- import { CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
15
+ import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
16
16
  // --hook has five seconds to capture the exact source and publish local custody.
17
17
  // Its detached sender owns the bounded network delivery and acknowledgement.
18
18
  // An explicit invocation still waits synchronously for the server response.
@@ -259,7 +259,9 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
259
259
  child.stdout.pipe(gzip);
260
260
  });
261
261
  }
262
- export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS) {
262
+ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS,
263
+ /** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
264
+ budgetMs) {
263
265
  // Capture this before git and compression work so delivery latency cannot
264
266
  // make an older stop look newer than a later one.
265
267
  const observedAt = Date.now();
@@ -287,7 +289,7 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
287
289
  cacheKey,
288
290
  patchGzBase64: patchGzip.toString('base64'),
289
291
  patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
290
- ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle } }),
292
+ ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
291
293
  },
292
294
  };
293
295
  }
@@ -452,14 +454,31 @@ function printResult(result) {
452
454
  }
453
455
  /** The hook, an explicit precompute and `haystack verify` all capture here.
454
456
  * The repository defaults to origin's, as the hook's does. */
455
- export async function captureCheckout(deadline, repository) {
457
+ /** The repository's crawl time: .haystack.json crawl.minutes at the git root, whole minutes from 1 to 30 (CRAWL-V1 amendment 8),
458
+ * or undefined when the file or the field is absent. A value out of range fails the capture, never silently becomes the default. */
459
+ function configuredCrawlMinutes(gitRoot) {
460
+ const path = join(gitRoot, '.haystack.json');
461
+ if (!existsSync(path))
462
+ return undefined;
463
+ const minutes = JSON.parse(readFileSync(path, 'utf8')).crawl?.minutes;
464
+ if (minutes === undefined)
465
+ return undefined;
466
+ if (typeof minutes !== 'number' || !Number.isInteger(minutes) || minutes * 60_000 < CRAWL_BUDGET_MIN_MS || minutes * 60_000 > CRAWL_BUDGET_MAX_MS) {
467
+ throw new PrecomputeFailure(`.haystack.json crawl.minutes must be a whole number from ${CRAWL_BUDGET_MIN_MS / 60_000} to ${CRAWL_BUDGET_MAX_MS / 60_000}.`);
468
+ }
469
+ return minutes;
470
+ }
471
+ export async function captureCheckout(deadline, repository,
472
+ /** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
473
+ minutes) {
456
474
  const gitRoot = findGitRoot();
457
475
  if (!gitRoot) {
458
476
  throw new PrecomputeFailure('Run this command from a git repository.');
459
477
  }
460
478
  const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
461
479
  const target = repository ?? resolveOriginRepository(gitRoot);
462
- const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline);
480
+ const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
481
+ const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
463
482
  return { metadataDirectory, derivation };
464
483
  }
465
484
  /** The explicit (non-hook) submission of a capture: one synchronous
@@ -8,20 +8,22 @@
8
8
  * precompute` sends when there is none, or when the one found ended cancelled
9
9
  * or incomplete or is being cancelled (the service runs it again). It follows
10
10
  * the acknowledged run by its id. It then waits (no wall-time cap: Ctrl-C
11
- * stops waiting, never the crawl) and prints where the change shows up in the
12
- * running app and what the crawl found. The hosted fleet run of pushed
13
- * commits is `haystack verify hosted start|status`.
11
+ * stops waiting, never the crawl), each read held by the service until the
12
+ * crawl changes (amendment 9), printing each bug as the crawl's answers bring
13
+ * it; it stops at the crawl's answer at its budget, or when the crawl ends, and
14
+ * prints where the change shows up in the running app and what the crawl found.
15
+ * The hosted fleet run of pushed commits is `haystack verify hosted start|status`.
14
16
  */
15
17
  import chalk from 'chalk';
16
18
  import { withSchema } from '../schema.js';
17
19
  import { resolveAuthContext } from '../utils/auth.js';
18
20
  import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
19
- import { gatewayFetch } from './case-batch.js';
20
- import { CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, } from './crawl-contract.js';
21
+ import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
22
+ 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
23
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
22
24
  const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
23
- const DEFAULT_INTERVAL_SECONDS = 5;
24
- const MAX_INTERVAL_SECONDS = 300;
25
+ /** A held read (waitAfter): the service's hold, then the time any read gets. */
26
+ const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
25
27
  const RUN_ID = /^cv_[0-9a-f]{48}$/;
26
28
  const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
27
29
  const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
@@ -136,21 +138,21 @@ function checkSpot(value) {
136
138
  || (reached.page !== null && typeof reached.page !== 'string')))
137
139
  invalid('how a changed spot was reached');
138
140
  }
139
- function checkManifest(value, view) {
141
+ /** The published results' shape, shared by the sealed manifest and (amendment 9) an answer; `what` names which. */
142
+ function checkResults(value, view, what) {
140
143
  if (!isRecord(value) || value.schemaVersion !== 1)
141
- invalid('its results');
142
- // The published results belong to exactly this crawl and this revision.
144
+ invalid(what);
145
+ // Published results belong to exactly this crawl and this revision.
143
146
  if (value.runId !== view.runId || value.repository !== view.repository || value.baseCommit !== view.baseCommit
144
- || value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle
145
- || value.status !== view.status) {
146
- invalid('results that belong to a different crawl');
147
+ || value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle) {
148
+ invalid(`${what} from a different crawl`);
147
149
  }
148
150
  if (typeof value.attemptId !== 'string' || !isCount(value.bugs) || !isCount(value.notFinishedInTime))
149
- invalid('its results');
151
+ invalid(what);
150
152
  if (value.worlds !== null && (!isRecord(value.worlds) || typeof value.worlds.base !== 'string'
151
153
  || typeof value.worlds.head !== 'string'))
152
- invalid('its results');
153
- checkReuse(value.reuse, 'its results');
154
+ invalid(what);
155
+ checkReuse(value.reuse, what);
154
156
  if (!isRecord(value.timings) || !Object.values(value.timings)
155
157
  .every(ms => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0))
156
158
  invalid('its stage timings');
@@ -170,6 +172,18 @@ function checkManifest(value, view) {
170
172
  if (value.error !== null)
171
173
  checkError(value.error, 'its error');
172
174
  }
175
+ function checkManifest(value, view) {
176
+ checkResults(value, view, 'its results');
177
+ if (value.status !== view.status)
178
+ invalid('its results from a different crawl');
179
+ }
180
+ /** Amendment 9: what the crawl found so far, or its answer at the budget: the manifest's shape, of this same crawl. */
181
+ function checkAnswer(value, view) {
182
+ checkResults(value, view, 'its answer');
183
+ if ((value.status !== 'exploring' && value.status !== 'answered') || !Number.isSafeInteger(value.n) || value.n < 1
184
+ || !isCount(value.atSeconds))
185
+ invalid('its answer');
186
+ }
173
187
  /** Accepts only a well-formed crawl of exactly this capture's revision. Another
174
188
  * revision's crawl is refused, never shown. The change title is not checked
175
189
  * here: the newest crawl of a revision may carry another title, which the
@@ -209,7 +223,19 @@ export function parseCrawlView(value, expected) {
209
223
  invalid('results before it finished');
210
224
  checkManifest(value.manifest, value);
211
225
  }
212
- return value;
226
+ // Amendment 8: a service from before it sends no budgetMs, and every crawl it ran was asked for the default (Astra review).
227
+ if (value.budgetMs !== undefined && value.budgetMs !== null && (typeof value.budgetMs !== 'number' || !Number.isInteger(value.budgetMs)
228
+ || value.budgetMs < CRAWL_BUDGET_MIN_MS || value.budgetMs > CRAWL_BUDGET_MAX_MS))
229
+ invalid('its time');
230
+ // Amendment 9: a service from before it sends no answer. An answer stands in for results not yet sealed, never beside them.
231
+ let answer = null;
232
+ if (value.answer !== undefined && value.answer !== null) {
233
+ if (value.manifest !== null)
234
+ invalid('an answer beside its sealed results');
235
+ checkAnswer(value.answer, value);
236
+ answer = value.answer;
237
+ }
238
+ return { ...value, budgetMs: value.budgetMs ?? null, answer };
213
239
  }
214
240
  /* ------------------------------------------------------------------- read */
215
241
  /** The change title is part of a crawl's identity and informs its judge: a
@@ -225,12 +251,19 @@ async function responseBody(response) {
225
251
  throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
226
252
  }
227
253
  }
254
+ /** A read, held by the service when `waitAfter` (the updatedAt the caller holds) is given: until the
255
+ * crawl's updatedAt changes, or CRAWL_WAIT_MAX_MS passes (amendment 9). */
256
+ function read(path, token, waitAfter) {
257
+ return waitAfter === undefined
258
+ ? gatewayFetch(path, token)
259
+ : gatewayFetch(`${path}&waitAfter=${encodeURIComponent(waitAfter)}`, token, { method: 'GET', timeoutMs: HELD_READ_TIMEOUT_MS });
260
+ }
228
261
  /** The caller's newest crawl of this capture's revision, or null. Its change
229
262
  * title may differ from the capture's (sameChange). */
230
- async function readCrawl(identity, token) {
263
+ async function readCrawl(identity, token, waitAfter) {
231
264
  const path = `${CRAWLS_PATH}?repository=${encodeURIComponent(identity.repository)}`
232
265
  + `&workCommit=${encodeURIComponent(identity.workCommit)}`;
233
- const response = await gatewayFetch(path, token);
266
+ const response = await read(path, token, waitAfter);
234
267
  if (!response.ok)
235
268
  throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}`);
236
269
  const body = await responseBody(response);
@@ -239,10 +272,10 @@ async function readCrawl(identity, token) {
239
272
  return body.crawl === null ? null : parseCrawlView(body.crawl, identity);
240
273
  }
241
274
  /** One crawl by its run id; it must be exactly this capture, change title included. */
242
- async function readCrawlRun(runId, identity, token) {
275
+ async function readCrawlRun(runId, identity, token, waitAfter) {
243
276
  if (!RUN_ID.test(runId))
244
277
  invalid('an acknowledged run id');
245
- const response = await gatewayFetch(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token);
278
+ const response = await read(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token, waitAfter);
246
279
  if (!response.ok)
247
280
  throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}/${runId}`);
248
281
  const view = parseCrawlView(await responseBody(response), identity);
@@ -270,14 +303,23 @@ function errorWords(error) {
270
303
  function currentStep(view) {
271
304
  return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
272
305
  }
306
+ /** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
307
+ function results(view) {
308
+ return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
309
+ }
310
+ /** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
311
+ * done; only its shutdown remains) reads as completed. */
312
+ function reportedStatus(view) {
313
+ return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
314
+ }
273
315
  function headline(view) {
274
- const bugs = view.manifest?.bugs ?? view.totals.bugs;
316
+ const bugs = results(view)?.bugs ?? view.totals.bugs;
275
317
  const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
276
- switch (view.status) {
318
+ switch (reportedStatus(view)) {
277
319
  case 'completed':
278
320
  return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
279
321
  case 'incomplete': {
280
- const error = view.manifest?.error ?? view.error;
322
+ const error = results(view)?.error ?? view.error;
281
323
  return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
282
324
  }
283
325
  case 'cancelled':
@@ -311,15 +353,17 @@ function findingLines(finding, position) {
311
353
  return lines;
312
354
  }
313
355
  export function formatCrawl(view) {
356
+ const status = reportedStatus(view);
314
357
  const lines = [chalk.bold(headline(view))];
315
- const error = view.manifest?.error ?? view.error;
316
- if (error && view.status !== 'completed')
358
+ const published = results(view);
359
+ const error = published?.error ?? view.error;
360
+ if (error && status !== 'completed')
317
361
  lines.push(chalk.dim(` Details: ${safe(error.message)}`));
318
- lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(` Crawl ${view.runId} of ${safe(view.repository)}: base ${view.baseCommit.slice(0, 12)},`
362
+ lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(` Time: ${view.clocks.crawlBudgetMs > 0 ? `${Math.round(view.clocks.crawlBudgetMs / 1000)} s` : timeWords(view.budgetMs)}`
363
+ + `${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
364
  + ` your change ${view.workCommit.slice(0, 12)}.`));
320
- const manifest = view.manifest;
321
- if (manifest === null) {
322
- lines.push('', TERMINAL.has(view.status)
365
+ if (published === null) {
366
+ lines.push('', TERMINAL.has(status)
323
367
  ? 'No results were published for this crawl.'
324
368
  : 'Where your change shows up in the app, and what the crawl found, appear when it finishes.');
325
369
  return lines.join('\n');
@@ -330,55 +374,60 @@ export function formatCrawl(view) {
330
374
  return lines.join('\n');
331
375
  }
332
376
  lines.push('', chalk.bold('Where your change shows up in the app'));
333
- if (manifest.reach.spots.length === 0)
377
+ if (published.reach.spots.length === 0)
334
378
  lines.push(' No changed spots were recorded.');
335
- for (const spot of manifest.reach.spots)
379
+ for (const spot of published.reach.spots)
336
380
  lines.push(...spotLines(spot));
337
381
  lines.push('', chalk.bold('What the crawl found'));
338
382
  // Bugs first, then the undecided, then what the judge called intended.
339
- const findings = VERDICTS.flatMap(verdict => manifest.findings.filter(finding => finding.verdict === verdict));
383
+ const findings = VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict));
340
384
  if (findings.length === 0)
341
385
  lines.push(' No confirmed differences between the app with and without your change.');
342
386
  findings.forEach((finding, index) => lines.push(...findingLines(finding, index + 1)));
343
- if (manifest.notFinishedInTime > 0) {
344
- lines.push(` ${plural(manifest.notFinishedInTime, 'difference')} ${manifest.notFinishedInTime === 1 ? 'was' : 'were'}`
387
+ if (published.notFinishedInTime > 0) {
388
+ lines.push(` ${plural(published.notFinishedInTime, 'difference')} ${published.notFinishedInTime === 1 ? 'was' : 'were'}`
345
389
  + ' still being checked when its time ran out.');
346
390
  }
347
391
  lines.push('', chalk.bold('What the crawl never ran'));
348
- if (manifest.reach.unreachedChangedFiles.length === 0)
392
+ if (published.reach.unreachedChangedFiles.length === 0)
349
393
  lines.push(' Every changed file ran at least once.');
350
394
  else {
351
395
  lines.push(' Changed files no path ran:');
352
- for (const file of manifest.reach.unreachedChangedFiles)
396
+ for (const file of published.reach.unreachedChangedFiles)
353
397
  lines.push(` ${safe(file)}`);
354
398
  }
355
- const blast = manifest.reach.blast;
399
+ const blast = published.reach.blast;
356
400
  if (blast !== null)
357
401
  lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
358
- if (view.totals.cleanupUnproven > 0) {
402
+ // Only a finished crawl has proven its cleanup: an answered one is still shutting its machines down (Bugbot).
403
+ if (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0) {
359
404
  lines.push('', chalk.yellow(` ${plural(view.totals.cleanupUnproven, 'machine')} it used could not be proven shut down.`));
360
405
  }
361
406
  return lines.join('\n');
362
407
  }
363
- /** Case-batch status conventions: 0 finished, or still running with --no-wait;
364
- * 2 ended without finishing (incomplete, cancelled) or cleanup unproven; bugs
408
+ /** Case-batch status conventions: 0 finished (or answered: amendment 9, its work
409
+ * is done and only its shutdown remains), or still running with --no-wait;
410
+ * 2 ended without finishing (incomplete, cancelled) or finished with cleanup
411
+ * unproven (an answered crawl has not finished its cleanup, so it is not judged); bugs
365
412
  * found never change it. The command's own failures exit 1 elsewhere. */
366
413
  export function crawlExitCode(view) {
367
- if (!TERMINAL.has(view.status))
414
+ const status = reportedStatus(view);
415
+ if (!TERMINAL.has(status))
368
416
  return 0;
369
- if (view.status !== 'completed' || view.totals.cleanupUnproven > 0)
417
+ if (status !== 'completed' || (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0))
370
418
  return 2;
371
419
  return 0;
372
420
  }
373
421
  /* ---------------------------------------------------------------- command */
374
- function intervalMs(value) {
422
+ /** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
423
+ function minutesOption(value) {
375
424
  if (value === undefined)
376
- return DEFAULT_INTERVAL_SECONDS * 1000;
377
- const seconds = Number(value);
378
- if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_INTERVAL_SECONDS) {
379
- throw new Error(`--interval must be a positive number no greater than ${MAX_INTERVAL_SECONDS}.`);
425
+ return undefined;
426
+ const minutes = Number(value);
427
+ if (!Number.isInteger(minutes) || minutes * 60_000 < CRAWL_BUDGET_MIN_MS || minutes * 60_000 > CRAWL_BUDGET_MAX_MS) {
428
+ throw new Error(`--minutes must be a whole number from ${CRAWL_BUDGET_MIN_MS / 60_000} to ${CRAWL_BUDGET_MAX_MS / 60_000}.`);
380
429
  }
381
- return seconds * 1000;
430
+ return minutes;
382
431
  }
383
432
  function repositoryOverride(value) {
384
433
  if (value === undefined)
@@ -389,15 +438,39 @@ function repositoryOverride(value) {
389
438
  }
390
439
  return { owner: parts[0], repository: parts[1] };
391
440
  }
392
- function delay(milliseconds) {
393
- return new Promise(resolve => setTimeout(resolve, milliseconds));
441
+ function clock() {
442
+ return new Date().toTimeString().slice(0, 8);
394
443
  }
395
444
  function progress(line) {
396
- console.error(chalk.dim(`${new Date().toTimeString().slice(0, 8)} ${line}`));
445
+ console.error(chalk.dim(`${clock()} ${line}`));
446
+ }
447
+ function clip(text, maximum = 60) {
448
+ return text.length <= maximum ? text : `${text.slice(0, maximum - 1)}…`;
449
+ }
450
+ /** A finding's steps in one line: where it starts, then its steps, the middle of a long path elided. */
451
+ function shortSteps(finding) {
452
+ const steps = finding.steps.map(step => clip(safe(step)));
453
+ const shown = steps.length <= 4 ? steps : [steps[0], `(${steps.length - 3} more steps)`, ...steps.slice(-2)];
454
+ return [`from ${clip(safe(finding.start))}`, ...shown].join(' > ');
397
455
  }
398
- /** Polls the same capture until its crawl is terminal. No wall-time cap: an
399
- * interrupt stops waiting and leaves the crawl running. */
400
- async function waitForCrawl(first, identity, token, everyMs) {
456
+ /** Prints each bug the view's answer brings that was not printed before (by finding id), one line each. */
457
+ function printNewBugs(view, printed) {
458
+ const answer = view.answer;
459
+ if (answer === null)
460
+ return;
461
+ for (const finding of answer.findings) {
462
+ // Keyed by run and attempt too: another run or a retried attempt may reuse a finding id (Astra review).
463
+ const key = `${view.runId}\0${answer.attemptId}\0${finding.id}`;
464
+ if (finding.verdict !== 'bug' || printed.has(key))
465
+ continue;
466
+ printed.add(key);
467
+ console.error(`${chalk.dim(clock())} ${chalk.red(VERDICT_WORDS.bug)}: ${shortSteps(finding)}`);
468
+ }
469
+ }
470
+ /** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
471
+ * service until the crawl changes, so there is no pause between reads. No wall-time cap: an interrupt stops waiting
472
+ * and leaves the crawl running. */
473
+ async function waitForCrawl(first, identity, token) {
401
474
  const interrupted = () => {
402
475
  console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
403
476
  process.exit(130);
@@ -405,21 +478,27 @@ async function waitForCrawl(first, identity, token, everyMs) {
405
478
  process.once('SIGINT', interrupted);
406
479
  try {
407
480
  let view = first;
481
+ const printed = new Set();
408
482
  progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
409
483
  progress(currentStep(view));
410
- while (!TERMINAL.has(view.status)) {
411
- await delay(everyMs);
412
- let next = await readCrawl(identity, token);
413
- if (next === null)
414
- throw new CrawlResponseError(`The crawl service no longer returns crawl ${view.runId} for this change.`);
415
- // A newer crawl of the same code under another title is another change's: keep following this one.
416
- if (!sameChange(next, identity))
417
- next = await readCrawlRun(view.runId, identity, token);
484
+ printNewBugs(view, printed);
485
+ while (!TERMINAL.has(reportedStatus(view))) {
486
+ // 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
+ // 25 s on another title's crawl of the same code).
488
+ let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
489
+ // A newer stop of the same change supersedes this crawl: once it is superseded or being cancelled, the capture's newest crawl
490
+ // is read at once (not held) and followed if it is this same change.
491
+ if (next.supersededBy !== null || next.cancelRequested) {
492
+ const newest = await readCrawl(identity, token);
493
+ if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
494
+ next = newest;
495
+ }
418
496
  if (next.runId !== view.runId)
419
497
  progress(`A newer crawl of this same change took over (${next.runId}).`);
420
- if (!TERMINAL.has(next.status) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
498
+ if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
421
499
  progress(currentStep(next));
422
500
  }
501
+ printNewBugs(next, printed);
423
502
  view = next;
424
503
  }
425
504
  return view;
@@ -428,9 +507,14 @@ async function waitForCrawl(first, identity, token, everyMs) {
428
507
  process.removeListener('SIGINT', interrupted);
429
508
  }
430
509
  }
510
+ /** A found crawl of the same code that is a different crawl (another title, another time): a new one starts, none is run again. */
511
+ const anotherCrawl = (stale) => stale === 'retitled' || stale === 'other-time';
512
+ const timeWords = (budgetMs) => budgetMs === null ? 'the default time' : `${budgetMs / 60_000} min`;
431
513
  function staleCrawl(found, identity) {
432
514
  if (!sameChange(found, identity))
433
515
  return 'retitled';
516
+ if (found.budgetMs !== identity.budgetMs)
517
+ return 'other-time';
434
518
  if (found.status === 'cancelled' || found.status === 'incomplete')
435
519
  return found.status;
436
520
  return !TERMINAL.has(found.status) && found.cancelRequested ? 'cancelling' : null;
@@ -438,6 +522,7 @@ function staleCrawl(found, identity) {
438
522
  function staleWords(found, stale) {
439
523
  switch (stale) {
440
524
  case 'retitled': return `The last crawl of this code ran under another title, "${safe(found.changeTitle)}" (${found.runId})`;
525
+ case 'other-time': return `The last crawl of this code was asked for ${timeWords(found.budgetMs)} (${found.runId})`;
441
526
  case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
442
527
  case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
443
528
  case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
@@ -475,7 +560,7 @@ async function submitCapture(capture, token, found) {
475
560
  ? `No crawl existed for this change yet; started one (${crawl.runId}).`
476
561
  : `The service already had a crawl for this change (${crawl.runId}).` };
477
562
  }
478
- if (found.stale !== 'retitled' && crawl.runId === found.view.runId) {
563
+ if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
479
564
  return { runId: crawl.runId, line: `${before}; asked the service to run it again.` };
480
565
  }
481
566
  return { runId: crawl.runId, line: crawl.status === 'queued'
@@ -483,13 +568,12 @@ async function submitCapture(capture, token, found) {
483
568
  : `${before}; the service already had one for this change (${crawl.runId}).` };
484
569
  }
485
570
  export async function verifyCommand(options) {
486
- const everyMs = intervalMs(options.interval);
487
571
  // In --json mode stdout carries the one document; every other line goes to stderr.
488
572
  const note = (line) => { if (options.json)
489
573
  console.error(line);
490
574
  else
491
575
  console.log(line); };
492
- const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo));
576
+ const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
493
577
  const request = capture.derivation.request;
494
578
  if (!request.crawl) {
495
579
  note(`Nothing to verify: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
@@ -503,6 +587,7 @@ export async function verifyCommand(options) {
503
587
  workCommit: request.workCommit,
504
588
  treeSha: request.crawl.treeSha,
505
589
  changeTitle: request.crawl.changeTitle,
590
+ budgetMs: request.crawl.budgetMs ?? null,
506
591
  };
507
592
  const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
508
593
  let view = await readCrawl(identity, auth.token);
@@ -518,12 +603,12 @@ export async function verifyCommand(options) {
518
603
  return;
519
604
  }
520
605
  view = await readCrawlRun(submitted.runId, identity, auth.token);
521
- if (found !== null && found.stale !== 'retitled' && view.runId === found.view.runId && TERMINAL.has(view.status)) {
606
+ if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
522
607
  note('The service did not run it again; its last result follows.');
523
608
  }
524
609
  }
525
- if (options.wait !== false && !TERMINAL.has(view.status)) {
526
- view = await waitForCrawl(view, identity, auth.token, everyMs);
610
+ if (options.wait !== false && !TERMINAL.has(reportedStatus(view))) {
611
+ view = await waitForCrawl(view, identity, auth.token);
527
612
  }
528
613
  if (options.json)
529
614
  process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: view }), null, 2)}\n`);
package/dist/index.js CHANGED
@@ -208,7 +208,7 @@ const verify = program
208
208
  .option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
209
209
  .option('--account <login>', 'Use a specific saved Haystack account')
210
210
  .option('--no-wait', 'Print the crawl\'s current state and return without waiting')
211
- .option('--interval <seconds>', 'Polling interval while waiting (default 5)')
211
+ .option('--minutes <n>', 'How long the crawl may take, 1-30 (default: .haystack.json crawl.minutes, else 3)')
212
212
  .option('--json', 'The crawl as one JSON document on stdout (see `haystack schema verify`)')
213
213
  .addHelpText('after', `
214
214
  Run inside a git checkout; nothing needs to be committed or pushed. It captures
@@ -222,15 +222,20 @@ again), says so, and follows that crawl.
222
222
 
223
223
  A crawl builds the app with and without your change, starts from the changed
224
224
  code, explores outward with both side by side, and double-checks and judges
225
- every difference. The command waits until the crawl finishes, printing each
226
- step; Ctrl-C stops waiting, never the crawl. It then prints where your change
227
- shows up in the app (each changed spot, how far the crawl got with it, and the
228
- steps that reached it), what the crawl found (bugs first, with the steps to see
229
- each), and the changed code it never ran.
225
+ every difference it can within its time: 3 minutes unless the repository's
226
+ .haystack.json sets crawl.minutes or --minutes says otherwise (1 to 30).
227
+ Differences it had no time to check are listed as not finished in time. The
228
+ command waits for the crawl, printing each step and each bug the moment the
229
+ crawl finds it; it finishes when the crawl answers, as soon as its time is up,
230
+ without waiting for the crawl's machines to shut down. Ctrl-C stops waiting,
231
+ never the crawl. It then prints where your change shows up in the app (each
232
+ changed spot, how far the crawl got with it, and the steps that reached it),
233
+ what the crawl found (bugs first, with the steps to see each), and the changed
234
+ code it never ran.
230
235
 
231
236
  Exit codes:
232
- 0 the crawl finished (the bugs it found are in the output), or it is
233
- still running and --no-wait was given
237
+ 0 the crawl answered or finished (the bugs it found are in the output), or
238
+ it is still running and --no-wait was given
234
239
  1 the command failed, or no crawl could be started for this change
235
240
  2 the crawl ended without finishing: it stopped early or was cancelled
236
241
  (a newer stop in the repository replaces an older crawl), or the
@@ -241,6 +246,7 @@ The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
241
246
  Examples:
242
247
  haystack verify
243
248
  haystack verify --no-wait
249
+ haystack verify --minutes 10
244
250
  haystack verify --json
245
251
  haystack verify history owner/repo --limit 20
246
252
  haystack verify hosted status cv_<48 lowercase hex characters> --wait
package/dist/types.js CHANGED
@@ -294,6 +294,9 @@ const PreferencesSchema = z.object({
294
294
  export const HaystackConfigSchema = z.object({
295
295
  /** Config version (must be "1") */
296
296
  version: z.literal('1'),
297
+ /** Hosted crawls of this repository (`haystack verify`, the stop hook): how long one may take, whole minutes from 1 to 30.
298
+ * Absent: the service's default (3 minutes). `haystack verify --minutes` overrides it for one run. */
299
+ crawl: z.object({ minutes: z.number().int().min(1).max(30).optional() }).strict().optional(),
297
300
  /** Project name (for display) */
298
301
  name: z.string().optional(),
299
302
  /** Repository-level Haystack behavior */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Set up Haystack for your project — automated PR review, triage, and merge queue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://docs.haystackeditor.com/cli/schemas/verify.v1.json",
4
4
  "title": "Haystack verify: the crawl of the current change (CRAWL-V1)",
5
- "description": "Machine-readable output of `haystack verify --json`: the caller's crawl of the exact captured checkout (its code and its change title), as GET /api/agent/cloud-verifier/crawls?repository=&workCommit= or, for a crawl the command just submitted, GET /api/agent/cloud-verifier/crawls/:runId?repository= returns it, or null when no crawl of the capture could be started. The crawl shapes mirror infra/lambda/haystack-design-verifier-shared/crawl_contracts.ts (with amendment 1); that file wins where they differ.",
5
+ "description": "Machine-readable output of `haystack verify --json`: the caller's crawl of the exact captured checkout (its code and its change title), as GET /api/agent/cloud-verifier/crawls?repository=&workCommit= or, for a crawl the command just submitted, GET /api/agent/cloud-verifier/crawls/:runId?repository= returns it, or null when no crawl of the capture could be started. The crawl shapes mirror infra/lambda/haystack-design-verifier-shared/crawl_contracts.ts (with amendments 1, 8 and 9); that file wins where they differ.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["schema_version", "crawl"],
@@ -97,8 +97,8 @@
97
97
  "findingIds": { "$ref": "#/$defs/strings" }
98
98
  }
99
99
  },
100
- "manifest": {
101
- "description": "Present once the crawl is terminal and published.",
100
+ "results": {
101
+ "description": "The published results' shape, shared by the sealed manifest and an answer; each sets its own status.",
102
102
  "type": "object",
103
103
  "required": ["schemaVersion", "runId", "attemptId", "repository", "baseCommit", "workCommit", "treeSha",
104
104
  "changeTitle", "status", "worlds", "reuse", "timings", "bugs", "findings", "reach", "notFinishedInTime", "error"],
@@ -111,7 +111,6 @@
111
111
  "workCommit": { "$ref": "#/$defs/commit" },
112
112
  "treeSha": { "$ref": "#/$defs/commit" },
113
113
  "changeTitle": { "type": "string", "maxLength": 200 },
114
- "status": { "enum": ["completed", "incomplete", "cancelled"] },
115
114
  "worlds": {
116
115
  "oneOf": [
117
116
  { "type": "null" },
@@ -157,10 +156,27 @@
157
156
  "error": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/error" }] }
158
157
  }
159
158
  },
159
+ "manifest": {
160
+ "description": "Present once the crawl is terminal and published.",
161
+ "allOf": [{ "$ref": "#/$defs/results" }],
162
+ "properties": {
163
+ "status": { "enum": ["completed", "incomplete", "cancelled"] }
164
+ }
165
+ },
166
+ "answer": {
167
+ "description": "CRAWL-V1 amendment 9: the current attempt's latest answer while no sealed manifest exists: what the crawl found so far (exploring), or its answer once its time is up (answered), with the same findings and reach the sealed manifest will carry. Its findings carry no images.",
168
+ "allOf": [{ "$ref": "#/$defs/results" }],
169
+ "required": ["n", "atSeconds"],
170
+ "properties": {
171
+ "status": { "enum": ["exploring", "answered"] },
172
+ "n": { "type": "integer", "minimum": 1, "description": "The attempt's answers are numbered from 1." },
173
+ "atSeconds": { "type": "integer", "minimum": 0, "description": "How far into the crawl stage it was written." }
174
+ }
175
+ },
160
176
  "crawlView": {
161
177
  "type": "object",
162
- "required": ["runId", "repository", "baseCommit", "workCommit", "treeSha", "changeTitle", "observedAt",
163
- "createdAt", "updatedAt", "supersededBy", "cancelRequested", "manifest", "status", "stage", "clocks",
178
+ "required": ["runId", "repository", "baseCommit", "workCommit", "treeSha", "changeTitle", "budgetMs", "observedAt",
179
+ "createdAt", "updatedAt", "supersededBy", "cancelRequested", "manifest", "answer", "status", "stage", "clocks",
164
180
  "reuse", "worlds", "totals"],
165
181
  "properties": {
166
182
  "runId": { "$ref": "#/$defs/runId" },
@@ -169,6 +185,8 @@
169
185
  "workCommit": { "$ref": "#/$defs/commit" },
170
186
  "treeSha": { "$ref": "#/$defs/commit" },
171
187
  "changeTitle": { "type": "string", "maxLength": 200 },
188
+ "budgetMs": { "description": "The time the crawl was asked for (ms), or null for the service's default.",
189
+ "oneOf": [{ "type": "null" }, { "type": "integer", "minimum": 60000, "maximum": 1800000 }] },
172
190
  "observedAt": { "$ref": "#/$defs/count" },
173
191
  "createdAt": { "type": "string", "format": "date-time" },
174
192
  "updatedAt": { "type": "string", "format": "date-time" },
@@ -210,7 +228,9 @@
210
228
  }
211
229
  },
212
230
  "error": { "$ref": "#/$defs/error" },
213
- "manifest": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/manifest" }] }
231
+ "manifest": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/manifest" }] },
232
+ "answer": { "description": "Null once the sealed manifest exists, and before the crawl's first answer.",
233
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/answer" }] }
214
234
  }
215
235
  }
216
236
  }