@haystackeditor/cli 0.17.1 → 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.
@@ -1,22 +1,23 @@
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';
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_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, 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) {
@@ -256,13 +259,17 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
256
259
  child.stdout.pipe(gzip);
257
260
  });
258
261
  }
259
- 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) {
260
265
  // Capture this before git and compression work so delivery latency cannot
261
266
  // make an older stop look newer than a later one.
262
267
  const observedAt = Date.now();
263
268
  const baseSha = deriveBaseSha(gitRoot, deadline);
264
269
  const treeSha = deriveWorktreeTreeSha(gitRoot, baseSha, deadline);
265
- const workCommit = deriveWorkCommit(gitRoot, baseSha, treeSha, deadline);
270
+ const commits = deriveWorkCommit(gitRoot, baseSha, treeSha, deadline);
271
+ const { workCommit } = commits;
272
+ const changeTitle = deriveChangeTitle(gitRoot, baseSha, treeSha, commits, deadline);
266
273
  const patchGzip = await buildCompressedPatch(gitRoot, baseSha, workCommit, deadline);
267
274
  const cacheKey = [
268
275
  baseSha.slice(0, 12),
@@ -271,7 +278,6 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
271
278
  'slim',
272
279
  slimInputHash(),
273
280
  ].join('-');
274
- const combinationSearch = captureCombinationSearchHook(gitRoot, `${owner}/${repository}`, baseSha, workCommit, deadline);
275
281
  return {
276
282
  treeSha,
277
283
  request: {
@@ -283,7 +289,7 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
283
289
  cacheKey,
284
290
  patchGzBase64: patchGzip.toString('base64'),
285
291
  patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
286
- ...(combinationSearch ? { combinationSearch } : {}),
292
+ ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
287
293
  },
288
294
  };
289
295
  }
@@ -291,16 +297,17 @@ function precomputeResult(response) {
291
297
  if (!response || typeof response !== 'object' || Array.isArray(response)) {
292
298
  throw new PrecomputeFailure('The precompute service returned an invalid response.');
293
299
  }
294
- const { status, runId, walker: rawWalker, search: rawSearch } = response;
300
+ const { status, runId, walker: rawWalker, crawl: rawCrawl } = response;
295
301
  const walker = rawWalker === undefined ? undefined : readWalkerStatus(rawWalker);
296
- const search = rawSearch === undefined ? undefined : readCombinationSearchHookStatus(rawSearch);
302
+ const crawl = rawCrawl === undefined ? undefined : readCrawlHookStatus(rawCrawl);
303
+ const reported = { ...(walker === undefined ? {} : { walker }), ...(crawl === undefined ? {} : { crawl }) };
297
304
  if (status === 'queued' && typeof runId === 'string' && isRunId(runId)) {
298
- return { outcome: 'enqueued', runId, ...(walker === undefined ? {} : { walker }), ...(search === undefined ? {} : { search }) };
305
+ return { outcome: 'enqueued', runId, ...reported };
299
306
  }
300
307
  if (status === 'cached')
301
- return { outcome: 'cached', ...(walker === undefined ? {} : { walker }), ...(search === undefined ? {} : { search }) };
308
+ return { outcome: 'cached', ...reported };
302
309
  if (status === 'existing')
303
- return { outcome: 'existing', ...(walker === undefined ? {} : { walker }), ...(search === undefined ? {} : { search }) };
310
+ return { outcome: 'existing', ...reported };
304
311
  throw new PrecomputeFailure('The precompute service returned an invalid response.');
305
312
  }
306
313
  /** One authenticated submission of the already captured request. No source reads or retries. */
@@ -325,6 +332,31 @@ function readWalkerStatus(value) {
325
332
  }
326
333
  throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
327
334
  }
335
+ const CRAWL_UNAVAILABLE_REASONS = ['not-configured', 'runner-unavailable', 'profile-ambiguous', 'superseded', 'enqueue-failed'];
336
+ function readCrawlHookStatus(value) {
337
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
338
+ const record = value;
339
+ if (Object.keys(record).length === 2) {
340
+ if ((record.status === 'queued' || record.status === 'existing')
341
+ && typeof record.runId === 'string' && isRunId(record.runId))
342
+ return { status: record.status, runId: record.runId };
343
+ const reason = CRAWL_UNAVAILABLE_REASONS.find(known => known === record.reason);
344
+ if (record.status === 'unavailable' && reason !== undefined)
345
+ return { status: record.status, reason };
346
+ }
347
+ }
348
+ throw new PrecomputeFailure('The precompute service returned an invalid crawl status.');
349
+ }
350
+ /** Why the service could not start a crawl, in plain words. */
351
+ export function crawlUnavailableText(reason) {
352
+ switch (reason) {
353
+ case 'not-configured': return 'this repository is not set up for crawling yet';
354
+ case 'runner-unavailable': return 'no machine that can crawl this repository is available right now';
355
+ case 'profile-ambiguous': return 'the machines that crawl this repository disagree about how to run it';
356
+ case 'superseded': return 'a newer stop in this repository came first';
357
+ case 'enqueue-failed': return 'the service could not queue it';
358
+ }
359
+ }
328
360
  function walkerMetadataDirectory(gitRoot, deadline) {
329
361
  const common = gitOutput(['rev-parse', '--git-common-dir'], gitRoot, deadline);
330
362
  return join(isAbsolute(common) ? common : resolve(gitRoot, common), 'haystack');
@@ -361,7 +393,8 @@ function saveWalkerStatus(directory, request, walker) {
361
393
  rmSync(temporary, { force: true });
362
394
  }
363
395
  }
