@haystackeditor/cli 0.17.0 → 0.18.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.
@@ -6,17 +6,18 @@ import { isAbsolute, join, resolve } from 'node:path';
6
6
  import { tmpdir } from 'node:os';
7
7
  import chalk from 'chalk';
8
8
  import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
9
- import { findGitRoot, parseRemoteUrl, resolveDiffBaseRef, } from '../utils/git.js';
9
+ import { findGitRoot, parseRemoteUrl, } from '../utils/git.js';
10
+ import { resolveVerifyBaseSha } from '../utils/verify-base.js';
10
11
  import { HaystackApiError, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
11
12
  import { trackPrecomputeEvent } from '../utils/telemetry.js';
12
13
  import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
13
14
  import { startPrecomputeDelivery } from './precompute-delivery.js';
14
- import { captureCombinationSearchHook, readCombinationSearchHookStatus } from './combination-search-hook.js';
15
+ import { CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
15
16
  // --hook has five seconds to capture the exact source and publish local custody.
16
17
  // Its detached sender owns the bounded network delivery and acknowledgement.
17
18
  // An explicit invocation still waits synchronously for the server response.
18
19
  const HOOK_WALL_MS = 5_000;
19
- const EXPLICIT_WALL_MS = 60_000;
20
+ export const EXPLICIT_WALL_MS = 60_000;
20
21
  const REMOTE_COMMAND_MS = 750;
21
22
  // The published CLI cannot import verifier/contracts outside its package root.
22
23
  const hasOnlyCharacters = (value, allowed) => value.length > 0
@@ -79,50 +80,29 @@ function resolveOriginRepository(gitRoot) {
79
80
  throw new PrecomputeFailure('No supported origin repository was found.');
80
81
  }
81
82
  }
82
- function resolveOriginDefaultBranch(gitRoot, deadline) {
83
+ /** The shared base rule (utils/verify-base.ts), bounded by the hook's deadline. */
84
+ function deriveBaseSha(gitRoot, deadline) {
83
85
  try {
84
- const remoteHead = gitOutput(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], gitRoot, deadline);
85
- if (remoteHead.startsWith('origin/') && remoteHead.length > 'origin/'.length) {
86
- return remoteHead.slice('origin/'.length);
87
- }
88
- }
89
- catch {
90
- // A clone does not have to carry origin/HEAD; ask the remote below.
86
+ return resolveVerifyBaseSha('HEAD', {
87
+ run: (args, options) => gitOutput(args, gitRoot, deadline, options?.remote
88
+ ? { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeoutCapMs: REMOTE_COMMAND_MS } : {}),
89
+ fetchOptions: () => ({ cwd: gitRoot, timeoutMs: Math.max(1, Math.min(remainingMs(deadline), REMOTE_COMMAND_MS)) }),
90
+ });
91
91
  }
92
- const remoteRefs = gitOutput(['ls-remote', '--symref', 'origin', 'HEAD'], gitRoot, deadline, {
93
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
94
- timeoutCapMs: REMOTE_COMMAND_MS,
95
- });
96
- const prefix = 'ref: refs/heads/';
97
- const heads = remoteRefs.split('\n').map(line => line.split('\t'))
98
- .filter(fields => fields.length === 2 && fields[1] === 'HEAD' && fields[0].startsWith(prefix))
99
- .map(fields => fields[0].slice(prefix.length));
100
- if (heads.length !== 1 || heads[0].length === 0) {
101
- throw new PrecomputeFailure('Could not resolve the origin default branch.');
102
- }
103
- gitOutput(['check-ref-format', `refs/heads/${heads[0]}`], gitRoot, deadline);
104
- return heads[0];
105
- }
106
- function deriveBaseSha(gitRoot, deadline) {
107
- const defaultBranch = resolveOriginDefaultBranch(gitRoot, deadline);
108
- const baseRef = resolveDiffBaseRef(defaultBranch, {
109
- cwd: gitRoot,
110
- timeoutMs: Math.max(1, Math.min(remainingMs(deadline), REMOTE_COMMAND_MS)),
111
- });
112
- const baseSha = gitOutput(['merge-base', 'HEAD', baseRef], gitRoot, deadline);
113
- if (!isCommitSha(baseSha)) {
114
- throw new PrecomputeFailure('Could not derive the base commit.');
92
+ catch (error) {
93
+ if (error instanceof PrecomputeFailure)
94
+ throw error;
95
+ throw new PrecomputeFailure(error instanceof Error ? error.message : 'Could not derive the base commit.');
115
96
  }
116
- return baseSha;
117
97
  }
118
98
  // The -U0 scan text omits binary content and context, so it stays far below
119
99
  // the uploaded patch; this only guards against git's 1 MiB default.
120
100
  const SCAN_PATCH_MAX_BYTES = 256 * 1024 * 1024;
121
101
  /**
122
102
  * Withholds secret-bearing changes from the snapshot index before the tree is
123
- * written, so the tree, work commit, uploaded patch and combination-search
124
- * patches all agree. A withheld path keeps its base version (or is absent when
125
- * base has none). Every withheld path is named on stderr; secret values never are.
103
+ * written, so the tree, work commit and uploaded patch all agree. A withheld
104
+ * path keeps its base version (or is absent when base has none). Every
105
+ * withheld path is named on stderr; secret values never are.
126
106
  */
127
107
  function withholdSecretBearingChanges(gitRoot, baseSha, indexEnv, deadline) {
128
108
  const changed = gitOutput(['diff', '--cached', '--no-renames', '--diff-filter=d', '--name-only', '-z', baseSha, '--'], gitRoot, deadline, { env: indexEnv, maxBuffer: SCAN_PATCH_MAX_BYTES, raw: true }).split('\0').filter(path => path.length > 0);
@@ -186,17 +166,40 @@ function deriveWorkCommit(gitRoot, baseSha, treeSha, deadline) {
186
166
  }
187
167
  // A clean checkout of the base already has the exact required identity.
188
168
  if (baseSha === headSha && treeSha === headTreeSha)
189
- return headSha;
169
+ return { workCommit: headSha, headSha, headTreeSha };
190
170
  const workCommit = gitOutput(['commit-tree', treeSha, '-p', baseSha, '-m', 'front-half-snapshot'], gitRoot, deadline, { env: { ...process.env, ...FIXED_COMMIT_ENV } });
191
171
  if (!isCommitSha(workCommit)) {
192
172
  throw new PrecomputeFailure('Could not derive the working commit.');
193
173
  }
194
- return workCommit;
174
+ return { workCommit, headSha, headTreeSha };
175
+ }
176
+ /**
177
+ * CrawlSubmission.changeTitle (CRAWL-V1): HEAD's subject when HEAD differs
178
+ * from base, then " (with uncommitted changes)" when the captured tree differs
179
+ * from HEAD's; "Uncommitted changes" when HEAD is base. A subject too long for
180
+ * 200 characters is cut at a character boundary, never the suffix.
181
+ */
182
+ function deriveChangeTitle(gitRoot, baseSha, treeSha, commits, deadline) {
183
+ if (commits.headSha === baseSha)
184
+ return 'Uncommitted changes';
185
+ // A commit with an empty message is named by its id, so the title is never empty.
186
+ const subject = gitOutput(['log', '-1', '--no-show-signature', '--encoding=UTF-8', '--format=%s', commits.headSha, '--'], gitRoot, deadline, { raw: true }).replace(/\n$/, '') || `Commit ${commits.headSha.slice(0, 12)}`;
187
+ const suffix = treeSha === commits.headTreeSha ? '' : ' (with uncommitted changes)';
188
+ let title = '';
189
+ for (const character of subject) {
190
+ if (title.length + character.length + suffix.length > CRAWL_TITLE_MAX_CHARS)
191
+ break;
192
+ title += character;
193
+ }
194
+ return title + suffix;
195
195
  }
196
196
  function slimInputHash() {
197
- // Mirrors printf '%s\0%s\0%s\0%s\0' slim agent-deferred none none.
197
+ // Mirrors front_half_cache_derive in blast-cache.sh for a slim bundle:
198
+ // printf '%s\0%s\0%s\0%s\0%s\0' slim agent-deferred none none crawl-blast-v1.
199
+ // The fifth field (CRAWL-V1 rule 4) keeps bundles written before
200
+ // crawl-blast.json existed from being restored for a crawl.
198
201
  return createHash('sha256')
199
- .update(Buffer.from('slim\0agent-deferred\0none\0none\0'))
202
+ .update(Buffer.from('slim\0agent-deferred\0none\0none\0crawl-blast-v1\0'))
200
203
  .digest('hex');
201
204
  }
202
205
  async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
@@ -262,7 +265,9 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
262
265
  const observedAt = Date.now();
263
266
  const baseSha = deriveBaseSha(gitRoot, deadline);
264
267
  const treeSha = deriveWorktreeTreeSha(gitRoot, baseSha, deadline);
265
- const workCommit = deriveWorkCommit(gitRoot, baseSha, treeSha, deadline);
268
+ const commits = deriveWorkCommit(gitRoot, baseSha, treeSha, deadline);
269
+ const { workCommit } = commits;
270
+ const changeTitle = deriveChangeTitle(gitRoot, baseSha, treeSha, commits, deadline);
266
271
  const patchGzip = await buildCompressedPatch(gitRoot, baseSha, workCommit, deadline);
267
272
  const cacheKey = [
268
273
  baseSha.slice(0, 12),
@@ -271,7 +276,6 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
271
276
  'slim',
272
277
  slimInputHash(),
273
278
  ].join('-');
274
- const combinationSearch = captureCombinationSearchHook(gitRoot, `${owner}/${repository}`, baseSha, workCommit, deadline);
275
279
  return {
276
280
  treeSha,
277
281
  request: {
@@ -283,7 +287,7 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
283
287
  cacheKey,
284
288
  patchGzBase64: patchGzip.toString('base64'),
285
289
  patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
286
- ...(combinationSearch ? { combinationSearch } : {}),
290
+ ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle } }),
287
291
  },
