@haystackeditor/cli 0.15.27 → 0.15.29

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.
Files changed (55) hide show
  1. package/README.md +13 -20
  2. package/dist/assets/hooks/agent-context/detect.ts +7 -7
  3. package/dist/assets/hooks/agent-context/parsers/claude.ts +1 -1
  4. package/dist/assets/hooks/scripts/commit-msg.sh +1 -3
  5. package/dist/assets/hooks/scripts/post-commit.sh +1 -3
  6. package/dist/assets/hooks/scripts/pre-push.sh +1 -4
  7. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +1 -2
  8. package/dist/assets/telemetry/browser-runtime.js +668 -43
  9. package/dist/assets/telemetry/runtime.cjs +62 -68
  10. package/dist/commands/design-verify.d.ts +11 -3
  11. package/dist/commands/design-verify.js +147 -4
  12. package/dist/commands/hooks.d.ts +1 -5
  13. package/dist/commands/hooks.js +10 -112
  14. package/dist/commands/install-session-hooks.d.ts +2 -2
  15. package/dist/commands/install-session-hooks.js +71 -21
  16. package/dist/commands/mcp.js +1 -34
  17. package/dist/commands/pr.js +2 -7
  18. package/dist/commands/setup.d.ts +1 -2
  19. package/dist/commands/setup.js +18 -272
  20. package/dist/commands/submit.js +1 -2
  21. package/dist/commands/verify-hosted-mcp.js +12 -2
  22. package/dist/commands/verify-hosted-reproducibility.d.ts +1 -0
  23. package/dist/commands/verify-hosted-reproducibility.js +4 -1
  24. package/dist/commands/verify-hosted.d.ts +20 -0
  25. package/dist/commands/verify-hosted.js +119 -11
  26. package/dist/commands/verify-precompute.d.ts +19 -0
  27. package/dist/commands/verify-precompute.js +328 -0
  28. package/dist/index.js +45 -44
  29. package/dist/schema.d.ts +1 -2
  30. package/dist/schema.js +1 -2
  31. package/dist/triage/prompts.d.ts +0 -7
  32. package/dist/triage/prompts.js +0 -145
  33. package/dist/triage/runner.js +3 -22
  34. package/dist/triage/types.d.ts +1 -8
  35. package/dist/types.d.ts +8 -8
  36. package/dist/types.js +1 -1
  37. package/dist/utils/design-verifier-api.d.ts +71 -0
  38. package/dist/utils/design-verifier-api.js +96 -0
  39. package/dist/utils/design-verifier-result.d.ts +7 -2
  40. package/dist/utils/design-verifier-result.js +67 -21
  41. package/dist/utils/git.d.ts +5 -1
  42. package/dist/utils/git.js +19 -9
  43. package/dist/utils/github-api.js +11 -14
  44. package/dist/utils/hooks.d.ts +1 -15
  45. package/dist/utils/hooks.js +1 -127
  46. package/dist/utils/telemetry.d.ts +2 -0
  47. package/dist/utils/telemetry.js +81 -8
  48. package/package.json +2 -1
  49. package/schemas/cloud-verifier.v1.json +69 -3
  50. package/schemas/{pr.v2.json → pr.v3.json} +3 -12
  51. package/dist/commands/traces.d.ts +0 -48
  52. package/dist/commands/traces.js +0 -92
  53. package/dist/triage/traces.d.ts +0 -20
  54. package/dist/triage/traces.js +0 -311
  55. package/schemas/traces.v1.json +0 -53
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
2
3
  import chalk from 'chalk';
3
4
  import { withSchema } from '../schema.js';
4
5
  import { resolveAuthContext } from '../utils/auth.js';
@@ -11,6 +12,11 @@ const STATUS = /^[a-z][a-z0-9_-]{1,31}$/;
11
12
  const TERMINAL_STATUSES = new Set(['complete', 'errored', 'expired', 'terminated']);
12
13
  const DEFAULT_INTERVAL_SECONDS = 5;
13
14
  const DEFAULT_TIMEOUT_MINUTES = 35;
