@haystackeditor/cli 0.15.21 → 0.15.22
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 +28 -2
- package/dist/commands/verify-core.d.ts +26 -1
- package/dist/commands/verify-core.js +59 -23
- package/dist/commands/verify-hosted.d.ts +60 -0
- package/dist/commands/verify-hosted.js +342 -0
- package/dist/commands/verify-mcp.js +59 -0
- package/dist/commands/verify-ops.js +1 -1
- package/dist/commands/verify-reproducibility.d.ts +85 -0
- package/dist/commands/verify-reproducibility.js +494 -0
- package/dist/commands/verify.d.ts +11 -0
- package/dist/commands/verify.js +55 -22
- package/dist/index.js +71 -0
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +1 -0
- package/dist/utils/haystack-api.d.ts +1 -1
- package/dist/utils/haystack-api.js +2 -2
- package/package.json +1 -1
- package/schemas/cloud-verifier.v1.json +96 -0
package/README.md
CHANGED
|
@@ -33,8 +33,8 @@ Most consumers of this CLI are coding agents. These invariants hold everywhere:
|
|
|
33
33
|
- **Every JSON payload is versioned and schema'd.** Payloads carry
|
|
34
34
|
`schema_version`; print the contract with `haystack schema <name>`
|
|
35
35
|
(`haystack schema` lists all: `triage`, `pr`, `pr-status`, `inbox`, `ask`,
|
|
36
|
-
`traces`, `submit`, `action`, `error`, `setup`). Schemas
|
|
37
|
-
2020-12 and CI-guarded against drift.
|
|
36
|
+
`traces`, `submit`, `action`, `cloud-verifier`, `error`, `setup`). Schemas
|
|
37
|
+
are JSON-Schema 2020-12 and CI-guarded against drift.
|
|
38
38
|
- **One state vocabulary.** All state tokens are snake_case across every
|
|
39
39
|
command: verdicts are `good_to_merge` / `needs_review` / `needs_input`;
|
|
40
40
|
feed buckets are `analyzing`, `good_to_merge`, `issues`, `needs_assignment`,
|
|
@@ -79,6 +79,32 @@ haystack verify refork latest 3 attempt:fix-2
|
|
|
79
79
|
haystack verify reopen archil:<disk-id>:branch:<returned-child>
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
**`haystack verify hosted`** is the authenticated production entry point for
|
|
83
|
+
exact GitHub commit pairs. It uses the repository-default saved account,
|
|
84
|
+
submits an idempotent run, and waits up to 35 minutes by default:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
haystack verify hosted start owner/repo \
|
|
88
|
+
--base <40-character-commit-sha> \
|
|
89
|
+
--head <40-character-commit-sha> \
|
|
90
|
+
--json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Use `--no-wait` to return as soon as the workflow is queued. The result includes
|
|
94
|
+
an account-bound `next_command`; use it verbatim on machines with multiple
|
|
95
|
+
saved GitHub accounts. Repeating the same repository/base/head tuple reuses the
|
|
96
|
+
same production run by default. Pass a new explicit `--idempotency-key` only
|
|
97
|
+
when a fresh execution is intentional. A bounded wait that expires still emits
|
|
98
|
+
the `cloud-verifier` schema with `timed_out: true`, the last observed status,
|
|
99
|
+
and `next_command`, then exits 2. Read or resume a known run with:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
haystack verify hosted status cv_<48-lowercase-hex-characters> \
|
|
103
|
+
--account <login> \
|
|
104
|
+
--wait \
|
|
105
|
+
--json
|
|
106
|
+
```
|
|
107
|
+
|
|
82
108
|
**`haystack setup --json`** speaks NDJSON: events (`question`, `permission`,
|
|
83
109
|
`progress`, `result`) on stdout, replies on stdin keyed by
|
|
84
110
|
`requestID`/`permissionID`. `haystack schema setup` documents the full
|
|
@@ -41,15 +41,31 @@ export interface UniverseRef {
|
|
|
41
41
|
}
|
|
42
42
|
export interface RiskCell {
|
|
43
43
|
riskCellId: string;
|
|
44
|
+
riskSourceId?: string;
|
|
44
45
|
ordinal: number;
|
|
45
46
|
title: string;
|
|
46
47
|
rationale?: string;
|
|
47
48
|
observableConsequence?: string;
|
|
48
49
|
activationConditions?: string[];
|
|
49
50
|
causalPath?: string[];
|
|
51
|
+
capabilityId?: string;
|
|
50
52
|
status: string;
|
|
51
53
|
severity?: string;
|
|
52
54
|
confidence?: number;
|
|
55
|
+
sourceEvidence?: Array<{
|
|
56
|
+
evidenceId: string;
|
|
57
|
+
file: string;
|
|
58
|
+
line?: number;
|
|
59
|
+
excerpt?: string;
|
|
60
|
+
relationship: string;
|
|
61
|
+
}>;
|
|
62
|
+
oracles?: Array<{
|
|
63
|
+
oracleId: string;
|
|
64
|
+
kind: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
measurement: string;
|
|
67
|
+
threshold?: string;
|
|
68
|
+
}>;
|
|
53
69
|
durationMs?: number;
|
|
54
70
|
base: UniverseRef;
|
|
55
71
|
head: UniverseRef;
|
|
@@ -114,6 +130,7 @@ export interface VerificationRun {
|
|
|
114
130
|
command: string;
|
|
115
131
|
createdAt: string;
|
|
116
132
|
updatedAt: string;
|
|
133
|
+
planSourceRunId?: string;
|
|
117
134
|
chapters: VerificationChapter[];
|
|
118
135
|
events: VerificationRunEvent[];
|
|
119
136
|
summary: {
|
|
@@ -139,9 +156,15 @@ export declare class VerifyUnauthorizedError extends Error {
|
|
|
139
156
|
export declare function discoverVerifyRoot(explicit?: string): string | null;
|
|
140
157
|
export declare function runsRoot(repoRoot: string): string;
|
|
141
158
|
export declare function runDir(repoRoot: string, runId: string): string;
|
|
159
|
+
/**
|
|
160
|
+
* Read when a run began from the record itself. updatedAt and mtime both move
|
|
161
|
+
* when cleanup or a migration rewrites old runs and therefore cannot define
|
|
162
|
+
* "latest".
|
|
163
|
+
*/
|
|
164
|
+
export declare function runStartedAtMs(runJsonPath: string): number | null;
|
|
142
165
|
export declare function listRunDirs(repoRoot: string): Array<{
|
|
143
166
|
runId: string;
|
|
144
|
-
|
|
167
|
+
startedAt: number;
|
|
145
168
|
}>;
|
|
146
169
|
/** Same alias rules as the control plane: run id verbatim, `latest`, or a chapter id. */
|
|
147
170
|
export declare function resolveRunIdOnDisk(repoRoot: string, requested: string): string;
|
|
@@ -179,6 +202,7 @@ export interface RunListRow {
|
|
|
179
202
|
chapters: string[];
|
|
180
203
|
status: string;
|
|
181
204
|
age: string | null;
|
|
205
|
+
created_at?: string;
|
|
182
206
|
updated_at: string;
|
|
183
207
|
tests: number;
|
|
184
208
|
passed: number;
|
|
@@ -400,6 +424,7 @@ export interface WatchCallbacks {
|
|
|
400
424
|
export interface WatchResult {
|
|
401
425
|
loaded: LoadedRun;
|
|
402
426
|
timedOut: boolean;
|
|
427
|
+
endReason: 'terminal' | 'stale' | 'timeout';
|
|
403
428
|
}
|
|
404
429
|
/**
|
|
405
430
|
* Poll until the run reaches a terminal state. The terminal predicate is the
|
|
@@ -72,6 +72,36 @@ export function runsRoot(repoRoot) {
|
|
|
72
72
|
export function runDir(repoRoot, runId) {
|
|
73
73
|
return path.join(runsRoot(repoRoot), runId);
|
|
74
74
|
}
|
|
75
|
+
function isMissingFileError(error) {
|
|
76
|
+
return typeof error === 'object'
|
|
77
|
+
&& error !== null
|
|
78
|
+
&& 'code' in error
|
|
79
|
+
&& error.code === 'ENOENT';
|
|
80
|
+
}
|
|
81
|
+
function runRecordReadError(runJsonPath, error) {
|
|
82
|
+
return new Error(`Could not read verifier run record ${runJsonPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read when a run began from the record itself. updatedAt and mtime both move
|
|
86
|
+
* when cleanup or a migration rewrites old runs and therefore cannot define
|
|
87
|
+
* "latest".
|
|
88
|
+
*/
|
|
89
|
+
export function runStartedAtMs(runJsonPath) {
|
|
90
|
+
try {
|
|
91
|
+
const run = JSON.parse(fs.readFileSync(runJsonPath, 'utf8'));
|
|
92
|
+
if (typeof run.createdAt === 'string') {
|
|
93
|
+
const recorded = Date.parse(run.createdAt);
|
|
94
|
+
if (Number.isFinite(recorded))
|
|
95
|
+
return recorded;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (isMissingFileError(error))
|
|
100
|
+
return null;
|
|
101
|
+
throw runRecordReadError(runJsonPath, error);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
75
105
|
export function listRunDirs(repoRoot) {
|
|
76
106
|
let entries;
|
|
77
107
|
try {
|
|
@@ -82,16 +112,12 @@ export function listRunDirs(repoRoot) {
|
|
|
82
112
|
}
|
|
83
113
|
return entries
|
|
84
114
|
.filter(name => name.startsWith('run-'))
|
|
85
|
-
.map(name => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
.filter(entry => entry.changedAt > 0)
|
|
94
|
-
.sort((a, b) => b.changedAt - a.changedAt);
|
|
115
|
+
.map(name => ({
|
|
116
|
+
runId: name,
|
|
117
|
+
startedAt: runStartedAtMs(path.join(runsRoot(repoRoot), name, 'run.json')),
|
|
118
|
+
}))
|
|
119
|
+
.filter((entry) => (entry.startedAt !== null))
|
|
120
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
95
121
|
}
|
|
96
122
|
/** Same alias rules as the control plane: run id verbatim, `latest`, or a chapter id. */
|
|
97
123
|
export function resolveRunIdOnDisk(repoRoot, requested) {
|
|
@@ -102,11 +128,14 @@ export function resolveRunIdOnDisk(repoRoot, requested) {
|
|
|
102
128
|
return match?.runId ?? requested;
|
|
103
129
|
}
|
|
104
130
|
export function loadRunFromDisk(repoRoot, runId) {
|
|
131
|
+
const runJsonPath = path.join(runDir(repoRoot, runId), 'run.json');
|
|
105
132
|
try {
|
|
106
|
-
return JSON.parse(fs.readFileSync(
|
|
133
|
+
return JSON.parse(fs.readFileSync(runJsonPath, 'utf8'));
|
|
107
134
|
}
|
|
108
|
-
catch {
|
|
109
|
-
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (isMissingFileError(error))
|
|
137
|
+
return null;
|
|
138
|
+
throw runRecordReadError(runJsonPath, error);
|
|
110
139
|
}
|
|
111
140
|
}
|
|
112
141
|
export async function fetchRunFromServer(server, idOrAlias) {
|
|
@@ -158,12 +187,13 @@ function rowOfRun(run) {
|
|
|
158
187
|
run_id: run.runId,
|
|
159
188
|
chapters: run.chapters.map(chapter => chapter.chapterId),
|
|
160
189
|
status: run.status,
|
|
161
|
-
age: formatAge(run.
|
|
190
|
+
age: formatAge(run.createdAt),
|
|
191
|
+
created_at: run.createdAt,
|
|
162
192
|
updated_at: run.updatedAt,
|
|
163
193
|
tests: run.summary.total,
|
|
164
194
|
passed: run.summary.passed,
|
|
165
195
|
failed: run.summary.failed,
|
|
166
|
-
could_not_run: run.summary.infraErrors,
|
|
196
|
+
could_not_run: run.summary.infraErrors + (run.summary.inconclusive ?? 0),
|
|
167
197
|
culprit_sha: bisect?.culpritSha ?? null,
|
|
168
198
|
};
|
|
169
199
|
}
|
|
@@ -184,7 +214,10 @@ export async function listRunRows(options) {
|
|
|
184
214
|
const body = (await response.json());
|
|
185
215
|
if (Array.isArray(body.runs)) {
|
|
186
216
|
// Recompute ages locally so they do not drift with server response caching.
|
|
187
|
-
const rows = body.runs.map(row => ({
|
|
217
|
+
const rows = body.runs.map(row => ({
|
|
218
|
+
...row,
|
|
219
|
+
age: formatAge(row.created_at || row.updated_at) ?? row.age,
|
|
220
|
+
}));
|
|
188
221
|
return { rows, source: 'server', total: body.total ?? rows.length };
|
|
189
222
|
}
|
|
190
223
|
}
|
|
@@ -504,7 +537,7 @@ export function summarizeRun(loaded, server) {
|
|
|
504
537
|
source: loaded.source,
|
|
505
538
|
created_at: run.createdAt,
|
|
506
539
|
updated_at: run.updatedAt,
|
|
507
|
-
age: formatAge(run.
|
|
540
|
+
age: formatAge(run.createdAt),
|
|
508
541
|
counts: {
|
|
509
542
|
total: run.summary.total,
|
|
510
543
|
completed: run.summary.completed,
|
|
@@ -729,8 +762,9 @@ export async function waitForTerminal(options) {
|
|
|
729
762
|
}
|
|
730
763
|
first = false;
|
|
731
764
|
const staleMarked = loaded.stale || loaded.run.events.some(event => event.eventId.startsWith('evt-stale-'));
|
|
732
|
-
if (isTerminal(loaded.run) && !staleMarked)
|
|
733
|
-
return { loaded, timedOut: false };
|
|
765
|
+
if (isTerminal(loaded.run) && !staleMarked) {
|
|
766
|
+
return { loaded, timedOut: false, endReason: 'terminal' };
|
|
767
|
+
}
|
|
734
768
|
const progressed = loaded.run.updatedAt !== lastUpdatedAt;
|
|
735
769
|
lastUpdatedAt = loaded.run.updatedAt;
|
|
736
770
|
if (staleMarked) {
|
|
@@ -739,15 +773,17 @@ export async function waitForTerminal(options) {
|
|
|
739
773
|
}
|
|
740
774
|
else {
|
|
741
775
|
noProgressSince = noProgressSince ?? Date.now();
|
|
742
|
-
if (Date.now() - noProgressSince > staleGraceMs)
|
|
743
|
-
return { loaded, timedOut: false };
|
|
776
|
+
if (Date.now() - noProgressSince > staleGraceMs) {
|
|
777
|
+
return { loaded, timedOut: false, endReason: 'stale' };
|
|
778
|
+
}
|
|
744
779
|
}
|
|
745
780
|
}
|
|
746
781
|
else {
|
|
747
782
|
noProgressSince = null;
|
|
748
783
|
}
|
|
749
|
-
if (deadline && Date.now() > deadline)
|
|
750
|
-
return { loaded, timedOut: true };
|
|
784
|
+
if (deadline && Date.now() > deadline) {
|
|
785
|
+
return { loaded, timedOut: true, endReason: 'timeout' };
|
|
786
|
+
}
|
|
751
787
|
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
752
788
|
}
|
|
753
789
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface HostedStartOptions {
|
|
2
|
+
base: string;
|
|
3
|
+
head: string;
|
|
4
|
+
idempotencyKey?: string;
|
|
5
|
+
account?: string;
|
|
6
|
+
wait?: boolean;
|
|
7
|
+
interval?: string;
|
|
8
|
+
timeout?: string;
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface HostedStatusOptions {
|
|
12
|
+
account?: string;
|
|
13
|
+
wait?: boolean;
|
|
14
|
+
interval?: string;
|
|
15
|
+
timeout?: string;
|
|
16
|
+
json?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface HostedRunState {
|
|
19
|
+
runId: string;
|
|
20
|
+
status: string;
|
|
21
|
+
repository?: string;
|
|
22
|
+
baseCommit?: string;
|
|
23
|
+
headCommit?: string;
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
completedAt?: string;
|
|
26
|
+
output?: Record<string, unknown>;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
interface HostedPollOptions {
|
|
30
|
+
intervalMs: number;
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
onStatus?: (state: HostedRunState) => void;
|
|
33
|
+
}
|
|
34
|
+
interface HostedWaitResult {
|
|
35
|
+
state: HostedRunState;
|
|
36
|
+
timedOut: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function parseHostedRunState(value: unknown, expectedRunId?: string): HostedRunState;
|
|
39
|
+
export declare function parseHostedRepository(value: string): {
|
|
40
|
+
owner: string;
|
|
41
|
+
repository: string;
|
|
42
|
+
fullName: string;
|
|
43
|
+
};
|
|
44
|
+
export declare function defaultHostedIdempotencyKey(repository: string, baseCommit: string, headCommit: string): string;
|
|
45
|
+
export declare function startHostedVerification(repositoryArg: string, options: HostedStartOptions, token: string): Promise<{
|
|
46
|
+
state: HostedRunState;
|
|
47
|
+
idempotencyKey: string;
|
|
48
|
+
}>;
|
|
49
|
+
export declare function getHostedVerification(runId: string, token: string): Promise<HostedRunState>;
|
|
50
|
+
export declare function waitForHostedVerification(runId: string, token: string, options: HostedPollOptions): Promise<HostedWaitResult>;
|
|
51
|
+
export declare function buildHostedJson(operation: 'start' | 'status', state: HostedRunState, extras?: {
|
|
52
|
+
account?: string;
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
nextCommand?: string;
|
|
55
|
+
timedOut?: boolean;
|
|
56
|
+
}): Record<string, unknown>;
|
|
57
|
+
export declare function hostedResumeCommand(runId: string, account?: string): string;
|
|
58
|
+
export declare function verifyHostedStartCommand(repositoryArg: string, options: HostedStartOptions): Promise<void>;
|
|
59
|
+
export declare function verifyHostedStatusCommand(runId: string, options: HostedStatusOptions): Promise<void>;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { withSchema } from '../schema.js';
|
|
4
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
5
|
+
import { HaystackApiError, haystackJson } from '../utils/haystack-api.js';
|
|
6
|
+
const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
7
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
8
|
+
const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
|
|
9
|
+
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
10
|
+
const STATUS = /^[a-z][a-z0-9_-]{1,31}$/;
|
|
11
|
+
const TERMINAL_STATUSES = new Set(['complete', 'errored', 'expired', 'terminated']);
|
|
12
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
13
|
+
const DEFAULT_TIMEOUT_MINUTES = 35;
|
|
14
|
+
function requireRecord(value, context) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
16
|
+
throw new Error(`${context} returned an invalid response object.`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function requireString(value, field, pattern) {
|
|
21
|
+
const candidate = value[field];
|
|
22
|
+
if (typeof candidate !== 'string'
|
|
23
|
+
|| candidate.length === 0
|
|
24
|
+
|| (pattern && !pattern.test(candidate))) {
|
|
25
|
+
throw new Error(`Hosted verifier response is missing a valid ${field}.`);
|
|
26
|
+
}
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
function optionalString(value, field) {
|
|
30
|
+
const candidate = value[field];
|
|
31
|
+
if (candidate === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (typeof candidate !== 'string' || candidate.length === 0) {
|
|
34
|
+
throw new Error(`Hosted verifier response has an invalid ${field}.`);
|
|
35
|
+
}
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
function optionalTimestamp(value, field) {
|
|
39
|
+
const candidate = optionalString(value, field);
|
|
40
|
+
if (candidate === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
const parsed = new Date(candidate);
|
|
43
|
+
if (!Number.isFinite(parsed.getTime())
|
|
44
|
+
|| parsed.toISOString() !== candidate) {
|
|
45
|
+
throw new Error(`Hosted verifier response has an invalid ${field}.`);
|
|
46
|
+
}
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
export function parseHostedRunState(value, expectedRunId) {
|
|
50
|
+
const response = requireRecord(value, 'Hosted verifier');
|
|
51
|
+
const runId = requireString(response, 'runId', RUN_ID);
|
|
52
|
+
if (expectedRunId && runId !== expectedRunId) {
|
|
53
|
+
throw new Error(`Hosted verifier returned run ${runId} while status for ${expectedRunId} was requested.`);
|
|
54
|
+
}
|
|
55
|
+
const status = requireString(response, 'status', STATUS);
|
|
56
|
+
const repository = optionalString(response, 'repository');
|
|
57
|
+
if (repository) {
|
|
58
|
+
const parts = repository.split('/');
|
|
59
|
+
if (parts.length !== 2
|
|
60
|
+
|| !parts.every(part => REPOSITORY_COMPONENT.test(part))) {
|
|
61
|
+
throw new Error('Hosted verifier response has an invalid repository.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const baseCommit = optionalString(response, 'baseCommit');
|
|
65
|
+
const headCommit = optionalString(response, 'headCommit');
|
|
66
|
+
if (baseCommit && !COMMIT_SHA.test(baseCommit)) {
|
|
67
|
+
throw new Error('Hosted verifier response has an invalid baseCommit.');
|
|
68
|
+
}
|
|
69
|
+
if (headCommit && !COMMIT_SHA.test(headCommit)) {
|
|
70
|
+
throw new Error('Hosted verifier response has an invalid headCommit.');
|
|
71
|
+
}
|
|
72
|
+
const outputValue = response.output;
|
|
73
|
+
const output = outputValue === undefined
|
|
74
|
+
? undefined
|
|
75
|
+
: requireRecord(outputValue, 'Hosted verifier output');
|
|
76
|
+
const createdAt = optionalTimestamp(response, 'createdAt');
|
|
77
|
+
const completedAt = optionalTimestamp(response, 'completedAt');
|
|
78
|
+
if (status === 'complete') {
|
|
79
|
+
if (!repository
|
|
80
|
+
|| !baseCommit
|
|
81
|
+
|| !headCommit
|
|
82
|
+
|| !createdAt
|
|
83
|
+
|| !completedAt
|
|
84
|
+
|| !output
|
|
85
|
+
|| output.runId !== runId
|
|
86
|
+
|| output.repository !== repository
|
|
87
|
+
|| output.baseCommit !== baseCommit
|
|
88
|
+
|| output.headCommit !== headCommit
|
|
89
|
+
|| output.sourceOfflineReady !== true
|
|
90
|
+
|| output.sandboxDeleted !== true) {
|
|
91
|
+
throw new Error('Hosted verifier returned a complete run without a matching terminal receipt.');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
runId,
|
|
96
|
+
status,
|
|
97
|
+
repository,
|
|
98
|
+
baseCommit,
|
|
99
|
+
headCommit,
|
|
100
|
+
createdAt,
|
|
101
|
+
completedAt,
|
|
102
|
+
output,
|
|
103
|
+
error: optionalString(response, 'error'),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function parseHostedRepository(value) {
|
|
107
|
+
const parts = value.split('/');
|
|
108
|
+
if (parts.length !== 2
|
|
109
|
+
|| !parts.every(part => REPOSITORY_COMPONENT.test(part))) {
|
|
110
|
+
throw new Error(`Invalid repository "${value}". Use an exact GitHub owner/repository name.`);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
owner: parts[0],
|
|
114
|
+
repository: parts[1],
|
|
115
|
+
fullName: value,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function requireCommit(value, flag) {
|
|
119
|
+
if (typeof value !== 'string' || !COMMIT_SHA.test(value)) {
|
|
120
|
+
throw new Error(`${flag} must be an exact 40-character lowercase commit SHA.`);
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
export function defaultHostedIdempotencyKey(repository, baseCommit, headCommit) {
|
|
125
|
+
const digest = createHash('sha256')
|
|
126
|
+
.update(JSON.stringify([repository, baseCommit, headCommit]))
|
|
127
|
+
.digest('hex');
|
|
128
|
+
return `cli:${digest}`;
|
|
129
|
+
}
|
|
130
|
+
function requireIdempotencyKey(value) {
|
|
131
|
+
if (!IDEMPOTENCY_KEY.test(value)) {
|
|
132
|
+
throw new Error('--idempotency-key must be 8-128 characters and use only letters, numbers, ".", "_", ":", or "-".');
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
function parsePositiveNumber(value, fallback, flag, maximum) {
|
|
137
|
+
if (value === undefined)
|
|
138
|
+
return fallback;
|
|
139
|
+
const parsed = Number(value);
|
|
140
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > maximum) {
|
|
141
|
+
throw new Error(`${flag} must be a positive number no greater than ${maximum}.`);
|
|
142
|
+
}
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
function pollingOptions(interval, timeout, onStatus) {
|
|
146
|
+
return {
|
|
147
|
+
intervalMs: parsePositiveNumber(interval, DEFAULT_INTERVAL_SECONDS, '--interval', 300) * 1000,
|
|
148
|
+
timeoutMs: parsePositiveNumber(timeout, DEFAULT_TIMEOUT_MINUTES, '--timeout', 1_440) * 60_000,
|
|
149
|
+
onStatus,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export async function startHostedVerification(repositoryArg, options, token) {
|
|
153
|
+
const repository = parseHostedRepository(repositoryArg);
|
|
154
|
+
const baseCommit = requireCommit(options.base, '--base');
|
|
155
|
+
const headCommit = requireCommit(options.head, '--head');
|
|
156
|
+
const idempotencyKey = requireIdempotencyKey(options.idempotencyKey
|
|
157
|
+
?? defaultHostedIdempotencyKey(repository.fullName, baseCommit, headCommit));
|
|
158
|
+
const response = await haystackJson('/api/agent/cloud-verifier/runs', token, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
owner: repository.owner,
|
|
163
|
+
repository: repository.repository,
|
|
164
|
+
baseCommit,
|
|
165
|
+
headCommit,
|
|
166
|
+
idempotencyKey,
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
const state = parseHostedRunState(response);
|
|
170
|
+
return {
|
|
171
|
+
state: {
|
|
172
|
+
...state,
|
|
173
|
+
repository: state.repository ?? repository.fullName,
|
|
174
|
+
baseCommit: state.baseCommit ?? baseCommit,
|
|
175
|
+
headCommit: state.headCommit ?? headCommit,
|
|
176
|
+
},
|
|
177
|
+
idempotencyKey,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export async function getHostedVerification(runId, token) {
|
|
181
|
+
if (!RUN_ID.test(runId)) {
|
|
182
|
+
throw new Error('Run id must match cv_ followed by 48 lowercase hex characters.');
|
|
183
|
+
}
|
|
184
|
+
let response;
|
|
185
|
+
try {
|
|
186
|
+
response = await haystackJson(`/api/agent/cloud-verifier/runs/${encodeURIComponent(runId)}`, token, {}, [410]);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof HaystackApiError && error.status === 404) {
|
|
190
|
+
throw new Error(`Hosted verifier run ${runId} was not found for the selected account. `
|
|
191
|
+
+ 'If the run was started with another saved account, retry with `--account <login>`.');
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
return parseHostedRunState(response, runId);
|
|
196
|
+
}
|
|
197
|
+
function delay(milliseconds) {
|
|
198
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
199
|
+
}
|
|
200
|
+
export async function waitForHostedVerification(runId, token, options) {
|
|
201
|
+
const startedAt = Date.now();
|
|
202
|
+
let lastStatus;
|
|
203
|
+
while (true) {
|
|
204
|
+
const state = await getHostedVerification(runId, token);
|
|
205
|
+
if (state.status !== lastStatus) {
|
|
206
|
+
options.onStatus?.(state);
|
|
207
|
+
lastStatus = state.status;
|
|
208
|
+
}
|
|
209
|
+
if (TERMINAL_STATUSES.has(state.status)) {
|
|
210
|
+
return { state, timedOut: false };
|
|
211
|
+
}
|
|
212
|
+
const elapsed = Date.now() - startedAt;
|
|
213
|
+
if (elapsed >= options.timeoutMs) {
|
|
214
|
+
return { state, timedOut: true };
|
|
215
|
+
}
|
|
216
|
+
await delay(Math.min(options.intervalMs, options.timeoutMs - elapsed));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function buildHostedJson(operation, state, extras = {}) {
|
|
220
|
+
return withSchema('cloud-verifier', {
|
|
221
|
+
operation,
|
|
222
|
+
run_id: state.runId,
|
|
223
|
+
status: state.status,
|
|
224
|
+
...(state.repository ? { repository: state.repository } : {}),
|
|
225
|
+
...(state.baseCommit ? { base_commit: state.baseCommit } : {}),
|
|
226
|
+
...(state.headCommit ? { head_commit: state.headCommit } : {}),
|
|
227
|
+
...(state.createdAt ? { created_at: state.createdAt } : {}),
|
|
228
|
+
...(state.completedAt ? { completed_at: state.completedAt } : {}),
|
|
229
|
+
...(extras.account ? { account: extras.account } : {}),
|
|
230
|
+
...(extras.idempotencyKey
|
|
231
|
+
? { idempotency_key: extras.idempotencyKey }
|
|
232
|
+
: {}),
|
|
233
|
+
...(extras.nextCommand ? { next_command: extras.nextCommand } : {}),
|
|
234
|
+
...(extras.timedOut !== undefined ? { timed_out: extras.timedOut } : {}),
|
|
235
|
+
...(state.output ? { output: state.output } : {}),
|
|
236
|
+
...(state.error ? { error: state.error } : {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
export function hostedResumeCommand(runId, account) {
|
|
240
|
+
return `haystack verify hosted status ${runId}${account ? ` --account ${account}` : ''} --wait`;
|
|
241
|
+
}
|
|
242
|
+
function printHostedState(operation, state, options) {
|
|
243
|
+
const nextCommand = TERMINAL_STATUSES.has(state.status)
|
|
244
|
+
? undefined
|
|
245
|
+
: hostedResumeCommand(state.runId, options.account);
|
|
246
|
+
if (options.json) {
|
|
247
|
+
process.stdout.write(`${JSON.stringify(buildHostedJson(operation, state, {
|
|
248
|
+
account: options.account,
|
|
249
|
+
idempotencyKey: options.idempotencyKey,
|
|
250
|
+
nextCommand,
|
|
251
|
+
timedOut: options.timedOut,
|
|
252
|
+
}), null, 2)}\n`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const icon = state.status === 'complete'
|
|
256
|
+
? chalk.green('✓')
|
|
257
|
+
: TERMINAL_STATUSES.has(state.status)
|
|
258
|
+
? chalk.red('✗')
|
|
259
|
+
: chalk.cyan('•');
|
|
260
|
+
console.log(`${icon} Hosted verification ${chalk.bold(state.runId)}: ${state.status}`);
|
|
261
|
+
if (state.repository) {
|
|
262
|
+
console.log(` ${chalk.dim('Repository:')} ${state.repository}`);
|
|
263
|
+
}
|
|
264
|
+
if (state.baseCommit && state.headCommit) {
|
|
265
|
+
console.log(` ${chalk.dim('Commits:')} ${state.baseCommit.slice(0, 12)} → ${state.headCommit.slice(0, 12)}`);
|
|
266
|
+
}
|
|
267
|
+
if (state.error)
|
|
268
|
+
console.log(` ${chalk.dim('Error:')} ${state.error}`);
|
|
269
|
+
if (options.timedOut) {
|
|
270
|
+
console.log(chalk.yellow(' Wait timed out; the production run is still active.'));
|
|
271
|
+
}
|
|
272
|
+
if (nextCommand)
|
|
273
|
+
console.log(` ${chalk.dim('Resume:')} ${nextCommand}`);
|
|
274
|
+
}
|
|
275
|
+
function markNonSuccess(state, timedOut = false) {
|
|
276
|
+
if (timedOut
|
|
277
|
+
|| (state.status !== 'complete' && TERMINAL_STATUSES.has(state.status))) {
|
|
278
|
+
process.exitCode = 2;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export async function verifyHostedStartCommand(repositoryArg, options) {
|
|
282
|
+
const repository = parseHostedRepository(repositoryArg);
|
|
283
|
+
const auth = await resolveAuthContext({
|
|
284
|
+
preferredLogin: options.account,
|
|
285
|
+
owner: repository.owner,
|
|
286
|
+
repo: repository.repository,
|
|
287
|
+
});
|
|
288
|
+
if (!options.json) {
|
|
289
|
+
console.error(chalk.dim('Submitting the exact commit pair to the hosted verifier…'));
|
|
290
|
+
}
|
|
291
|
+
const started = await startHostedVerification(repositoryArg, options, auth.token);
|
|
292
|
+
const selectedAccount = auth.token.startsWith('hsk_live_')
|
|
293
|
+
? undefined
|
|
294
|
+
: auth.login;
|
|
295
|
+
let state = started.state;
|
|
296
|
+
let timedOut;
|
|
297
|
+
if (options.wait !== false && !TERMINAL_STATUSES.has(state.status)) {
|
|
298
|
+
const waited = await waitForHostedVerification(state.runId, auth.token, pollingOptions(options.interval, options.timeout, current => {
|
|
299
|
+
if (!options.json) {
|
|
300
|
+
console.error(chalk.dim(`Hosted verifier: ${current.status}…`));
|
|
301
|
+
}
|
|
302
|
+
}));
|
|
303
|
+
state = waited.state;
|
|
304
|
+
timedOut = waited.timedOut;
|
|
305
|
+
}
|
|
306
|
+
else if (options.wait !== false) {
|
|
307
|
+
timedOut = false;
|
|
308
|
+
}
|
|
309
|
+
printHostedState('start', state, {
|
|
310
|
+
json: options.json,
|
|
311
|
+
account: selectedAccount,
|
|
312
|
+
idempotencyKey: started.idempotencyKey,
|
|
313
|
+
timedOut,
|
|
314
|
+
});
|
|
315
|
+
markNonSuccess(state, timedOut);
|
|
316
|
+
}
|
|
317
|
+
export async function verifyHostedStatusCommand(runId, options) {
|
|
318
|
+
const auth = await resolveAuthContext({ preferredLogin: options.account });
|
|
319
|
+
const selectedAccount = auth.token.startsWith('hsk_live_')
|
|
320
|
+
? undefined
|
|
321
|
+
: auth.login;
|
|
322
|
+
let state = await getHostedVerification(runId, auth.token);
|
|
323
|
+
let timedOut;
|
|
324
|
+
if (options.wait && !TERMINAL_STATUSES.has(state.status)) {
|
|
325
|
+
const waited = await waitForHostedVerification(runId, auth.token, pollingOptions(options.interval, options.timeout, current => {
|
|
326
|
+
if (!options.json) {
|
|
327
|
+
console.error(chalk.dim(`Hosted verifier: ${current.status}…`));
|
|
328
|
+
}
|
|
329
|
+
}));
|
|
330
|
+
state = waited.state;
|
|
331
|
+
timedOut = waited.timedOut;
|
|
332
|
+
}
|
|
333
|
+
else if (options.wait) {
|
|
334
|
+
timedOut = false;
|
|
335
|
+
}
|
|
336
|
+
printHostedState('status', state, {
|
|
337
|
+
json: options.json,
|
|
338
|
+
account: selectedAccount,
|
|
339
|
+
timedOut,
|
|
340
|
+
});
|
|
341
|
+
markNonSuccess(state, timedOut);
|
|
342
|
+
}
|