288
292
  };
289
293
  }
@@ -291,16 +295,17 @@ function precomputeResult(response) {
291
295
  if (!response || typeof response !== 'object' || Array.isArray(response)) {
292
296
  throw new PrecomputeFailure('The precompute service returned an invalid response.');
293
297
  }
294
- const { status, runId, walker: rawWalker, search: rawSearch } = response;
298
+ const { status, runId, walker: rawWalker, crawl: rawCrawl } = response;
295
299
  const walker = rawWalker === undefined ? undefined : readWalkerStatus(rawWalker);
296
- const search = rawSearch === undefined ? undefined : readCombinationSearchHookStatus(rawSearch);
300
+ const crawl = rawCrawl === undefined ? undefined : readCrawlHookStatus(rawCrawl);
301
+ const reported = { ...(walker === undefined ? {} : { walker }), ...(crawl === undefined ? {} : { crawl }) };
297
302
  if (status === 'queued' && typeof runId === 'string' && isRunId(runId)) {
298
- return { outcome: 'enqueued', runId, ...(walker === undefined ? {} : { walker }), ...(search === undefined ? {} : { search }) };
303
+ return { outcome: 'enqueued', runId, ...reported };
299
304
  }
300
305
  if (status === 'cached')
301
- return { outcome: 'cached', ...(walker === undefined ? {} : { walker }), ...(search === undefined ? {} : { search }) };
306
+ return { outcome: 'cached', ...reported };
302
307
  if (status === 'existing')
303
- return { outcome: 'existing', ...(walker === undefined ? {} : { walker }), ...(search === undefined ? {} : { search }) };
308
+ return { outcome: 'existing', ...reported };
304
309
  throw new PrecomputeFailure('The precompute service returned an invalid response.');
305
310
  }