15
+ const INTENT_FIELDS = [
16
+ 'problem',
17
+ 'goal',
18
+ 'intended_outcomes',
19
+ ];
14
20
  function requireRecord(value, context) {
15
21
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
16
22
  throw new Error(`${context} returned an invalid response object.`);
@@ -73,10 +79,22 @@ export function parseHostedRunState(value, expectedRunId) {
73
79
  const output = outputValue === undefined
74
80
  ? undefined
75
81
  : requireRecord(outputValue, 'Hosted verifier output');
82
+ const resultValue = response.result;
83
+ const result = resultValue === undefined
84
+ ? undefined
85
+ : parseHostedRunnerResult(resultValue);
76
86
  const createdAt = optionalTimestamp(response, 'createdAt');
77
87
  const completedAt = optionalTimestamp(response, 'completedAt');
78
88
  if (status === 'complete') {
79
- if (!repository
89
+ // Two terminal shapes exist: the Workflow receipt envelope (`output`)
90
+ // and the runner-queue result envelope (`result`). Exactly one must
91
+ // validate.
92
+ if (result) {
93
+ if (!repository || !baseCommit || !headCommit || !createdAt) {
94
+ throw new Error('Hosted verifier returned a complete run without a matching terminal receipt.');
95
+ }
96
+ }
97
+ else if (!repository
80
98
  || !baseCommit
81
99
  || !headCommit
82
100
  || !createdAt
@@ -100,9 +118,35 @@ export function parseHostedRunState(value, expectedRunId) {
100
118
  createdAt,
101
119
  completedAt,
102
120
  output,
121
+ result,
103
122
  error: optionalString(response, 'error'),
104
123
  };
105
124
  }
125
+ function parseHostedRunnerResult(value) {
126
+ const record = requireRecord(value, 'Hosted verifier runner result');
127
+ const regressionsValue = record.regressions;
128
+ if (typeof record.verdict !== 'string'
129
+ || typeof record.summary !== 'string'
130
+ || !Number.isInteger(record.casesTotal)
131
+ || !Number.isInteger(record.casesFailed)
132
+ || typeof record.outputLocation !== 'string'
133
+ || typeof record.wallSeconds !== 'number'
134
+ || !Array.isArray(regressionsValue)
135
+ || !regressionsValue.every(item => !!item && typeof item === 'object' && !Array.isArray(item)
136
+ && typeof item.caseId === 'string'
137
+ && typeof item.title === 'string')) {
138
+ throw new Error('Hosted verifier runner result is malformed.');
139
+ }
140
+ return {
141
+ verdict: record.verdict,
142
+ summary: record.summary,
143
+ casesTotal: record.casesTotal,
144
+ casesFailed: record.casesFailed,
145
+ regressions: regressionsValue,
146
+ outputLocation: record.outputLocation,
147
+ wallSeconds: record.wallSeconds,
148
+ };
149
+ }
106
150
  export function parseHostedRepository(value) {
107
151
  const parts = value.split('/');
108
152
  if (parts.length !== 2
@@ -121,6 +165,54 @@ function requireCommit(value, flag) {
121
165
  }
122
166
  return value;
123
167
  }
168
+ export function readHostedIntentFile(intentFile) {
169
+ if (typeof intentFile !== 'string' || intentFile.length === 0) {
170
+ throw new Error('--intent-file is required and must name a JSON file containing exactly problem, goal, and intended_outcomes.');
171
+ }
172
+ let source;
173
+ try {
174
+ source = readFileSync(intentFile, 'utf8');
175
+ }
176
+ catch (error) {
177
+ const reason = error instanceof Error ? error.message : String(error);
178
+ throw new Error(`Could not read --intent-file "${intentFile}": ${reason}`);
179
+ }
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(source);
183
+ }
184
+ catch {
185
+ throw new Error(`--intent-file "${intentFile}" must contain valid JSON.`);
186
+ }
187
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
188
+ throw new Error(`--intent-file "${intentFile}" must contain exactly problem, goal, and intended_outcomes.`);
189
+ }
190
+ const record = parsed;
191
+ const unknownField = Object.keys(record)
192
+ .find(field => !INTENT_FIELDS.includes(field));
193
+ if (unknownField) {
194
+ throw new Error(`--intent-file "${intentFile}" contains unknown field "${unknownField}"; expected exactly problem, goal, and intended_outcomes.`);
195
+ }
196
+ for (const field of ['problem', 'goal']) {
197
+ if (typeof record[field] !== 'string' || record[field].trim().length === 0) {
198
+ throw new Error(`--intent-file "${intentFile}" field "${field}" must be a non-empty string.`);
199
+ }
200
+ }
201
+ const outcomes = record.intended_outcomes;
202
+ if (!Array.isArray(outcomes)
203
+ || outcomes.length === 0
204
+ || outcomes.some(item => typeof item !== 'string' || item.trim().length === 0)) {
205
+ throw new Error(`--intent-file "${intentFile}" field "intended_outcomes" must be a non-empty array of non-empty strings.`);
206
+ }
207
+ if (Object.keys(record).length !== INTENT_FIELDS.length) {
208
+ throw new Error(`--intent-file "${intentFile}" must contain exactly problem, goal, and intended_outcomes.`);
209
+ }
210
+ return {
211
+ problem: record.problem,
212
+ goal: record.goal,
213
+ intended_outcomes: record.intended_outcomes,
214
+ };
215
+ }
124
216
  export function defaultHostedIdempotencyKey(repository, baseCommit, headCommit, replayPlanFrom) {
125
217
  const digest = createHash('sha256')
126
218
  .update(JSON.stringify(replayPlanFrom
@@ -155,25 +247,28 @@ export async function startHostedVerification(repositoryArg, options, token) {
155
247
  const repository = parseHostedRepository(repositoryArg);
156
248
  const baseCommit = requireCommit(options.base, '--base');
157
249
  const headCommit = requireCommit(options.head, '--head');
250
+ const intent = readHostedIntentFile(options.intentFile);
158
251
  const replayPlanFrom = options.replayPlanFrom;
159
252
  if (replayPlanFrom !== undefined && !RUN_ID.test(replayPlanFrom)) {
160
253
  throw new Error('--replay-plan-from must match cv_ followed by 48 lowercase hex characters.');
161
254
  }
162
255
  const idempotencyKey = requireIdempotencyKey(options.idempotencyKey
163
256
  ?? defaultHostedIdempotencyKey(repository.fullName, baseCommit, headCommit, replayPlanFrom));
257
+ const launchBody = {
258
+ owner: repository.owner,
259
+ repository: repository.repository,
260
+ baseCommit,
261
+ headCommit,
262
+ idempotencyKey,
263
+ intent,
264
+ ...(replayPlanFrom
265
+ ? { replayPlanFromRunId: replayPlanFrom }
266
+ : {}),
267
+ };
164
268
  const response = await haystackJson('/api/agent/cloud-verifier/runs', token, {
165
269
  method: 'POST',
166
270
  headers: { 'Content-Type': 'application/json' },
167
- body: JSON.stringify({
168
- owner: repository.owner,
169
- repository: repository.repository,
170
- baseCommit,
171
- headCommit,
172
- idempotencyKey,
173
- ...(replayPlanFrom
174
- ? { replayPlanFromRunId: replayPlanFrom }
175
- : {}),
176
- }),
271
+ body: JSON.stringify(launchBody),
177
272
  });
178
273
  const state = parseHostedRunState(response);
179
274
  return {
@@ -242,6 +337,7 @@ export function buildHostedJson(operation, state, extras = {}) {
242
337
  ...(extras.nextCommand ? { next_command: extras.nextCommand } : {}),
243
338
  ...(extras.timedOut !== undefined ? { timed_out: extras.timedOut } : {}),
244
339
  ...(state.output ? { output: state.output } : {}),
340
+ ...(state.result ? { result: state.result } : {}),
245
341
  ...(state.error ? { error: state.error } : {}),
246
342
  });
247
343
  }
@@ -273,6 +369,15 @@ function printHostedState(operation, state, options) {
273
369
  if (state.baseCommit && state.headCommit) {
274
370
  console.log(` ${chalk.dim('Commits:')} ${state.baseCommit.slice(0, 12)} → ${state.headCommit.slice(0, 12)}`);
275
371
  }
372
+ if (state.result) {
373
+ const passed = state.result.casesTotal - state.result.casesFailed;
374
+ console.log(` ${chalk.dim('Verdict:')} ${state.result.verdict} `
375
+ + `(${passed}/${state.result.casesTotal} cases clean, ${Math.round(state.result.wallSeconds)}s)`);
376
+ for (const regression of state.result.regressions) {
377
+ console.log(` ${chalk.red('✗')} ${regression.caseId}: ${regression.title}`);
378
+ }
379
+ console.log(` ${chalk.dim('Artifacts:')} ${state.result.outputLocation}`);
380
+ }
276
381
  if (state.error)
277
382
  console.log(` ${chalk.dim('Error:')} ${state.error}`);
278
383
  if (options.timedOut) {
@@ -289,6 +394,9 @@ function markNonSuccess(state, timedOut = false) {
289
394
  }
290
395
  export async function verifyHostedStartCommand(repositoryArg, options) {
291
396
  const repository = parseHostedRepository(repositoryArg);
397
+ // Fail before account resolution so an invalid local intent never advances
398
+ // toward a hosted launch.
399
+ readHostedIntentFile(options.intentFile);
292
400
  const auth = await resolveAuthContext({
293
401
  preferredLogin: options.account,
294
402
  owner: repository.owner,
@@ -0,0 +1,19 @@
1
+ export type PrecomputeOutcome = 'enqueued' | 'cached' | 'existing' | 'skipped_no_auth' | 'error';
2
+ export interface VerifyPrecomputeOptions {
3
+ hook?: boolean;
4
+ }
5
+ export interface FrontHalfEnqueueRequest {
6
+ owner: string;
7
+ repository: string;
8
+ baseCommit: string;
9
+ workCommit: string;
10
+ cacheKey: string;
11
+ patchGzBase64: string;
12
+ patchSha256: string;
13
+ }
14
+ export interface PrecomputeDerivation {
15
+ treeSha: string;
16
+ request: FrontHalfEnqueueRequest;
17
+ }
18
+ export declare function derivePrecomputeRequest(gitRoot: string, owner: string, repository: string, deadline?: number): Promise<PrecomputeDerivation>;
19
+ export declare function verifyPrecomputeCommand(options: VerifyPrecomputeOptions): Promise<void>;
@@ -0,0 +1,328 @@
1
+ import { spawn, execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { createGzip } from 'node:zlib';
5
+ import { isAbsolute, join, resolve } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import chalk from 'chalk';
8
+ import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
9
+ import { findGitRoot, parseRemoteUrl, resolveDiffBaseRef, } from '../utils/git.js';
10
+ import { HaystackApiError, haystackJson } from '../utils/haystack-api.js';
11
+ import { trackPrecomputeEvent } from '../utils/telemetry.js';
12
+ const TOTAL_WALL_MS = 5_000;
13
+ const REMOTE_COMMAND_MS = 750;
14
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
15
+ const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
16
+ const RUN_ID = /^cv_[0-9a-f]{48}$/;
17
+ const FIXED_COMMIT_ENV = {
18
+ GIT_AUTHOR_NAME: 'haystack-stop-hook',
19
+ GIT_AUTHOR_EMAIL: 'stop-hook@haystack.invalid',
20
+ GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z',
21
+ GIT_COMMITTER_NAME: 'haystack-stop-hook',
22
+ GIT_COMMITTER_EMAIL: 'stop-hook@haystack.invalid',
23
+ GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z',
24
+ };
25
+ class PrecomputeFailure extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = 'PrecomputeFailure';
29
+ }
30
+ }
31
+ function remainingMs(deadline) {
32
+ const remaining = deadline - Date.now();
33
+ if (remaining <= 0) {
34
+ throw new PrecomputeFailure('Precompute timed out.');
35
+ }
36
+ return remaining;
37
+ }
38
+ function gitOutput(args, gitRoot, deadline, options = {}) {
39
+ const timeout = Math.max(1, Math.min(remainingMs(deadline), options.timeoutCapMs ?? TOTAL_WALL_MS));
40
+ try {
41
+ return execFileSync('git', args, {
42
+ cwd: gitRoot,
43
+ encoding: 'utf8',
44
+ env: options.env ?? process.env,
45
+ stdio: ['pipe', 'pipe', 'pipe'],
46
+ timeout,
47
+ }).trim();
48
+ }
49
+ catch {
50
+ throw new PrecomputeFailure('Could not derive the repository snapshot.');
51
+ }
52
+ }
53
+ function resolveOriginRepository(gitRoot) {
54
+ try {
55
+ const remote = parseRemoteUrl('origin');
56
+ if (!REPOSITORY_COMPONENT.test(remote.owner)
57
+ || !REPOSITORY_COMPONENT.test(remote.repo)) {
58
+ throw new Error('invalid repository identity');
59
+ }
60
+ return { owner: remote.owner, repository: remote.repo };
61
+ }
62
+ catch {
63
+ // Keep remote URLs and local paths out of errors.
64
+ throw new PrecomputeFailure('No supported origin repository was found.');
65
+ }
66
+ }
67
+ function resolveOriginDefaultBranch(gitRoot, deadline) {
68
+ try {
69
+ const remoteHead = gitOutput(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], gitRoot, deadline);
70
+ if (remoteHead.startsWith('origin/') && remoteHead.length > 'origin/'.length) {
71
+ return remoteHead.slice('origin/'.length);
72
+ }
73
+ }
74
+ catch {
75
+ // A clone does not have to carry origin/HEAD; ask the remote below.
76
+ }
77
+ const remoteSummary = gitOutput(['remote', 'show', 'origin'], gitRoot, deadline, {
78
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
79
+ timeoutCapMs: REMOTE_COMMAND_MS,
80
+ });
81
+ const match = remoteSummary.match(/^\s*HEAD branch:\s*(\S+)\s*$/m);
82
+ if (!match) {
83
+ throw new PrecomputeFailure('Could not resolve the origin default branch.');
84
+ }
85
+ return match[1];
86
+ }
87
+ function deriveBaseSha(gitRoot, deadline) {
88
+ const defaultBranch = resolveOriginDefaultBranch(gitRoot, deadline);
89
+ const baseRef = resolveDiffBaseRef(defaultBranch, {
90
+ cwd: gitRoot,
91
+ timeoutMs: Math.max(1, Math.min(remainingMs(deadline), REMOTE_COMMAND_MS)),
92
+ });
93
+ const baseSha = gitOutput(['merge-base', 'HEAD', baseRef], gitRoot, deadline);
94
+ if (!COMMIT_SHA.test(baseSha)) {
95
+ throw new PrecomputeFailure('Could not derive the base commit.');
96
+ }
97
+ return baseSha;
98
+ }
99
+ function deriveWorktreeTreeSha(gitRoot, deadline) {
100
+ const scratchDirectory = mkdtempSync(join(tmpdir(), 'haystack-precompute-index-'));
101
+ const temporaryIndex = join(scratchDirectory, 'index');
102
+ try {
103
+ const gitIndexPath = gitOutput(['rev-parse', '--git-path', 'index'], gitRoot, deadline);
104
+ const sourceIndex = isAbsolute(gitIndexPath)
105
+ ? gitIndexPath
106
+ : resolve(gitRoot, gitIndexPath);
107
+ const indexEnv = { ...process.env, GIT_INDEX_FILE: temporaryIndex };
108
+ if (existsSync(sourceIndex)) {
109
+ // blast_cache_worktree_tree_sha uses `cp -p`: preserving timestamps keeps
110
+ // git's racy-clean handling byte-identical to the bash implementation.
111
+ cpSync(sourceIndex, temporaryIndex, { preserveTimestamps: true });
112
+ }
113
+ else {
114
+ gitOutput(['read-tree', 'HEAD'], gitRoot, deadline, { env: indexEnv });
115
+ }
116
+ gitOutput(['add', '-A'], gitRoot, deadline, { env: indexEnv });
117
+ const treeSha = gitOutput(['write-tree'], gitRoot, deadline, { env: indexEnv });
118
+ if (!COMMIT_SHA.test(treeSha)) {
119
+ throw new PrecomputeFailure('Could not derive the working-tree snapshot.');
120
+ }
121
+ return treeSha;
122
+ }
123
+ finally {
124
+ rmSync(scratchDirectory, { recursive: true, force: true });
125
+ }
126
+ }
127
+ function deriveWorkCommit(gitRoot, baseSha, treeSha, deadline) {
128
+ const headSha = gitOutput(['rev-parse', 'HEAD'], gitRoot, deadline);
129
+ const headTreeSha = gitOutput(['rev-parse', 'HEAD^{tree}'], gitRoot, deadline);
130
+ if (!COMMIT_SHA.test(headSha) || !COMMIT_SHA.test(headTreeSha)) {
131
+ throw new PrecomputeFailure('Could not resolve the current commit.');
132
+ }
133
+ // A clean checkout of the base already has the exact required identity.
134
+ if (baseSha === headSha && treeSha === headTreeSha)
135
+ return headSha;
136
+ const workCommit = gitOutput(['commit-tree', treeSha, '-p', baseSha, '-m', 'front-half-snapshot'], gitRoot, deadline, { env: { ...process.env, ...FIXED_COMMIT_ENV } });
137
+ if (!COMMIT_SHA.test(workCommit)) {
138
+ throw new PrecomputeFailure('Could not derive the working commit.');
139
+ }
140
+ return workCommit;
141
+ }
142
+ function slimInputHash() {
143
+ // Mirrors printf '%s\0%s\0%s\0%s\0' slim agent-deferred none none.
144
+ return createHash('sha256')
145
+ .update(Buffer.from('slim\0agent-deferred\0none\0none\0'))
146
+ .digest('hex');
147
+ }
148
+ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
149
+ return new Promise((resolvePatch, rejectPatch) => {
150
+ const child = spawn('git', ['diff', '--binary', baseSha, workCommit], {
151
+ cwd: gitRoot,
152
+ stdio: ['ignore', 'pipe', 'pipe'],
153
+ });
154
+ const gzip = createGzip();
155
+ const chunks = [];
156
+ let compressedBytes = 0;
157
+ let childClosed = false;
158
+ let gzipEnded = false;
159
+ let childSucceeded = false;
160
+ let settled = false;
161
+ const timer = setTimeout(() => {
162
+ child.kill('SIGKILL');
163
+ fail(new PrecomputeFailure('Precompute timed out.'));
164
+ }, remainingMs(deadline));
165
+ const clear = () => clearTimeout(timer);
166
+ const fail = (error) => {
167
+ if (settled)
168
+ return;
169
+ settled = true;
170
+ clear();
171
+ child.kill('SIGKILL');
172
+ gzip.destroy();
173
+ rejectPatch(error);
174
+ };
175
+ const finish = () => {
176
+ if (settled || !childClosed || !gzipEnded)
177
+ return;
178
+ if (!childSucceeded) {
179
+ fail(new PrecomputeFailure('Could not build the working-tree patch.'));
180
+ return;
181
+ }
182
+ settled = true;
183
+ clear();
184
+ resolvePatch(Buffer.concat(chunks, compressedBytes));
185
+ };
186
+ child.stderr.resume();
187
+ child.once('error', () => fail(new PrecomputeFailure('Could not build the working-tree patch.')));
188
+ child.once('close', code => {
189
+ childClosed = true;
190
+ childSucceeded = code === 0;
191
+ finish();
192
+ });
193
+ gzip.on('data', (chunk) => {
194
+ compressedBytes += chunk.length;
195
+ chunks.push(chunk);
196
+ });
197
+ gzip.once('error', () => fail(new PrecomputeFailure('Could not compress the working-tree patch.')));
198
+ gzip.once('end', () => {
199
+ gzipEnded = true;
200
+ finish();
201
+ });
202
+ child.stdout.pipe(gzip);
203
+ });
204
+ }
205
+ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + TOTAL_WALL_MS) {
206
+ const baseSha = deriveBaseSha(gitRoot, deadline);
207
+ const treeSha = deriveWorktreeTreeSha(gitRoot, deadline);
208
+ const workCommit = deriveWorkCommit(gitRoot, baseSha, treeSha, deadline);
209
+ const patchGzip = await buildCompressedPatch(gitRoot, baseSha, workCommit, deadline);
210
+ const cacheKey = [
211
+ baseSha.slice(0, 12),
212
+ workCommit.slice(0, 12),
213
+ treeSha.slice(0, 16),
214
+ 'slim',
215
+ slimInputHash(),
216
+ ].join('-');
217
+ return {
218
+ treeSha,
219
+ request: {
220
+ owner,
221
+ repository,
222
+ baseCommit: baseSha,
223
+ workCommit,
224
+ cacheKey,
225
+ patchGzBase64: patchGzip.toString('base64'),
226
+ patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
227
+ },
228
+ };
229
+ }
230
+ function precomputeResult(response) {
231
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
232
+ throw new PrecomputeFailure('The precompute service returned an invalid response.');
233
+ }
234
+ const { status, runId } = response;
235
+ if (status === 'queued' && typeof runId === 'string' && RUN_ID.test(runId)) {
236
+ return { outcome: 'enqueued', runId };
237
+ }
238
+ if (status === 'cached')
239
+ return { outcome: 'cached' };
240
+ if (status === 'existing')
241
+ return { outcome: 'existing' };
242
+ throw new PrecomputeFailure('The precompute service returned an invalid response.');
243
+ }
244
+ function normalModeFailure(error) {
245
+ if (error instanceof PrecomputeFailure)
246
+ return error;
247
+ if (error instanceof NotLoggedInError) {
248
+ return new PrecomputeFailure('Authentication is required. Run `haystack login` first.');
249
+ }
250
+ if (error instanceof HaystackApiError) {
251
+ if (error.status === 401 || error.status === 403) {
252
+ return new PrecomputeFailure('Authentication was rejected. Run `haystack login` and try again.');
253
+ }
254
+ if (error.status === 429) {
255
+ return new PrecomputeFailure('Precompute requests are temporarily rate limited. Try again later.');
256
+ }
257
+ return new PrecomputeFailure(`The precompute request failed (HTTP ${error.status}).`);
258
+ }
259
+ return new PrecomputeFailure('The precompute request failed.');
260
+ }
261
+ function printResult(outcome, runId) {
262
+ if (outcome === 'enqueued') {
263
+ console.log(chalk.green(`Precompute request queued${runId ? ` (${runId})` : ''}.`));
264
+ }
265
+ else if (outcome === 'cached') {
266
+ console.log(chalk.green('Precompute result is already cached.'));
267
+ }
268
+ else if (outcome === 'existing') {
269
+ console.log(chalk.green('A matching precompute request already exists.'));
270
+ }
271
+ }
272
+ export async function verifyPrecomputeCommand(options) {
273
+ const startedAt = Date.now();
274
+ const deadline = startedAt + TOTAL_WALL_MS;
275
+ let outcome = 'error';
276
+ try {
277
+ const gitRoot = findGitRoot();
278
+ if (!gitRoot) {
279
+ throw new PrecomputeFailure('Run this command from a git repository.');
280
+ }
281
+ const repository = resolveOriginRepository(gitRoot);
282
+ const derivation = await derivePrecomputeRequest(gitRoot, repository.owner, repository.repository, deadline);
283
+ let token;
284
+ try {
285
+ const auth = await resolveAuthContext({
286
+ owner: repository.owner,
287
+ repo: repository.repository,
288
+ });
289
+ token = auth.token;
290
+ }
291
+ catch (error) {
292
+ if (error instanceof NotLoggedInError) {
293
+ outcome = 'skipped_no_auth';
294
+ if (options.hook)
295
+ return;
296
+ }
297
+ throw error;
298
+ }
299
+ const controller = new AbortController();
300
+ const fetchTimer = setTimeout(() => controller.abort(), remainingMs(deadline));
301
+ let response;
302
+ try {
303
+ response = await haystackJson('/api/agent/cloud-verifier/front-half', token, {
304
+ method: 'POST',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify(derivation.request),
307
+ signal: controller.signal,
308
+ });
309
+ }
310
+ finally {
311
+ clearTimeout(fetchTimer);
312
+ }
313
+ const result = precomputeResult(response);
314
+ outcome = result.outcome;
315
+ if (!options.hook)
316
+ printResult(outcome, result.runId);
317
+ }
318
+ catch (error) {
319
+ if (!options.hook)
320
+ throw normalModeFailure(error);
321
+ }
322
+ finally {
323
+ const wallMs = Date.now() - startedAt;
324
+ trackPrecomputeEvent(outcome, wallMs, Math.max(1, TOTAL_WALL_MS - wallMs));
325
+ if (options.hook)
326
+ process.exitCode = 0;
327
+ }
328
+ }