@haystackeditor/cli 0.15.29 → 0.15.30
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/dist/assets/telemetry/browser-runtime.js +148 -2
- package/dist/commands/precompute-delivery-contract.d.ts +68 -0
- package/dist/commands/precompute-delivery-contract.js +1 -0
- package/dist/commands/precompute-delivery-worker.d.ts +1 -0
- package/dist/commands/precompute-delivery-worker.js +68 -0
- package/dist/commands/precompute-delivery.d.ts +20 -0
- package/dist/commands/precompute-delivery.js +319 -0
- package/dist/commands/verify-explore.d.ts +14 -0
- package/dist/commands/verify-explore.js +180 -0
- package/dist/commands/verify-hosted.d.ts +12 -2
- package/dist/commands/verify-hosted.js +186 -50
- package/dist/commands/verify-precompute.d.ts +12 -1
- package/dist/commands/verify-precompute.js +135 -27
- package/dist/commands/verify.d.ts +6 -0
- package/dist/commands/verify.js +22 -0
- package/dist/index.js +43 -5
- package/dist/utils/design-verifier-result.d.ts +17 -0
- package/dist/utils/design-verifier-result.js +80 -1
- package/dist/utils/git.js +5 -1
- package/dist/utils/telemetry.js +8 -2
- package/package.json +1 -1
- package/schemas/cloud-verifier.v1.json +108 -45
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
3
|
+
import { haystackJson } from '../utils/haystack-api.js';
|
|
4
|
+
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
5
|
+
const CASE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
6
|
+
const ACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
7
|
+
const TERMINAL = new Set(['complete', 'errored', 'uncertain']);
|
|
8
|
+
function object(value) {
|
|
9
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function timestamp(value) {
|
|
12
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
13
|
+
}
|
|
14
|
+
function terminalSafe(value) {
|
|
15
|
+
return value.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
|
|
16
|
+
}
|
|
17
|
+
async function context(runId, repository) {
|
|
18
|
+
if (!RUN_ID.test(runId))
|
|
19
|
+
throw new Error('Use the exact cv_… run ID from verification.');
|
|
20
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository || '')) {
|
|
21
|
+
throw new Error('--repo must be owner/repository.');
|
|
22
|
+
}
|
|
23
|
+
const [owner, repo] = repository.split('/');
|
|
24
|
+
if ([owner, repo].some(part => part === '.' || part === '..'))
|
|
25
|
+
throw new Error('Invalid repository.');
|
|
26
|
+
const { token } = await resolveAuthContext({ owner, repo });
|
|
27
|
+
return {
|
|
28
|
+
token,
|
|
29
|
+
path: `/api/agent/cloud-verifier/runs/${runId}/cases`,
|
|
30
|
+
query: `?repository=${encodeURIComponent(repository)}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function verifyCasesCommand(runId, options) {
|
|
34
|
+
const ctx = await context(runId, options.repo);
|
|
35
|
+
const payload = await haystackJson(ctx.path + ctx.query, ctx.token, {
|
|
36
|
+
signal: AbortSignal.timeout(30_000),
|
|
37
|
+
});
|
|
38
|
+
if (!object(payload) || !Array.isArray(payload.cases)
|
|
39
|
+
|| payload.cases.some(item => !object(item) || item.runId !== runId
|
|
40
|
+
|| typeof item.caseId !== 'string' || !CASE_ID.test(item.caseId)
|
|
41
|
+
|| (item.side !== 'base' && item.side !== 'head')
|
|
42
|
+
|| typeof item.revisionSha !== 'string' || !/^[0-9a-f]{40}$/.test(item.revisionSha)
|
|
43
|
+
|| !timestamp(item.expiresAt) || !Array.isArray(item.capabilities)
|
|
44
|
+
|| item.capabilities.join(',') !== 'open,exec,close')) {
|
|
45
|
+
throw new Error('Server returned an invalid retained-case catalog.');
|
|
46
|
+
}
|
|
47
|
+
const cases = payload.cases;
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const item of cases) {
|
|
50
|
+
const key = JSON.stringify([item.runId, item.caseId, item.side]);
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
throw new Error('Server returned duplicate case identities.');
|
|
53
|
+
seen.add(key);
|
|
54
|
+
}
|
|
55
|
+
if (options.json) {
|
|
56
|
+
console.log(JSON.stringify({ runId, cases }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!cases.length) {
|
|
60
|
+
console.log('No saved cases are available. They appear after completion and expire 24 hours later.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const item of cases)
|
|
64
|
+
console.log(`${item.caseId} ${item.side} — available until ${item.expiresAt}`);
|
|
65
|
+
console.log(`Open a copy: haystack verify explore ${runId} CASE_ID --repo ${options.repo} --side head`);
|
|
66
|
+
}
|
|
67
|
+
function parseAction(payload, runId, caseId, side, actionId) {
|
|
68
|
+
if (!object(payload) || !object(payload.action))
|
|
69
|
+
throw new Error('Invalid action response.');
|
|
70
|
+
const action = payload.action;
|
|
71
|
+
if (action.runId !== runId || action.caseId !== caseId || action.side !== side || action.actionId !== actionId
|
|
72
|
+
|| typeof action.state !== 'string'
|
|
73
|
+
|| !['queued', 'claimed', 'running', 'complete', 'errored', 'uncertain'].includes(action.state)
|
|
74
|
+
|| !timestamp(action.expiresAt)
|
|
75
|
+
|| (action.error !== null && typeof action.error !== 'string')
|
|
76
|
+
|| (action.output !== null && (!object(action.output)
|
|
77
|
+
|| typeof action.output.stdout !== 'string' || typeof action.output.stderr !== 'string'
|
|
78
|
+
|| typeof action.output.exitCode !== 'number' || !Number.isInteger(action.output.exitCode)))) {
|
|
79
|
+
throw new Error('Server returned an action for the wrong case or an invalid result.');
|
|
80
|
+
}
|
|
81
|
+
return action;
|
|
82
|
+
}
|
|
83
|
+
function newRequest(options, actionId) {
|
|
84
|
+
if (options.close) {
|
|
85
|
+
if (options.execJson !== undefined || options.cwd || options.commandTimeout) {
|
|
86
|
+
throw new Error('--close cannot be combined with a command.');
|
|
87
|
+
}
|
|
88
|
+
return { actionId, kind: 'close' };
|
|
89
|
+
}
|
|
90
|
+
if (options.execJson === undefined) {
|
|
91
|
+
if (options.cwd || options.commandTimeout)
|
|
92
|
+
throw new Error('--cwd and --command-timeout require --exec-json.');
|
|
93
|
+
return { actionId, kind: 'open' };
|
|
94
|
+
}
|
|
95
|
+
const argv = JSON.parse(options.execJson);
|
|
96
|
+
if (!Array.isArray(argv) || argv.length < 1 || argv.length > 128 || !argv[0]
|
|
97
|
+
|| argv.some(arg => typeof arg !== 'string' || arg.includes('\0'))) {
|
|
98
|
+
throw new Error('--exec-json must contain 1–128 string arguments with a non-empty executable.');
|
|
99
|
+
}
|
|
100
|
+
const cwd = options.cwd ?? '/home/user/pj';
|
|
101
|
+
const timeoutMs = Number(options.commandTimeout ?? '60') * 1_000;
|
|
102
|
+
if (!cwd.startsWith('/') || /[\0\r\n]/.test(cwd) || cwd.length > 4_096) {
|
|
103
|
+
throw new Error('--cwd must be an absolute path inside the sandbox.');
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 300_000) {
|
|
106
|
+
throw new Error('--command-timeout must be between 1 and 300 seconds.');
|
|
107
|
+
}
|
|
108
|
+
const request = { actionId, kind: 'exec', argv, cwd, timeoutMs };
|
|
109
|
+
if (Buffer.byteLength(JSON.stringify(request)) > 32 * 1_024)
|
|
110
|
+
throw new Error('Command request exceeds 32 KiB.');
|
|
111
|
+
return request;
|
|
112
|
+
}
|
|
113
|
+
export async function verifyExploreCommand(runId, caseId, options) {
|
|
114
|
+
if (!CASE_ID.test(caseId))
|
|
115
|
+
throw new Error('Use the exact case ID from `haystack verify cases`.');
|
|
116
|
+
const side = options.side ?? 'head';
|
|
117
|
+
if (side !== 'base' && side !== 'head')
|
|
118
|
+
throw new Error('--side must be base or head.');
|
|
119
|
+
if (options.action && (options.requestId || options.execJson !== undefined || options.close || options.cwd || options.commandTimeout)) {
|
|
120
|
+
throw new Error('--action reads an existing action; do not combine it with a new command.');
|
|
121
|
+
}
|
|
122
|
+
const actionId = options.action ?? options.requestId ?? randomUUID();
|
|
123
|
+
if (!ACTION_ID.test(actionId))
|
|
124
|
+
throw new Error('Action/request ID must be a UUID v4.');
|
|
125
|
+
const request = options.action ? null : newRequest(options, actionId);
|
|
126
|
+
const ctx = await context(runId, options.repo);
|
|
127
|
+
const actionPath = `${ctx.path}/${encodeURIComponent(caseId)}/${side}/actions`;
|
|
128
|
+
const pollCommand = `haystack verify explore ${runId} ${caseId} --repo ${options.repo} --side ${side} --action ${actionId}`;
|
|
129
|
+
const read = async () => parseAction(await haystackJson(`${actionPath}/${actionId}${ctx.query}`, ctx.token, {
|
|
130
|
+
signal: AbortSignal.timeout(30_000),
|
|
131
|
+
}), runId, caseId, side, actionId);
|
|
132
|
+
let action;
|
|
133
|
+
try {
|
|
134
|
+
action = request
|
|
135
|
+
? parseAction(await haystackJson(actionPath + ctx.query, ctx.token, {
|
|
136
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(request), signal: AbortSignal.timeout(30_000),
|
|
138
|
+
}), runId, caseId, side, actionId)
|
|
139
|
+
: await read();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\nCheck whether it was accepted before sending another command:\n${pollCommand}`);
|
|
143
|
+
}
|
|
144
|
+
if (options.wait !== false) {
|
|
145
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
146
|
+
while (!TERMINAL.has(action.state) && Date.now() < deadline) {
|
|
147
|
+
await new Promise(resolve => setTimeout(resolve, 2_000));
|
|
148
|
+
try {
|
|
149
|
+
action = await read();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw new Error(`${String(error)}\nThe command was not resubmitted. Check it with:\n${pollCommand}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (options.json)
|
|
157
|
+
console.log(JSON.stringify({ action }));
|
|
158
|
+
else {
|
|
159
|
+
console.log(`${caseId}/${side}: ${action.state}; available until ${action.expiresAt}`);
|
|
160
|
+
if (action.output) {
|
|
161
|
+
process.stdout.write(terminalSafe(action.output.stdout));
|
|
162
|
+
process.stderr.write(terminalSafe(action.output.stderr));
|
|
163
|
+
console.error(`Exit code: ${action.output.exitCode}`);
|
|
164
|
+
}
|
|
165
|
+
if (action.error)
|
|
166
|
+
console.error(terminalSafe(action.error));
|
|
167
|
+
if (!TERMINAL.has(action.state))
|
|
168
|
+
console.log(`Follow this action:\n${pollCommand}`);
|
|
169
|
+
else if (action.state === 'complete' && !action.output) {
|
|
170
|
+
console.log(options.close ? 'Working copy discarded. The saved case is unchanged.'
|
|
171
|
+
: 'Your working copy is ready. Add --exec-json to run commands in it.');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (action.state === 'errored' || action.state === 'uncertain')
|
|
175
|
+
process.exitCode = 2;
|
|
176
|
+
else if (action.output?.exitCode)
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
else if (!TERMINAL.has(action.state) && options.wait !== false)
|
|
179
|
+
process.exitCode = 2;
|
|
180
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
declare const FLEET_PROJECTION_VERSION = "dv-cloud-verifier-fleet-runner-projection-v1";
|
|
1
2
|
export interface HostedIntent {
|
|
2
3
|
problem: string;
|
|
3
4
|
goal: string;
|
|
4
5
|
intended_outcomes: string[];
|
|
5
6
|
}
|
|
6
7
|
export interface HostedStartOptions {
|
|
8
|
+
provider?: 'haystack';
|
|
7
9
|
base: string;
|
|
8
10
|
head: string;
|
|
9
|
-
intentFile
|
|
11
|
+
intentFile?: string;
|
|
10
12
|
idempotencyKey?: string;
|
|
11
13
|
replayPlanFrom?: string;
|
|
12
14
|
account?: string;
|
|
@@ -44,6 +46,11 @@ export interface HostedRunState {
|
|
|
44
46
|
completedAt?: string;
|
|
45
47
|
output?: Record<string, unknown>;
|
|
46
48
|
result?: HostedRunnerResult;
|
|
49
|
+
provider?: 'haystack';
|
|
50
|
+
version?: typeof FLEET_PROJECTION_VERSION;
|
|
51
|
+
/** Opaque P0 documents, authoritatively validated by the hosted service. */
|
|
52
|
+
progress?: Record<string, unknown>;
|
|
53
|
+
fleetResult?: Record<string, unknown>;
|
|
47
54
|
error?: string;
|
|
48
55
|
}
|
|
49
56
|
export interface HostedPollOptions {
|
|
@@ -62,7 +69,10 @@ export declare function parseHostedRepository(value: string): {
|
|
|
62
69
|
fullName: string;
|
|
63
70
|
};
|
|
64
71
|
export declare function readHostedIntentFile(intentFile: string | undefined): HostedIntent;
|
|
65
|
-
export declare function defaultHostedIdempotencyKey(repository: string, baseCommit: string, headCommit: string, replayPlanFrom?: string
|
|
72
|
+
export declare function defaultHostedIdempotencyKey(repository: string, baseCommit: string, headCommit: string, replayPlanFrom?: string, fleetRequest?: {
|
|
73
|
+
provider: 'haystack';
|
|
74
|
+
intent?: HostedIntent;
|
|
75
|
+
}): string;
|
|
66
76
|
export declare function hostedPollingOptions(interval: string | undefined, timeout: string | undefined, onStatus?: (state: HostedRunState) => void): HostedPollOptions;
|
|
67
77
|
export declare function startHostedVerification(repositoryArg: string, options: HostedStartOptions, token: string): Promise<{
|
|
68
78
|
state: HostedRunState;
|
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { withSchema } from '../schema.js';
|
|
5
5
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
6
|
-
import { HaystackApiError, haystackJson } from '../utils/haystack-api.js';
|
|
6
|
+
import { classifyHttpError, HaystackApiError, haystackApiUrl, haystackJson } from '../utils/haystack-api.js';
|
|
7
7
|
const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
8
8
|
const COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
9
9
|
const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
|
|
@@ -17,6 +17,7 @@ const INTENT_FIELDS = [
|
|
|
17
17
|
'goal',
|
|
18
18
|
'intended_outcomes',
|
|
19
19
|
];
|
|
20
|
+
const FLEET_PROJECTION_VERSION = 'dv-cloud-verifier-fleet-runner-projection-v1';
|
|
20
21
|
function requireRecord(value, context) {
|
|
21
22
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
22
23
|
throw new Error(`${context} returned an invalid response object.`);
|
|
@@ -80,12 +81,67 @@ export function parseHostedRunState(value, expectedRunId) {
|
|
|
80
81
|
? undefined
|
|
81
82
|
: requireRecord(outputValue, 'Hosted verifier output');
|
|
82
83
|
const resultValue = response.result;
|
|
83
|
-
const
|
|
84
|
+
const fleet = response.provider === 'haystack';
|
|
85
|
+
if (response.provider !== undefined && !fleet) {
|
|
86
|
+
throw new Error('Hosted verifier returned an unsupported provider.');
|
|
87
|
+
}
|
|
88
|
+
if (fleet && response.version !== FLEET_PROJECTION_VERSION) {
|
|
89
|
+
throw new Error('Hosted verifier returned an unsupported fleet projection version.');
|
|
90
|
+
}
|
|
91
|
+
const result = fleet || resultValue === undefined
|
|
84
92
|
? undefined
|
|
85
93
|
: parseHostedRunnerResult(resultValue);
|
|
86
94
|
const createdAt = optionalTimestamp(response, 'createdAt');
|
|
87
95
|
const completedAt = optionalTimestamp(response, 'completedAt');
|
|
88
|
-
|
|
96
|
+
let progress;
|
|
97
|
+
let fleetResult;
|
|
98
|
+
if (fleet) {
|
|
99
|
+
if (response.publicRunId !== runId || !repository || !baseCommit || !headCommit || !createdAt || output) {
|
|
100
|
+
throw new Error('Hosted fleet projection is missing its immutable run identity.');
|
|
101
|
+
}
|
|
102
|
+
if (response.progress !== undefined && response.progress !== null) {
|
|
103
|
+
progress = requireRecord(response.progress, 'Hosted fleet progress');
|
|
104
|
+
if (progress.version !== 'dv-verify-progress-v1' || progress.publicRunId !== runId) {
|
|
105
|
+
throw new Error('Hosted fleet progress does not match the requested run.');
|
|
106
|
+
}
|
|
107
|
+
requireString(progress, 'stage');
|
|
108
|
+
requireString(progress, 'status');
|
|
109
|
+
}
|
|
110
|
+
if (resultValue !== undefined && resultValue !== null) {
|
|
111
|
+
fleetResult = requireRecord(resultValue, 'Hosted fleet result');
|
|
112
|
+
const mapping = requireRecord(fleetResult.mapping, 'Hosted fleet result mapping');
|
|
113
|
+
const provenance = requireRecord(fleetResult.provenance, 'Hosted fleet provenance');
|
|
114
|
+
if (fleetResult.version !== 'dv-verify-result-v1' || fleetResult.provider !== 'haystack'
|
|
115
|
+
|| mapping.publicRunId !== runId || provenance.baseSha !== baseCommit || provenance.headSha !== headCommit) {
|
|
116
|
+
throw new Error('Hosted fleet result does not match the requested run and commits.');
|
|
117
|
+
}
|
|
118
|
+
// CONTRACT-GAP: P0 currently couples its canonical parsers to runner-only
|
|
119
|
+
// modules outside this published CLI. The server validates the complete
|
|
120
|
+
// P0 result; the CLI only checks the fields it presents, retaining the
|
|
121
|
+
// original document instead of defining a second P0 wire contract.
|
|
122
|
+
requireString(fleetResult, 'lifecycle', /^(complete|failed|cancelled|budget-stopped|account-action)$/);
|
|
123
|
+
requireString(fleetResult, 'runHealth', /^(healthy|degraded|infrastructure-error|not-executed)$/);
|
|
124
|
+
const cleanup = requireRecord(fleetResult.cleanup, 'Hosted fleet cleanup');
|
|
125
|
+
requireString(cleanup, 'status', /^(complete|pending)$/);
|
|
126
|
+
if (!Array.isArray(cleanup.pending) || !Array.isArray(fleetResult.findings)) {
|
|
127
|
+
throw new Error('Hosted fleet result has invalid findings or cleanup.');
|
|
128
|
+
}
|
|
129
|
+
for (const value of fleetResult.findings) {
|
|
130
|
+
const finding = requireRecord(value, 'Hosted fleet finding');
|
|
131
|
+
requireString(finding, 'findingId');
|
|
132
|
+
requireString(finding, 'caseId');
|
|
133
|
+
requireString(finding, 'judgment');
|
|
134
|
+
}
|
|
135
|
+
requireString(provenance, 'intent', /^(user|inferred)$/);
|
|
136
|
+
if (status === 'complete' && fleetResult.lifecycle !== 'complete') {
|
|
137
|
+
throw new Error('Hosted fleet lifecycle disagrees with its completed job.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (status === 'complete' && !fleetResult) {
|
|
141
|
+
throw new Error('Hosted fleet run completed without a result receipt.');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else if (status === 'complete') {
|
|
89
145
|
// Two terminal shapes exist: the Workflow receipt envelope (`output`)
|
|
90
146
|
// and the runner-queue result envelope (`result`). Exactly one must
|
|
91
147
|
// validate.
|
|
@@ -119,6 +175,7 @@ export function parseHostedRunState(value, expectedRunId) {
|
|
|
119
175
|
completedAt,
|
|
120
176
|
output,
|
|
121
177
|
result,
|
|
178
|
+
...(fleet ? { provider: 'haystack', version: FLEET_PROJECTION_VERSION, progress, fleetResult } : {}),
|
|
122
179
|
error: optionalString(response, 'error'),
|
|
123
180
|
};
|
|
124
181
|
}
|
|
@@ -165,6 +222,12 @@ function requireCommit(value, flag) {
|
|
|
165
222
|
}
|
|
166
223
|
return value;
|
|
167
224
|
}
|
|
225
|
+
function requireRevision(value, flag) {
|
|
226
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 1_024 || /[\u0000-\u0020\u007f]/.test(value)) {
|
|
227
|
+
throw new Error(`${flag} must be a non-empty Git revision of at most 1024 characters without whitespace or control characters.`);
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
168
231
|
export function readHostedIntentFile(intentFile) {
|
|
169
232
|
if (typeof intentFile !== 'string' || intentFile.length === 0) {
|
|
170
233
|
throw new Error('--intent-file is required and must name a JSON file containing exactly problem, goal, and intended_outcomes.');
|
|
@@ -213,11 +276,13 @@ export function readHostedIntentFile(intentFile) {
|
|
|
213
276
|
intended_outcomes: record.intended_outcomes,
|
|
214
277
|
};
|
|
215
278
|
}
|
|
216
|
-
export function defaultHostedIdempotencyKey(repository, baseCommit, headCommit, replayPlanFrom) {
|
|
279
|
+
export function defaultHostedIdempotencyKey(repository, baseCommit, headCommit, replayPlanFrom, fleetRequest) {
|
|
217
280
|
const digest = createHash('sha256')
|
|
218
|
-
.update(JSON.stringify(
|
|
219
|
-
? [repository, baseCommit, headCommit,
|
|
220
|
-
:
|
|
281
|
+
.update(JSON.stringify(fleetRequest
|
|
282
|
+
? [fleetRequest.provider, repository, baseCommit, headCommit, fleetRequest.intent ?? null]
|
|
283
|
+
: replayPlanFrom
|
|
284
|
+
? [repository, baseCommit, headCommit, replayPlanFrom]
|
|
285
|
+
: [repository, baseCommit, headCommit]))
|
|
221
286
|
.digest('hex');
|
|
222
287
|
return `cli:${digest}`;
|
|
223
288
|
}
|
|
@@ -244,42 +309,77 @@ export function hostedPollingOptions(interval, timeout, onStatus) {
|
|
|
244
309
|
};
|
|
245
310
|
}
|
|
246
311
|
export async function startHostedVerification(repositoryArg, options, token) {
|
|
312
|
+
const { repository, launchBody, idempotencyKey } = prepareHostedLaunch(repositoryArg, options);
|
|
313
|
+
const path = '/api/agent/cloud-verifier/runs';
|
|
314
|
+
const response = await fetch(haystackApiUrl(path), {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: {
|
|
317
|
+
Authorization: `Bearer ${token}`,
|
|
318
|
+
Accept: 'application/json',
|
|
319
|
+
'User-Agent': 'Haystack-CLI',
|
|
320
|
+
'Content-Type': 'application/json',
|
|
321
|
+
},
|
|
322
|
+
body: JSON.stringify(launchBody),
|
|
323
|
+
});
|
|
324
|
+
if (!response.ok) {
|
|
325
|
+
// Keep the shared HTTP classification and raw-body fallback while exposing
|
|
326
|
+
// the launch service's additional explanation for a retryable failure.
|
|
327
|
+
const body = response.status === 503
|
|
328
|
+
? await response.clone().json().catch(() => null)
|
|
329
|
+
: null;
|
|
330
|
+
const error = await classifyHttpError(response, `Haystack API ${path}`);
|
|
331
|
+
if (body && typeof body === 'object' && 'detail' in body
|
|
332
|
+
&& typeof body.detail === 'string' && body.detail.trim()) {
|
|
333
|
+
error.message += ` Details: ${body.detail}`;
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
const state = parseHostedRunState(await response.json());
|
|
338
|
+
if (options.provider === 'haystack' && state.provider !== 'haystack') {
|
|
339
|
+
throw new Error('Hosted verifier did not acknowledge the fleet-pinned request.');
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
state: {
|
|
343
|
+
...state,
|
|
344
|
+
repository: state.repository ?? repository.fullName,
|
|
345
|
+
baseCommit: state.baseCommit ?? launchBody.baseCommit,
|
|
346
|
+
headCommit: state.headCommit ?? launchBody.headCommit,
|
|
347
|
+
},
|
|
348
|
+
idempotencyKey,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function prepareHostedLaunch(repositoryArg, options) {
|
|
247
352
|
const repository = parseHostedRepository(repositoryArg);
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
const
|
|
353
|
+
const fleet = options.provider === 'haystack';
|
|
354
|
+
const validateRevision = fleet ? requireRevision : requireCommit;
|
|
355
|
+
const baseCommit = validateRevision(options.base, '--base');
|
|
356
|
+
const headCommit = validateRevision(options.head, '--head');
|
|
357
|
+
const intent = fleet && options.intentFile === undefined ? undefined : readHostedIntentFile(options.intentFile);
|
|
358
|
+
if (fleet && intent && [intent.problem, intent.goal, ...intent.intended_outcomes].some(value => /[\u0000-\u001f\u007f]/.test(value))) {
|
|
359
|
+
throw new Error('Fleet --intent-file fields must be single-line text without control characters.');
|
|
360
|
+
}
|
|
251
361
|
const replayPlanFrom = options.replayPlanFrom;
|
|
362
|
+
if (fleet && replayPlanFrom !== undefined) {
|
|
363
|
+
throw new Error('--replay-plan-from is available through the explicit hosted start alias.');
|
|
364
|
+
}
|
|
252
365
|
if (replayPlanFrom !== undefined && !RUN_ID.test(replayPlanFrom)) {
|
|
253
366
|
throw new Error('--replay-plan-from must match cv_ followed by 48 lowercase hex characters.');
|
|
254
367
|
}
|
|
255
368
|
const idempotencyKey = requireIdempotencyKey(options.idempotencyKey
|
|
256
|
-
?? defaultHostedIdempotencyKey(repository.fullName, baseCommit, headCommit, replayPlanFrom));
|
|
369
|
+
?? defaultHostedIdempotencyKey(repository.fullName, baseCommit, headCommit, replayPlanFrom, fleet ? { provider: 'haystack', intent } : undefined));
|
|
257
370
|
const launchBody = {
|
|
371
|
+
...(fleet ? { provider: 'haystack' } : {}),
|
|
258
372
|
owner: repository.owner,
|
|
259
373
|
repository: repository.repository,
|
|
260
374
|
baseCommit,
|
|
261
375
|
headCommit,
|
|
262
376
|
idempotencyKey,
|
|
263
|
-
intent,
|
|
377
|
+
...(intent ? { intent } : {}),
|
|
264
378
|
...(replayPlanFrom
|
|
265
379
|
? { replayPlanFromRunId: replayPlanFrom }
|
|
266
380
|
: {}),
|
|
267
381
|
};
|
|
268
|
-
|
|
269
|
-
method: 'POST',
|
|
270
|
-
headers: { 'Content-Type': 'application/json' },
|
|
271
|
-
body: JSON.stringify(launchBody),
|
|
272
|
-
});
|
|
273
|
-
const state = parseHostedRunState(response);
|
|
274
|
-
return {
|
|
275
|
-
state: {
|
|
276
|
-
...state,
|
|
277
|
-
repository: state.repository ?? repository.fullName,
|
|
278
|
-
baseCommit: state.baseCommit ?? baseCommit,
|
|
279
|
-
headCommit: state.headCommit ?? headCommit,
|
|
280
|
-
},
|
|
281
|
-
idempotencyKey,
|
|
282
|
-
};
|
|
382
|
+
return { repository, launchBody, idempotencyKey };
|
|
283
383
|
}
|
|
284
384
|
export async function getHostedVerification(runId, token) {
|
|
285
385
|
if (!RUN_ID.test(runId)) {
|
|
@@ -303,12 +403,13 @@ function delay(milliseconds) {
|
|
|
303
403
|
}
|
|
304
404
|
export async function waitForHostedVerification(runId, token, options) {
|
|
305
405
|
const startedAt = Date.now();
|
|
306
|
-
let
|
|
406
|
+
let lastProgress;
|
|
307
407
|
for (;;) {
|
|
308
408
|
const state = await getHostedVerification(runId, token);
|
|
309
|
-
|
|
409
|
+
const progressKey = JSON.stringify([state.status, state.progress?.sequence, state.progress?.stage, state.progress?.status]);
|
|
410
|
+
if (progressKey !== lastProgress) {
|
|
310
411
|
options.onStatus?.(state);
|
|
311
|
-
|
|
412
|
+
lastProgress = progressKey;
|
|
312
413
|
}
|
|
313
414
|
if (TERMINAL_STATUSES.has(state.status)) {
|
|
314
415
|
return { state, timedOut: false };
|
|
@@ -338,6 +439,9 @@ export function buildHostedJson(operation, state, extras = {}) {
|
|
|
338
439
|
...(extras.timedOut !== undefined ? { timed_out: extras.timedOut } : {}),
|
|
339
440
|
...(state.output ? { output: state.output } : {}),
|
|
340
441
|
...(state.result ? { result: state.result } : {}),
|
|
442
|
+
...(state.provider ? { provider: state.provider, version: state.version } : {}),
|
|
443
|
+
...(state.progress ? { progress: state.progress } : {}),
|
|
444
|
+
...(state.fleetResult ? { result: state.fleetResult } : {}),
|
|
341
445
|
...(state.error ? { error: state.error } : {}),
|
|
342
446
|
});
|
|
343
447
|
}
|
|
@@ -357,11 +461,13 @@ function printHostedState(operation, state, options) {
|
|
|
357
461
|
}), null, 2)}\n`);
|
|
358
462
|
return;
|
|
359
463
|
}
|
|
360
|
-
const icon = state.status === 'complete'
|
|
361
|
-
? chalk.
|
|
362
|
-
:
|
|
363
|
-
? chalk.
|
|
364
|
-
:
|
|
464
|
+
const icon = state.provider === 'haystack' && state.status === 'complete'
|
|
465
|
+
? chalk.cyan('•')
|
|
466
|
+
: state.status === 'complete'
|
|
467
|
+
? chalk.green('✓')
|
|
468
|
+
: TERMINAL_STATUSES.has(state.status)
|
|
469
|
+
? chalk.red('✗')
|
|
470
|
+
: chalk.cyan('•');
|
|
365
471
|
console.log(`${icon} Hosted verification ${chalk.bold(state.runId)}: ${state.status}`);
|
|
366
472
|
if (state.repository) {
|
|
367
473
|
console.log(` ${chalk.dim('Repository:')} ${state.repository}`);
|
|
@@ -378,6 +484,22 @@ function printHostedState(operation, state, options) {
|
|
|
378
484
|
}
|
|
379
485
|
console.log(` ${chalk.dim('Artifacts:')} ${state.result.outputLocation}`);
|
|
380
486
|
}
|
|
487
|
+
if (state.progress) {
|
|
488
|
+
console.log(` ${chalk.dim('Progress:')} ${state.progress.stage} (${state.progress.status})`);
|
|
489
|
+
}
|
|
490
|
+
if (state.fleetResult) {
|
|
491
|
+
const result = state.fleetResult;
|
|
492
|
+
const cleanup = requireRecord(result.cleanup, 'Hosted fleet cleanup');
|
|
493
|
+
const provenance = requireRecord(result.provenance, 'Hosted fleet provenance');
|
|
494
|
+
console.log(` ${chalk.dim('Lifecycle:')} ${result.lifecycle}`);
|
|
495
|
+
console.log(` ${chalk.dim('Run health:')} ${result.runHealth}`);
|
|
496
|
+
console.log(` ${chalk.dim('Intent:')} ${provenance.intent}`);
|
|
497
|
+
console.log(` ${chalk.dim('Cleanup:')} ${cleanup.status} (${cleanup.pending.length} pending)`);
|
|
498
|
+
for (const value of result.findings) {
|
|
499
|
+
const finding = requireRecord(value, 'Hosted fleet finding');
|
|
500
|
+
console.log(` ${chalk.dim('Finding:')} ${finding.findingId} / ${finding.caseId}: ${finding.judgment}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
381
503
|
if (state.error)
|
|
382
504
|
console.log(` ${chalk.dim('Error:')} ${state.error}`);
|
|
383
505
|
if (options.timedOut) {
|
|
@@ -387,23 +509,38 @@ function printHostedState(operation, state, options) {
|
|
|
387
509
|
console.log(` ${chalk.dim('Resume:')} ${nextCommand}`);
|
|
388
510
|
}
|
|
389
511
|
function markNonSuccess(state, timedOut = false) {
|
|
512
|
+
if (state.fleetResult) {
|
|
513
|
+
const result = state.fleetResult;
|
|
514
|
+
const cleanup = requireRecord(result.cleanup, 'Hosted fleet cleanup');
|
|
515
|
+
if (result.lifecycle !== 'complete' || result.runHealth !== 'healthy' || cleanup.status !== 'complete') {
|
|
516
|
+
process.exitCode = 2;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (result.findings.some(value => requireRecord(value, 'Hosted fleet finding').judgment === 'regression')) {
|
|
520
|
+
process.exitCode = 1;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
390
523
|
if (timedOut
|
|
391
524
|
|| (state.status !== 'complete' && TERMINAL_STATUSES.has(state.status))) {
|
|
392
525
|
process.exitCode = 2;
|
|
393
526
|
}
|
|
394
527
|
}
|
|
395
528
|
export async function verifyHostedStartCommand(repositoryArg, options) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
529
|
+
// Validate all local input before resolving accounts or submitting a job.
|
|
530
|
+
const { repository } = prepareHostedLaunch(repositoryArg, options);
|
|
531
|
+
const polling = hostedPollingOptions(options.interval, options.timeout, current => {
|
|
532
|
+
if (!options.json)
|
|
533
|
+
console.error(chalk.dim(hostedProgressMessage(current)));
|
|
534
|
+
});
|
|
400
535
|
const auth = await resolveAuthContext({
|
|
401
536
|
preferredLogin: options.account,
|
|
402
537
|
owner: repository.owner,
|
|
403
538
|
repo: repository.repository,
|
|
404
539
|
});
|
|
405
540
|
if (!options.json) {
|
|
406
|
-
console.error(chalk.dim(
|
|
541
|
+
console.error(chalk.dim(options.provider === 'haystack'
|
|
542
|
+
? 'Submitting repository revisions to the hosted fleet verifier…'
|
|
543
|
+
: 'Submitting the exact commit pair to the hosted verifier…'));
|
|
407
544
|
}
|
|
408
545
|
const started = await startHostedVerification(repositoryArg, options, auth.token);
|
|
409
546
|
const selectedAccount = auth.token.startsWith('hsk_live_')
|
|
@@ -412,11 +549,7 @@ export async function verifyHostedStartCommand(repositoryArg, options) {
|
|
|
412
549
|
let state = started.state;
|
|
413
550
|
let timedOut;
|
|
414
551
|
if (options.wait !== false && !TERMINAL_STATUSES.has(state.status)) {
|
|
415
|
-
const waited = await waitForHostedVerification(state.runId, auth.token,
|
|
416
|
-
if (!options.json) {
|
|
417
|
-
console.error(chalk.dim(`Hosted verifier: ${current.status}…`));
|
|
418
|
-
}
|
|
419
|
-
}));
|
|
552
|
+
const waited = await waitForHostedVerification(state.runId, auth.token, polling);
|
|
420
553
|
state = waited.state;
|
|
421
554
|
timedOut = waited.timedOut;
|
|
422
555
|
}
|
|
@@ -431,7 +564,14 @@ export async function verifyHostedStartCommand(repositoryArg, options) {
|
|
|
431
564
|
});
|
|
432
565
|
markNonSuccess(state, timedOut);
|
|
433
566
|
}
|
|
567
|
+
function hostedProgressMessage(state) {
|
|
568
|
+
return `Hosted verifier: ${state.status}${state.progress ? ` · ${state.progress.stage} (${state.progress.status})` : ''}…`;
|
|
569
|
+
}
|
|
434
570
|
export async function verifyHostedStatusCommand(runId, options) {
|
|
571
|
+
const polling = hostedPollingOptions(options.interval, options.timeout, current => {
|
|
572
|
+
if (!options.json)
|
|
573
|
+
console.error(chalk.dim(hostedProgressMessage(current)));
|
|
574
|
+
});
|
|
435
575
|
const auth = await resolveAuthContext({ preferredLogin: options.account });
|
|
436
576
|
const selectedAccount = auth.token.startsWith('hsk_live_')
|
|
437
577
|
? undefined
|
|
@@ -439,11 +579,7 @@ export async function verifyHostedStatusCommand(runId, options) {
|
|
|
439
579
|
let state = await getHostedVerification(runId, auth.token);
|
|
440
580
|
let timedOut;
|
|
441
581
|
if (options.wait && !TERMINAL_STATUSES.has(state.status)) {
|
|
442
|
-
const waited = await waitForHostedVerification(runId, auth.token,
|
|
443
|
-
if (!options.json) {
|
|
444
|
-
console.error(chalk.dim(`Hosted verifier: ${current.status}…`));
|
|
445
|
-
}
|
|
446
|
-
}));
|
|
582
|
+
const waited = await waitForHostedVerification(runId, auth.token, polling);
|
|
447
583
|
state = waited.state;
|
|
448
584
|
timedOut = waited.timedOut;
|
|
449
585
|
}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
import type { DeliverPrecomputeRequest } from './precompute-delivery-contract.js';
|
|
2
|
+
export type RepoWalkHookStatus = {
|
|
3
|
+
status: 'queued' | 'existing';
|
|
4
|
+
runId: string;
|
|
5
|
+
} | {
|
|
6
|
+
status: 'unavailable';
|
|
7
|
+
reason: 'runner-unavailable' | 'enqueue-failed';
|
|
8
|
+
};
|
|
9
|
+
export type PrecomputeOutcome = 'pending' | 'enqueued' | 'cached' | 'existing' | 'skipped_no_auth' | 'error';
|
|
2
10
|
export interface VerifyPrecomputeOptions {
|
|
3
11
|
hook?: boolean;
|
|
4
12
|
}
|
|
@@ -7,6 +15,7 @@ export interface FrontHalfEnqueueRequest {
|
|
|
7
15
|
repository: string;
|
|
8
16
|
baseCommit: string;
|
|
9
17
|
workCommit: string;
|
|
18
|
+
observedAt: number;
|
|
10
19
|
cacheKey: string;
|
|
11
20
|
patchGzBase64: string;
|
|
12
21
|
patchSha256: string;
|
|
@@ -16,4 +25,6 @@ export interface PrecomputeDerivation {
|
|
|
16
25
|
request: FrontHalfEnqueueRequest;
|
|
17
26
|
}
|
|
18
27
|
export declare function derivePrecomputeRequest(gitRoot: string, owner: string, repository: string, deadline?: number): Promise<PrecomputeDerivation>;
|
|
28
|
+
/** One authenticated submission of the already captured request. No source reads or retries. */
|
|
29
|
+
export declare const deliverPrecomputeRequest: DeliverPrecomputeRequest;
|
|
19
30
|
export declare function verifyPrecomputeCommand(options: VerifyPrecomputeOptions): Promise<void>;
|