306
311
  /** One authenticated submission of the already captured request. No source reads or retries. */
@@ -325,6 +330,31 @@ function readWalkerStatus(value) {
325
330
  }
326
331
  throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
327
332
  }
333
+ const CRAWL_UNAVAILABLE_REASONS = ['not-configured', 'runner-unavailable', 'profile-ambiguous', 'superseded', 'enqueue-failed'];
334
+ function readCrawlHookStatus(value) {
335
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
336
+ const record = value;
337
+ if (Object.keys(record).length === 2) {
338
+ if ((record.status === 'queued' || record.status === 'existing')
339
+ && typeof record.runId === 'string' && isRunId(record.runId))
340
+ return { status: record.status, runId: record.runId };
341
+ const reason = CRAWL_UNAVAILABLE_REASONS.find(known => known === record.reason);
342
+ if (record.status === 'unavailable' && reason !== undefined)
343
+ return { status: record.status, reason };
344
+ }
345
+ }
346
+ throw new PrecomputeFailure('The precompute service returned an invalid crawl status.');
347
+ }
348
+ /** Why the service could not start a crawl, in plain words. */
349
+ export function crawlUnavailableText(reason) {
350
+ switch (reason) {
351
+ case 'not-configured': return 'this repository is not set up for crawling yet';
352
+ case 'runner-unavailable': return 'no machine that can crawl this repository is available right now';
353
+ case 'profile-ambiguous': return 'the machines that crawl this repository disagree about how to run it';
354
+ case 'superseded': return 'a newer stop in this repository came first';
355
+ case 'enqueue-failed': return 'the service could not queue it';
356
+ }
357
+ }
328
358
  function walkerMetadataDirectory(gitRoot, deadline) {
329
359
  const common = gitOutput(['rev-parse', '--git-common-dir'], gitRoot, deadline);
330
360
  return join(isAbsolute(common) ? common : resolve(gitRoot, common), 'haystack');
@@ -361,7 +391,8 @@ function saveWalkerStatus(directory, request, walker) {
361
391
  rmSync(temporary, { force: true });
362
392
  }
363
393
  }
