@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.
- package/README.md +13 -20
- package/dist/assets/hooks/agent-context/detect.ts +7 -7
- package/dist/assets/hooks/agent-context/parsers/claude.ts +1 -1
- package/dist/assets/hooks/scripts/commit-msg.sh +1 -3
- package/dist/assets/hooks/scripts/post-commit.sh +1 -3
- package/dist/assets/hooks/scripts/pre-push.sh +1 -4
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +1 -2
- package/dist/assets/telemetry/browser-runtime.js +668 -43
- package/dist/assets/telemetry/runtime.cjs +62 -68
- package/dist/commands/design-verify.d.ts +11 -3
- package/dist/commands/design-verify.js +147 -4
- package/dist/commands/hooks.d.ts +1 -5
- package/dist/commands/hooks.js +10 -112
- package/dist/commands/install-session-hooks.d.ts +2 -2
- package/dist/commands/install-session-hooks.js +71 -21
- package/dist/commands/mcp.js +1 -34
- package/dist/commands/pr.js +2 -7
- package/dist/commands/setup.d.ts +1 -2
- package/dist/commands/setup.js +18 -272
- package/dist/commands/submit.js +1 -2
- package/dist/commands/verify-hosted-mcp.js +12 -2
- package/dist/commands/verify-hosted-reproducibility.d.ts +1 -0
- package/dist/commands/verify-hosted-reproducibility.js +4 -1
- package/dist/commands/verify-hosted.d.ts +20 -0
- package/dist/commands/verify-hosted.js +119 -11
- package/dist/commands/verify-precompute.d.ts +19 -0
- package/dist/commands/verify-precompute.js +328 -0
- package/dist/index.js +45 -44
- package/dist/schema.d.ts +1 -2
- package/dist/schema.js +1 -2
- package/dist/triage/prompts.d.ts +0 -7
- package/dist/triage/prompts.js +0 -145
- package/dist/triage/runner.js +3 -22
- package/dist/triage/types.d.ts +1 -8
- package/dist/types.d.ts +8 -8
- package/dist/types.js +1 -1
- package/dist/utils/design-verifier-api.d.ts +71 -0
- package/dist/utils/design-verifier-api.js +96 -0
- package/dist/utils/design-verifier-result.d.ts +7 -2
- package/dist/utils/design-verifier-result.js +67 -21
- package/dist/utils/git.d.ts +5 -1
- package/dist/utils/git.js +19 -9
- package/dist/utils/github-api.js +11 -14
- package/dist/utils/hooks.d.ts +1 -15
- package/dist/utils/hooks.js +1 -127
- package/dist/utils/telemetry.d.ts +2 -0
- package/dist/utils/telemetry.js +81 -8
- package/package.json +2 -1
- package/schemas/cloud-verifier.v1.json +69 -3
- package/schemas/{pr.v2.json → pr.v3.json} +3 -12
- package/dist/commands/traces.d.ts +0 -48
- package/dist/commands/traces.js +0 -92
- package/dist/triage/traces.d.ts +0 -20
- package/dist/triage/traces.js +0 -311
- 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
|
-
|
|
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
|
+
}
|