@haystackeditor/cli 0.22.1 → 0.23.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.
@@ -7,3 +7,7 @@ export const CRAWL_MAX_FINDING_STEPS = 64;
7
7
  /** Amendment 9: what a crawl has found so far (`exploring`) or its answer at the budget (`answered`), before the sealed manifest;
8
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
9
  export const CRAWL_WAIT_MAX_MS = 25_000;
10
+ /** Amendment 13: the crawl's stand-in coverage, one row per provider, operation, kind and phase with its count in each build
11
+ * (how many times the copy's calls reached the stand-in across the crawl's preparation and all its replays, not user actions). */
12
+ export const CRAWL_MAX_STAND_IN_ROWS = 200;
13
+ export const CRAWL_MAX_STAND_IN_LINES = 20;
@@ -93,7 +93,7 @@ export function parseOnboardingStatus(value, expected) {
93
93
  invalid('its data absences');
94
94
  if (!isStrings(value.versionChoices))
95
95
  invalid('its version choices');
96
- if (!isStrings(value.standIns) || !isStrings(value.unsetProviders))
96
+ if (!isStrings(value.standIns) || !isStrings(value.unsetProviders) || !isStrings(value.standInGaps))
97
97
  invalid('its stand-ins');
98
98
  if (value.block !== null)
99
99
  parseOnboardingBlock(value.block);
@@ -290,11 +290,15 @@ export function formatOnboarding(status) {
290
290
  lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
291
291
  if (status.versionChoices.length)
292
292
  lines.push('', ...status.versionChoices.map(choice => chalk.yellow(`Note: ${safe(choice)}`)));
293
- const outside = [...status.standIns, ...status.unsetProviders];
293
+ const outside = [...status.standIns, ...status.unsetProviders, ...standInGapNotes(status.standInGaps)];
294
294
  if (outside.length)
295
295
  lines.push('', ...outside.map(note => chalk.yellow(`Note: ${safe(note)}`)));
296
296
  return lines.join('\n');
297
297
  }
298
+ /** Rule 15(g), amendment 3: a stand-in gap as the sentence every result shows. */
299
+ export function standInGapNotes(gaps) {
300
+ return gaps.map(gap => `A stand-in could not serve ${gap} while onboarding signed in and did the workflow; the crawl may meet it too.`);
301
+ }
298
302
  /** Telemetry: onboarding started, blocked or ready, once per state a command sees. */
299
303
  export function reportOnboardingState(status) {
300
304
  if (status.state === 'onboarding')
@@ -8,7 +8,7 @@ import chalk from 'chalk';
8
8
  import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
9
9
  import { findGitRoot, parseRemoteUrl, } from '../utils/git.js';
10
10
  import { resolveVerifyBaseSha } from '../utils/verify-base.js';
11
- import { HaystackApiError, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
11
+ import { HaystackApiError, haystackFetch, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
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';
@@ -313,13 +313,45 @@ function precomputeResult(response) {
313
313
  return { outcome: 'existing', ...reported };
314
314
  throw new PrecomputeFailure('The precompute service returned an invalid response.');
315
315
  }
316
+ /** The captured change as the service takes it (CRAWL-V1 amendment 14): uploaded once, content-addressed by its sha256 in
317
+ * the repository's scope (a HEAD finds an earlier upload; a PUT only when it is missing), and named in the front-half
318
+ * request by digest and size instead of carried inline. Nothing on the way holds a size limit of its own: tldraw's 13.8 MB
319
+ * capture was refused at the 8 MiB inline bound (2026-10-02). `force` uploads even when the HEAD found it (a 409 says the
320
+ * service no longer has it). */
321
+ async function uploadSourcePatch(request, token, signal, force = false) {
322
+ const gzipped = Buffer.from(request.patchGzBase64, 'base64');
323
+ const path = `/api/agent/cloud-verifier/source-patches/${request.patchSha256}`
324
+ + `?owner=${encodeURIComponent(request.owner)}&repository=${encodeURIComponent(request.repository)}`;
325
+ const found = force ? null : await haystackFetch(path, token, { method: 'HEAD', signal }, [404]);
326
+ if (found === null || found.status === 404) {
327
+ await haystackFetch(path, token, { method: 'PUT', headers: { 'Content-Type': 'application/gzip' }, body: gzipped, signal });
328
+ }
329
+ return gzipped.byteLength;
330
+ }
331
+ /** One front-half submission of a captured request: the change uploaded (uploadSourcePatch), then the request naming it. A
332
+ * 409 (the service has no such upload: it expired between the HEAD and the POST) uploads it again and submits once more. */
333
+ async function submitCapture(request, token, signal) {
334
+ const { patchGzBase64: _inline, ...named } = request;
335
+ const submit = async (force) => {
336
+ const patchBytes = await uploadSourcePatch(request, token, signal, force);
337
+ return haystackJson('/api/agent/cloud-verifier/front-half', token, { method: 'POST',
338
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...named, patchBytes }), signal });
339
+ };
340
+ try {
341
+ return await submit(false);
342
+ }
343
+ catch (error) {
344
+ if (!(error instanceof HaystackApiError && error.status === 409))
345
+ throw error;
346
+ return submit(true);
347
+ }
348
+ }
316
349
  /** One authenticated submission of the already captured request. No source reads or retries. */
317
350
  export const deliverPrecomputeRequest = async (request, signal) => {
318
351
  signal.throwIfAborted();
319
352
  const auth = await resolveAuthContext({ owner: request.owner, repo: request.repository });
320
353
  signal.throwIfAborted();
321
- return precomputeResult(await haystackJson('/api/agent/cloud-verifier/front-half', auth.token, { method: 'POST', headers: { 'Content-Type': 'application/json' },
322
- body: JSON.stringify(request), signal }));
354
+ return precomputeResult(await submitCapture(request, auth.token, signal));
323
355
  };
324
356
  function readWalkerStatus(value) {
325
357
  if (value && typeof value === 'object' && !Array.isArray(value)) {
@@ -426,7 +458,9 @@ export function normalModeFailure(error) {
426
458
  if (error.status === 429) {
427
459
  return new PrecomputeFailure('Precompute requests are temporarily rate limited. Try again later.');
428
460
  }
429
- return new PrecomputeFailure(`The precompute request failed (HTTP ${error.status}).`);
461
+ // With the server's reason (classifyHttpError's message carries it): a refused capture said only "HTTP 400", and tldraw's
462
+ // could not be told apart from any other refusal (2026-10-02).
463
+ return new PrecomputeFailure(`The precompute request failed: ${error.message}`);
430
464
  }
431
465
  // The deadline fired while the POST was in flight. The server may still
432
466
  // have accepted the job, so say what happened rather than "failed".
@@ -504,12 +538,7 @@ export async function postPrecomputeCapture(capture, token, deadline) {
504
538
  const fetchTimer = setTimeout(() => controller.abort(), remainingMs(deadline));
505
539
  let response;
506
540
  try {
507
- response = await haystackJson('/api/agent/cloud-verifier/front-half', token, {
508
- method: 'POST',
509
- headers: { 'Content-Type': 'application/json' },
510
- body: JSON.stringify(capture.derivation.request),
511
- signal: controller.signal,
512
- });
541
+ response = await submitCapture(capture.derivation.request, token, controller.signal);
513
542
  }
514
543
  finally {
515
544
  clearTimeout(fetchTimer);
@@ -28,9 +28,9 @@ 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_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, } from './crawl-contract.js';
32
32
  import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
33
- import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, waitForOnboarding } from './verify-onboarding.js';
33
+ import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, standInGapNotes, waitForOnboarding } from './verify-onboarding.js';
34
34
  const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
35
35
  /** A held read (waitAfter): the service's hold, then the time any read gets. */
36
36
  const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
@@ -137,6 +137,16 @@ function checkFinding(value) {
137
137
  || (value.images.old !== null && typeof value.images.old !== 'string')
138
138
  || (value.images.new !== null && typeof value.images.new !== 'string'))
139
139
  invalid('a finding');
140
+ // Amendment 13: present only when the crawl's worlds ran stand-ins for outside services.
141
+ if (value.standIns !== undefined && (!isRecord(value.standIns) || !isStrings(value.standIns.old, CRAWL_MAX_STAND_IN_LINES)
142
+ || !isStrings(value.standIns.new, CRAWL_MAX_STAND_IN_LINES)))
143
+ invalid('the outside services on a finding\'s path');
144
+ }
145
+ /** Amendment 13: one row of the crawl's stand-in coverage. */
146
+ function isStandInRow(value) {
147
+ return isRecord(value) && typeof value.provider === 'string' && typeof value.operation === 'string'
148
+ && (value.kind === 'unsupported' || value.kind === 'mutates') && isMember(value.phase, ['build', 'boot', 'run'])
149
+ && isCount(value.old) && isCount(value.new);
140
150
  }
141
151
  function checkSpot(value) {
142
152
  if (!isRecord(value) || typeof value.id !== 'string' || typeof value.file !== 'string'
@@ -181,6 +191,11 @@ function checkResults(value, view, what) {
181
191
  if (blast !== null && (!isRecord(blast) || !isCount(blast.functions) || !isCount(blast.ran)
182
192
  || !isCount(blast.unreached) || !isStrings(blast.nearestUnreached)))
183
193
  invalid('its affected functions');
194
+ const standIns = value.standIns;
195
+ if (standIns !== undefined && (!isRecord(standIns) || !Array.isArray(standIns.coverage)
196
+ || standIns.coverage.length > CRAWL_MAX_STAND_IN_ROWS || !standIns.coverage.every(isStandInRow)
197
+ || !isCount(standIns.omittedRows) || !isStrings(standIns.gaps, CRAWL_MAX_STAND_IN_ROWS)))
198
+ invalid('its outside-service coverage');
184
199
  if (value.error !== null)
185
200
  checkError(value.error, 'its error');
186
201
  }
@@ -369,6 +384,12 @@ function findingLines(finding, position) {
369
384
  const lines = [` ${position}. ${label}: ${finding.why === null ? 'no reason was recorded.' : safe(finding.why)}`,
370
385
  ` Starting from ${safe(finding.start)}:`];
371
386
  finding.steps.forEach((step, index) => lines.push(` ${index + 1}. ${safe(step)}`));
387
+ // Amendment 13: the outside-service calls on its path, answered by Haystack stand-ins, as the judge read them.
388
+ const outside = finding.standIns;
389
+ if (outside && (outside.old.length > 0 || outside.new.length > 0)) {
390
+ const side = (calls) => (calls.length ? calls.map(safe).join('; ') : 'none');
391
+ lines.push(chalk.dim(' Outside services on this path, answered by Haystack stand-ins ("unsupported": one it could not serve):'), chalk.dim(` without your change: ${side(outside.old)}`), chalk.dim(` with your change: ${side(outside.new)}`));
392
+ }
372
393
  return lines;
373
394
  }
374
395
  export function formatCrawl(view) {
@@ -418,6 +439,13 @@ export function formatCrawl(view) {
418
439
  const blast = published.reach.blast;
419
440
  if (blast !== null)
420
441
  lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
442
+ // Amendment 13: calls to outside services the stand-ins could not serve: the copy's missing coverage, never a finding.
443
+ const gaps = published.standIns?.gaps ?? [];
444
+ if (gaps.length > 0) {
445
+ lines.push('', chalk.bold('Outside services the copy could not imitate'), ' Haystack stand-ins answer these services in the copy, but could not serve these calls, so paths through them', ' did not behave as they would in production (missing coverage, not bugs in your change):');
446
+ for (const gap of gaps)
447
+ lines.push(` ${safe(gap)}`);
448
+ }
421
449
  // Only a finished crawl has proven its cleanup: an answered one is still shutting its machines down (Bugbot).
422
450
  if (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0) {
423
451
  lines.push('', chalk.yellow(` ${plural(view.totals.cleanupUnproven, 'machine')} it used could not be proven shut down.`));
@@ -710,7 +738,7 @@ export async function verifyCommand(options) {
710
738
  else {
711
739
  console.log(formatCrawl(view));
712
740
  const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? []), ...(onboarding?.standIns ?? []),
713
- ...(onboarding?.unsetProviders ?? [])];
741
+ ...(onboarding?.unsetProviders ?? []), ...standInGapNotes(onboarding?.standInGaps ?? [])];
714
742
  if (notes.length)
715
743
  console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
716
744
  }
@@ -183,6 +183,11 @@ export async function classifyHttpError(response, context) {
183
183
  return new HaystackApiError(response.status, `${context}: unexpected response (HTTP ${response.status})${suffix}.`);
184
184
  }
185
185
  export async function haystackJson(path, token, init = {}, acceptedStatuses = []) {
186
+ const response = await haystackFetch(path, token, init, acceptedStatuses);
187
+ return response.json();
188
+ }
189
+ /** An authenticated request whose response is not JSON (a HEAD, an upload): the response itself, or the classified error. */
190
+ export async function haystackFetch(path, token, init = {}, acceptedStatuses = []) {
186
191
  const response = await fetch(haystackApiUrl(path), {
187
192
  ...init,
188
193
  headers: headers(token, init.headers),
@@ -190,7 +195,7 @@ export async function haystackJson(path, token, init = {}, acceptedStatuses = []
190
195
  if (!response.ok && !acceptedStatuses.includes(response.status)) {
191
196
  throw await classifyHttpError(response, `Haystack API ${path}`);
192
197
  }
193
- return response.json();
198
+ return response;
194
199
  }
195
200
  export async function fetchCliIdentity(token) {
196
201
  return haystackJson('/api/cli/me', token);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
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": {
@@ -288,7 +288,7 @@
288
288
  "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
289
289
  "count": { "type": "integer", "minimum": 0 },
290
290
  "timestamp": { "type": ["string", "null"], "format": "date-time" },
291
- "status": { "enum": ["queued", "preparing", "crawling", "draining", "completed", "incomplete", "cancelled"] },
291
+ "status": { "enum": ["queued", "preparing", "crawling", "draining", "completed", "prepared", "incomplete", "cancelled"] },
292
292
  "stage": { "enum": ["reconcile", "source", "worlds", "blast", "knobs", "prepare", "crawl", "publish", "cleanup"] },
293
293
  "spotStage": { "enum": ["resolved", "loaded", "exposed", "activated", "pressed", "observed", "confirmed", "judged"] },
294
294
  "reuseValue": { "enum": ["hit", "miss", null] },
@@ -334,9 +334,31 @@
334
334
  "old": { "type": ["string", "null"] },
335
335
  "new": { "type": ["string", "null"] }
336
336
  }
337
+ },
338
+ "standIns": {
339
+ "description": "CRAWL-V1 amendment 13, only when the app's copies answer outside services (sign-in, mail, payments...) with Haystack stand-ins: the calls to them on the path this finding was judged on, per build, one line per distinct call ('<unsupported|mutates> <provider>: <operation>', the phase added in parentheses when not run). 'unsupported': the stand-in could not serve the call, so the copy did not behave as production would there.",
340
+ "type": "object",
341
+ "required": ["old", "new"],
342
+ "properties": {
343
+ "old": { "type": "array", "maxItems": 20, "items": { "type": "string" } },
344
+ "new": { "type": "array", "maxItems": 20, "items": { "type": "string" } }
345
+ }
337
346
  }
338
347
  }
339
348
  },
349
+ "standInRow": {
350
+ "description": "CRAWL-V1 amendment 13: one distinct outside-service call the crawl's stand-ins logged, with its count in each build: how many times the copy's calls reached the stand-in across the crawl's preparation and all its replays (double-checks and re-walks included), not how many user actions did.",
351
+ "type": "object",
352
+ "required": ["provider", "operation", "kind", "phase", "old", "new"],
353
+ "properties": {
354
+ "provider": { "type": "string" },
355
+ "operation": { "type": "string" },
356
+ "kind": { "enum": ["unsupported", "mutates"] },
357
+ "phase": { "enum": ["build", "boot", "run"] },
358
+ "old": { "$ref": "#/$defs/count" },
359
+ "new": { "$ref": "#/$defs/count" }
360
+ }
361
+ },
340
362
  "spot": {
341
363
  "description": "One changed spot (one hunk of a changed file) and how far the crawl got with it: the change's product blast radius.",
342
364
  "type": "object",
@@ -420,15 +442,25 @@
420
442
  }
421
443
  }
422
444
  },
445
+ "standIns": {
446
+ "description": "CRAWL-V1 amendment 13, only when the app's copies answer outside services with Haystack stand-ins: every distinct call the crawl's stand-ins logged, both builds alike (at most 200 rows, the rest counted in omittedRows), and each call a stand-in could not serve as a gap line ('<provider>: <operation> (only without your change|only with your change|with and without your change)'). Gaps are missing coverage of the copy, never findings.",
447
+ "type": "object",
448
+ "required": ["coverage", "omittedRows", "gaps"],
449
+ "properties": {
450
+ "coverage": { "type": "array", "maxItems": 200, "items": { "$ref": "#/$defs/standInRow" } },
451
+ "omittedRows": { "$ref": "#/$defs/count" },
452
+ "gaps": { "type": "array", "maxItems": 200, "items": { "type": "string" } }
453
+ }
454
+ },
423
455
  "notFinishedInTime": { "$ref": "#/$defs/count" },
424
456
  "error": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/error" }] }
425
457
  }
426
458
  },
427
459
  "manifest": {
428
- "description": "Present once the crawl is terminal and published.",
460
+ "description": "Present once the crawl is terminal and published. 'prepared' (CRAWL-V1 amendment 12): the change was built and frozen by the turn-end hook and not crawled.",
429
461
  "allOf": [{ "$ref": "#/$defs/results" }],
430
462
  "properties": {
431
- "status": { "enum": ["completed", "incomplete", "cancelled"] }
463
+ "status": { "enum": ["completed", "prepared", "incomplete", "cancelled"] }
432
464
  }
433
465
  },
434
466
  "answer": {
@@ -460,6 +492,7 @@
460
492
  "updatedAt": { "type": "string", "format": "date-time" },
461
493
  "supersededBy": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/runId" }] },
462
494
  "cancelRequested": { "type": "boolean" },
495
+ "mode": { "enum": ["prepare", "crawl"], "description": "CRAWL-V1 amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' checks the change." },
463
496
  "status": { "$ref": "#/$defs/status" },
464
497
  "stage": { "$ref": "#/$defs/stage" },
465
498
  "clocks": {