@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.
@@ -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: string;
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): 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 result = resultValue === undefined
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
- if (status === 'complete') {
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(replayPlanFrom
219
- ? [repository, baseCommit, headCommit, replayPlanFrom]
220
- : [repository, baseCommit, headCommit]))
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 baseCommit = requireCommit(options.base, '--base');
249
- const headCommit = requireCommit(options.head, '--head');
250
- const intent = readHostedIntentFile(options.intentFile);
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
- const response = await haystackJson('/api/agent/cloud-verifier/runs', token, {
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 lastStatus;
406
+ let lastProgress;
307
407
  for (;;) {
308
408
  const state = await getHostedVerification(runId, token);
309
- if (state.status !== lastStatus) {
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
- lastStatus = state.status;
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.green('✓')
362
- : TERMINAL_STATUSES.has(state.status)
363
- ? chalk.red('✗')
364
- : chalk.cyan('•');
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
- 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);
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('Submitting the exact commit pair to the hosted verifier…'));
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, hostedPollingOptions(options.interval, options.timeout, current => {
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, hostedPollingOptions(options.interval, options.timeout, current => {
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
- export type PrecomputeOutcome = 'enqueued' | 'cached' | 'existing' | 'skipped_no_auth' | 'error';
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>;