@haystackeditor/cli 0.18.0 → 0.20.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,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,8 @@ 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
+ import { blockLines, parseOnboardingBlock } from './verify-onboarding.js';
16
17
  // --hook has five seconds to capture the exact source and publish local custody.
17
18
  // Its detached sender owns the bounded network delivery and acknowledgement.
18
19
  // An explicit invocation still waits synchronously for the server response.
@@ -66,7 +67,7 @@ function gitOutput(args, gitRoot, deadline, options = {}) {
66
67
  throw new PrecomputeFailure('Could not derive the repository snapshot.');
67
68
  }
68
69
  }
69
- function resolveOriginRepository(gitRoot) {
70
+ export function resolveOriginRepository(gitRoot) {
70
71
  try {
71
72
  const remote = parseRemoteUrl('origin');
72
73
  if (!isRepositoryComponent(remote.owner)
@@ -80,8 +81,8 @@ function resolveOriginRepository(gitRoot) {
80
81
  throw new PrecomputeFailure('No supported origin repository was found.');
81
82
  }
82
83
  }
83
- /** The shared base rule (utils/verify-base.ts), bounded by the hook's deadline. */
84
- function deriveBaseSha(gitRoot, deadline) {
84
+ /** The shared base rule (utils/verify-base.ts), bounded by the caller's deadline. */
85
+ export function deriveBaseSha(gitRoot, deadline) {
85
86
  try {
86
87
  return resolveVerifyBaseSha('HEAD', {
87
88
  run: (args, options) => gitOutput(args, gitRoot, deadline, options?.remote
@@ -259,7 +260,9 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
259
260
  child.stdout.pipe(gzip);
260
261
  });
261
262
  }
262
- export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS) {
263
+ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS,
264
+ /** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
265
+ budgetMs) {
263
266
  // Capture this before git and compression work so delivery latency cannot
264
267
  // make an older stop look newer than a later one.
265
268
  const observedAt = Date.now();
@@ -287,7 +290,7 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
287
290
  cacheKey,
288
291
  patchGzBase64: patchGzip.toString('base64'),
289
292
  patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
290
- ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle } }),
293
+ ...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
291
294
  },
292
295
  };
293
296
  }
@@ -330,7 +333,8 @@ function readWalkerStatus(value) {
330
333
  }
331
334
  throw new PrecomputeFailure('The precompute service returned an invalid walker status.');
332
335
  }
333
- const CRAWL_UNAVAILABLE_REASONS = ['not-configured', 'runner-unavailable', 'profile-ambiguous', 'superseded', 'enqueue-failed'];
336
+ const CRAWL_UNAVAILABLE_REASONS = ['runner-unavailable', 'superseded', 'enqueue-failed'];
337
+ const isOnboardRunId = (value) => typeof value === 'string' && /^ob_[0-9a-f]{48}$/.test(value);
334
338
  function readCrawlHookStatus(value) {
335
339
  if (value && typeof value === 'object' && !Array.isArray(value)) {
336
340
  const record = value;
@@ -341,6 +345,18 @@ function readCrawlHookStatus(value) {
341
345
  const reason = CRAWL_UNAVAILABLE_REASONS.find(known => known === record.reason);
342
346
  if (record.status === 'unavailable' && reason !== undefined)
343
347
  return { status: record.status, reason };
348
+ // Crawl amendment 8: the base has no published onboarding record yet.
349
+ if (record.status === 'onboarding' && isOnboardRunId(record.onboardRunId))
350
+ return { status: 'onboarding', onboardRunId: record.onboardRunId };
351
+ }
352
+ if (Object.keys(record).length === 3 && record.status === 'onboarding-blocked'
353
+ && (record.onboardRunId === null || isOnboardRunId(record.onboardRunId))) {
354
+ try {
355
+ return { status: 'onboarding-blocked', onboardRunId: record.onboardRunId, block: parseOnboardingBlock(record.block) };
356
+ }
357
+ catch (error) {
358
+ throw new PrecomputeFailure(`The precompute service returned an invalid onboarding block (${error instanceof Error ? error.message : String(error)}).`);
359
+ }
344
360
  }
345
361
  }
346
362
  throw new PrecomputeFailure('The precompute service returned an invalid crawl status.');
@@ -348,9 +364,7 @@ function readCrawlHookStatus(value) {
348
364
  /** Why the service could not start a crawl, in plain words. */
349
365
  export function crawlUnavailableText(reason) {
350
366
  switch (reason) {
351
- case 'not-configured': return 'this repository is not set up for crawling yet';
352
367
  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
368
  case 'superseded': return 'a newer stop in this repository came first';
355
369
  case 'enqueue-failed': return 'the service could not queue it';
356
370
  }
@@ -447,19 +461,41 @@ function printResult(result) {
447
461
  console.log(`A matching crawl already exists (${crawl.runId}).`);
448
462
  else if (crawl?.status === 'unavailable')
449
463
  console.log(`Crawl unavailable: ${crawlUnavailableText(crawl.reason)}.`);
464
+ else if (crawl?.status === 'onboarding') {
465
+ console.log(`The app is being onboarded for this base (${crawl.onboardRunId}); the crawl runs when it is ready. Run \`haystack verify\` to follow it.`);
466
+ }
467
+ else if (crawl?.status === 'onboarding-blocked')
468
+ console.log(blockLines(crawl.block).join('\n'));
450
469
  else
451
470
  console.log('Crawl status was not reported by the service.');
452
471
  }
453
472
  /** The hook, an explicit precompute and `haystack verify` all capture here.
454
473
  * The repository defaults to origin's, as the hook's does. */
455
- export async function captureCheckout(deadline, repository) {
474
+ /** The repository's crawl time: .haystack.json crawl.minutes at the git root, whole minutes from 1 to 30 (CRAWL-V1 amendment 8),
475
+ * or undefined when the file or the field is absent. A value out of range fails the capture, never silently becomes the default. */
476
+ function configuredCrawlMinutes(gitRoot) {
477
+ const path = join(gitRoot, '.haystack.json');
478
+ if (!existsSync(path))
479
+ return undefined;
480
+ const minutes = JSON.parse(readFileSync(path, 'utf8')).crawl?.minutes;
481
+ if (minutes === undefined)
482
+ return undefined;
483
+ if (typeof minutes !== 'number' || !Number.isInteger(minutes) || minutes * 60_000 < CRAWL_BUDGET_MIN_MS || minutes * 60_000 > CRAWL_BUDGET_MAX_MS) {
484
+ 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}.`);
485
+ }
486
+ return minutes;
487
+ }
488
+ export async function captureCheckout(deadline, repository,
489
+ /** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
490
+ minutes) {
456
491
  const gitRoot = findGitRoot();
457
492
  if (!gitRoot) {
458
493
  throw new PrecomputeFailure('Run this command from a git repository.');
459
494
  }
460
495
  const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
461
496
  const target = repository ?? resolveOriginRepository(gitRoot);
462
- const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline);
497
+ const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
498
+ const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
463
499
  return { metadataDirectory, derivation };
464
500
  }
465
501
  /** The explicit (non-hook) submission of a capture: one synchronous