364
- function normalModeFailure(error) {
394
+ /** The one-line error an explicit (non-hook) submission reports. */
395
+ export function normalModeFailure(error) {
365
396
  if (error instanceof PrecomputeFailure)
366
397
  return error;
367
398
  if (error instanceof NotLoggedInError) {
@@ -389,7 +420,8 @@ function normalModeFailure(error) {
389
420
  }
390
421
  return new PrecomputeFailure('The precompute request failed.');
391
422
  }
392
- function printResult(outcome, runId, walker, search) {
423
+ function printResult(result) {
424
+ const { outcome, runId, walker, crawl } = result;
393
425
  if (outcome === 'enqueued') {
394
426
  console.log(chalk.green(`Precompute request queued${runId ? ` (${runId})` : ''}.`));
395
427
  }
@@ -409,10 +441,55 @@ function printResult(outcome, runId, walker, search) {
409
441
  : 'Walker unavailable: the enqueue request failed.');
410
442
  else
411
443
  console.log('Walker status was not reported by the service.');
412
- if (search?.status === 'queued' || search?.status === 'existing')
413
- console.log(`Browser search ${search.status} (${search.runId}).`);
414
- else if (search?.status === 'unavailable' && search.reason !== 'not-configured')
415
- console.log(`Browser search unavailable: ${search.reason}.`);
444
+ if (crawl?.status === 'queued')
445
+ console.log(`Crawl queued (${crawl.runId}).`);
446
+ else if (crawl?.status === 'existing')
447
+ console.log(`A matching crawl already exists (${crawl.runId}).`);
448
+ else if (crawl?.status === 'unavailable')
449
+ console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
450
+ else
451
+ console.log('Crawl status was not reported by the service.');
452
+ }
453
+ /** The hook, an explicit precompute and `haystack verify` all capture here.
454
+ * The repository defaults to origin's, as the hook's does. */
455
+ export async function captureCheckout(deadline, repository) {
456
+ const gitRoot = findGitRoot();
457
+ if (!gitRoot) {
458
+ throw new PrecomputeFailure('Run this command from a git repository.');
459
+ }
460
+ const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
461
+ const target = repository ?? resolveOriginRepository(gitRoot);
462
+ const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline);
463
+ return { metadataDirectory, derivation };
464
+ }
465
+ /** The explicit (non-hook) submission of a capture: one synchronous
466
+ * authenticated POST, bounded by the deadline, and its walker status saved.
467
+ * `haystack verify precompute` and `haystack verify` both submit here. */
468
+ export async function postPrecomputeCapture(capture, token, deadline) {
469
+ const controller = new AbortController();
470
+ const fetchTimer = setTimeout(() => controller.abort(), remainingMs(deadline));
471
+ let response;
472
+ try {
473
+ response = await haystackJson('/api/agent/cloud-verifier/front-half', token, {
474
+ method: 'POST',
475
+ headers: { 'Content-Type': 'application/json' },
476
+ body: JSON.stringify(capture.derivation.request),
477
+ signal: controller.signal,
478
+ });
479
+ }
480
+ finally {
481
+ clearTimeout(fetchTimer);
482
+ }
483
+ const result = precomputeResult(response);
484
+ if (result.walker) {
485
+ try {
486
+ saveWalkerStatus(capture.metadataDirectory, capture.derivation.request, result.walker);
487
+ }
488
+ catch {
489
+ throw new PrecomputeFailure('Precompute response received, but its walker status could not be saved.');
490
+ }
491
+ }
492
+ return result;
416
493
  }
417
494
  export async function verifyPrecomputeCommand(options) {
418
495
  const startedAt = Date.now();
@@ -420,61 +497,29 @@ export async function verifyPrecomputeCommand(options) {
420
497
  const deadline = startedAt + totalWallMs;
421
498
  let outcome = 'error';
422
499
  try {
423
- const gitRoot = findGitRoot();
424
- if (!gitRoot) {
425
- throw new PrecomputeFailure('Run this command from a git repository.');
426
- }
427
- const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
428
- const repository = resolveOriginRepository(gitRoot);
429
- const derivation = await derivePrecomputeRequest(gitRoot, repository.owner, repository.repository, deadline);
500
+ const capture = await captureCheckout(deadline);
430
501
  if (options.hook) {
431
502
  remainingMs(deadline);
432
- await startPrecomputeDelivery(metadataDirectory, derivation.request);
503
+ await startPrecomputeDelivery(capture.metadataDirectory, capture.derivation.request);
433
504
  outcome = 'pending';
434
505
  return;
435
506
  }
436
507
  let token;
437
508
  try {
438
509
  const auth = await resolveAuthContext({
439
- owner: repository.owner,
440
- repo: repository.repository,
510
+ owner: capture.derivation.request.owner,
511
+ repo: capture.derivation.request.repository,
441
512
  });
442
513
  token = auth.token;
443
514
  }
444
515
  catch (error) {
445
- if (error instanceof NotLoggedInError) {
516
+ if (error instanceof NotLoggedInError)
446
517
  outcome = 'skipped_no_auth';
447
- if (options.hook)
448
- return;
449
- }
450
518
  throw error;
451
519
  }
452
- const controller = new AbortController();
453
- const fetchTimer = setTimeout(() => controller.abort(), remainingMs(deadline));
454
- let response;
455
- try {
456
- response = await haystackJson('/api/agent/cloud-verifier/front-half', token, {
457
- method: 'POST',
458
- headers: { 'Content-Type': 'application/json' },
459
- body: JSON.stringify(derivation.request),
460
- signal: controller.signal,
461
- });
462
- }
463
- finally {
464
- clearTimeout(fetchTimer);
465
- }
466
- const result = precomputeResult(response);
520
+ const result = await postPrecomputeCapture(capture, token, deadline);
467
521
  outcome = result.outcome;
468
- if (result.walker) {
469
- try {
470
- saveWalkerStatus(metadataDirectory, derivation.request, result.walker);
471
- }
472
- catch {
473
- throw new PrecomputeFailure('Precompute response received, but its walker status could not be saved.');
474
- }
475
- }
476
- if (!options.hook)
477
- printResult(outcome, result.runId, result.walker, result.search);
522
+ printResult(result);
478
523
  }
479
524
  catch (error) {
480
525
  if (!options.hook)