364
- function normalModeFailure(error) {
396
+ /** The one-line error an explicit (non-hook) submission reports. */
397
+ export function normalModeFailure(error) {
365
398
  if (error instanceof PrecomputeFailure)
366
399
  return error;
367
400
  if (error instanceof NotLoggedInError) {
@@ -389,7 +422,8 @@ function normalModeFailure(error) {
389
422
  }
390
423
  return new PrecomputeFailure('The precompute request failed.');
391
424
  }
392
- function printResult(outcome, runId, walker, search) {
425
+ function printResult(result) {
426
+ const { outcome, runId, walker, crawl } = result;
393
427
  if (outcome === 'enqueued') {
394
428
  console.log(chalk.green(`Precompute request queued${runId ? ` (${runId})` : ''}.`));
395
429
  }
@@ -409,10 +443,72 @@ function printResult(outcome, runId, walker, search) {
409
443
  : 'Walker unavailable: the enqueue request failed.');
410
444
  else
411
445
  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}.`);
446
+ if (crawl?.status === 'queued')
447
+ console.log(`Crawl queued (${crawl.runId}).`);
448
+ else if (crawl?.status === 'existing')
449
+ console.log(`A matching crawl already exists (${crawl.runId}).`);
450
+ else if (crawl?.status === 'unavailable')
451
+ console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
452
+ else
453
+ console.log('Crawl status was not reported by the service.');
454
+ }
455
+ /** The hook, an explicit precompute and `haystack verify` all capture here.
456
+ * The repository defaults to origin's, as the hook's does. */
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) {
474
+ const gitRoot = findGitRoot();
475
+ if (!gitRoot) {
476
+ throw new PrecomputeFailure('Run this command from a git repository.');
477
+ }
478
+ const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
479
+ const target = repository ?? resolveOriginRepository(gitRoot);
480
+ const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
481
+ const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
482
+ return { metadataDirectory, derivation };
483
+ }
484
+ /** The explicit (non-hook) submission of a capture: one synchronous
485
+ * authenticated POST, bounded by the deadline, and its walker status saved.
486
+ * `haystack verify precompute` and `haystack verify` both submit here. */
487
+ export async function postPrecomputeCapture(capture, token, deadline) {
488
+ const controller = new AbortController();
489
+ const fetchTimer = setTimeout(() => controller.abort(), remainingMs(deadline));
490
+ let response;
491
+ try {
492
+ response = await haystackJson('/api/agent/cloud-verifier/front-half', token, {
493
+ method: 'POST',
494
+ headers: { 'Content-Type': 'application/json' },
495
+ body: JSON.stringify(capture.derivation.request),
496
+ signal: controller.signal,
497
+ });
498
+ }
499
+ finally {
500
+ clearTimeout(fetchTimer);
501
+ }
502
+ const result = precomputeResult(response);
503
+ if (result.walker) {
504
+ try {
505
+ saveWalkerStatus(capture.metadataDirectory, capture.derivation.request, result.walker);
506
+ }
507
+ catch {
508
+ throw new PrecomputeFailure('Precompute response received, but its walker status could not be saved.');
509
+ }
510
+ }
511
+ return result;
416
512
  }
417
513
  export async function verifyPrecomputeCommand(options) {
418
514
  const startedAt = Date.now();
@@ -420,61 +516,29 @@ export async function verifyPrecomputeCommand(options) {
420
516
  const deadline = startedAt + totalWallMs;
421
517
  let outcome = 'error';
422
518
  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);
519
+ const capture = await captureCheckout(deadline);
430
520
  if (options.hook) {
431
521
  remainingMs(deadline);
432
- await startPrecomputeDelivery(metadataDirectory, derivation.request);
522
+ await startPrecomputeDelivery(capture.metadataDirectory, capture.derivation.request);
433
523
  outcome = 'pending';
434
524
  return;
435
525
  }
436
526
  let token;
437
527
  try {
438
528
  const auth = await resolveAuthContext({
439
- owner: repository.owner,
440
- repo: repository.repository,
529
+ owner: capture.derivation.request.owner,
530
+ repo: capture.derivation.request.repository,
441
531
  });
442
532
  token = auth.token;
443
533
  }
444
534
  catch (error) {
445
- if (error instanceof NotLoggedInError) {
535
+ if (error instanceof NotLoggedInError)
446
536
  outcome = 'skipped_no_auth';
447
- if (options.hook)
448
- return;
449
- }
450
537
  throw error;
451
538
  }
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);
539
+ const result = await postPrecomputeCapture(capture, token, deadline);
467
540
  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);
541
+ printResult(result);
478
542
  }
479
543
  catch (error) {
480
544
  if (!options.hook)