@haystackeditor/cli 0.15.22 → 0.15.24
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 +27 -0
- package/dist/commands/verify-hosted-mcp.d.ts +12 -0
- package/dist/commands/verify-hosted-mcp.js +234 -0
- package/dist/commands/verify-hosted-reproducibility.d.ts +73 -0
- package/dist/commands/verify-hosted-reproducibility.js +427 -0
- package/dist/commands/verify-hosted.d.ts +3 -2
- package/dist/commands/verify-hosted.js +3 -3
- package/dist/index.js +46 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,33 @@ haystack verify hosted status cv_<48-lowercase-hex-characters> \
|
|
|
105
105
|
--json
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
Agents can use the same production lifecycle over stdio MCP:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
haystack verify hosted mcp --account <login>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The hosted server exposes only `verify_start`, `verify_status`, and
|
|
115
|
+
`verify_wait`. Tool results use the same `cloud-verifier` schema as the CLI;
|
|
116
|
+
credentials, repository source, provider metadata, and sandbox mutation are
|
|
117
|
+
not exposed.
|
|
118
|
+
|
|
119
|
+
Repeat the same exact production launch to detect planner or assessment drift:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
haystack verify hosted reproducibility owner/repo \
|
|
123
|
+
--base <40-character-commit-sha> \
|
|
124
|
+
--head <40-character-commit-sha> \
|
|
125
|
+
--runs 3 \
|
|
126
|
+
--json
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each run gets a distinct idempotency key derived from one printed series key.
|
|
130
|
+
Pass `--series-key <key>` to resume the same series without creating duplicate
|
|
131
|
+
runs. The report compares stable risk-cell, capability, and comparison IDs.
|
|
132
|
+
Plan drift, assessment drift, timeouts, unsupported receipts, and inconclusive
|
|
133
|
+
cells all prevent a reproducible verdict and exit 2.
|
|
134
|
+
|
|
108
135
|
**`haystack setup --json`** speaks NDJSON: events (`question`, `permission`,
|
|
109
136
|
`progress`, `result`) on stdout, replies on stdin keyed by
|
|
110
137
|
`requestID`/`permissionID`. `haystack schema setup` documents the full
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
export declare const HOSTED_VERIFY_TOOLS: Tool[];
|
|
3
|
+
export interface HostedVerifyMcpOptions {
|
|
4
|
+
account?: string;
|
|
5
|
+
}
|
|
6
|
+
interface HostedToolContext {
|
|
7
|
+
token: string;
|
|
8
|
+
account?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function executeHostedVerifyTool(name: string, input: Record<string, unknown>, context: HostedToolContext): Promise<Record<string, unknown>>;
|
|
11
|
+
export declare function runHostedVerifyMcpServer(options: HostedVerifyMcpOptions): Promise<void>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Production Cloud Verifier MCP server: `haystack verify hosted mcp`.
|
|
2
|
+
//
|
|
3
|
+
// This intentionally exposes only the durable hosted run lifecycle. The local
|
|
4
|
+
// specimen MCP server has artifact and retained-sandbox tools whose data model
|
|
5
|
+
// does not exist in the source-free production result contract.
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
10
|
+
import { buildHostedJson, getHostedVerification, hostedResumeCommand, parseHostedRepository, startHostedVerification, waitForHostedVerification, } from './verify-hosted.js';
|
|
11
|
+
const TERMINAL_STATUSES = new Set([
|
|
12
|
+
'complete',
|
|
13
|
+
'errored',
|
|
14
|
+
'expired',
|
|
15
|
+
'terminated',
|
|
16
|
+
]);
|
|
17
|
+
const DEFAULT_TIMEOUT_SECONDS = 30 * 60;
|
|
18
|
+
const MAX_TIMEOUT_SECONDS = 60 * 60;
|
|
19
|
+
const accountInput = {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Saved Haystack account login. Omit to use the server default or active account.',
|
|
22
|
+
};
|
|
23
|
+
const runIdInput = {
|
|
24
|
+
type: 'string',
|
|
25
|
+
pattern: '^cv_[0-9a-f]{48}$',
|
|
26
|
+
description: 'Owned hosted run id.',
|
|
27
|
+
};
|
|
28
|
+
export const HOSTED_VERIFY_TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: 'verify_start',
|
|
31
|
+
description: 'Start an authenticated production verification for an exact GitHub commit pair. Returns immediately; call verify_wait next.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
properties: {
|
|
36
|
+
repository: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
pattern: '^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$',
|
|
39
|
+
description: 'Exact GitHub owner/repository name.',
|
|
40
|
+
},
|
|
41
|
+
base_commit: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
pattern: '^[0-9a-f]{40}$',
|
|
44
|
+
description: 'Exact 40-character lowercase base commit SHA.',
|
|
45
|
+
},
|
|
46
|
+
head_commit: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
pattern: '^[0-9a-f]{40}$',
|
|
49
|
+
description: 'Exact 40-character lowercase head commit SHA.',
|
|
50
|
+
},
|
|
51
|
+
idempotency_key: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$',
|
|
54
|
+
description: 'Optional stable retry key. Omit to derive it from the exact repository and commits.',
|
|
55
|
+
},
|
|
56
|
+
account: accountInput,
|
|
57
|
+
},
|
|
58
|
+
required: ['repository', 'base_commit', 'head_commit'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'verify_status',
|
|
63
|
+
description: 'Read the current source-free production receipt for an owned hosted run.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: {
|
|
68
|
+
run_id: runIdInput,
|
|
69
|
+
account: accountInput,
|
|
70
|
+
},
|
|
71
|
+
required: ['run_id'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'verify_wait',
|
|
76
|
+
description: 'Wait for an owned production run to finish. A timeout returns the last observed status with timed_out=true; the run keeps going.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
properties: {
|
|
81
|
+
run_id: runIdInput,
|
|
82
|
+
timeout_seconds: {
|
|
83
|
+
type: 'integer',
|
|
84
|
+
minimum: 10,
|
|
85
|
+
maximum: MAX_TIMEOUT_SECONDS,
|
|
86
|
+
description: 'Bounded wait in seconds (default 1800, maximum 3600).',
|
|
87
|
+
},
|
|
88
|
+
account: accountInput,
|
|
89
|
+
},
|
|
90
|
+
required: ['run_id'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
function requireInputString(input, field) {
|
|
95
|
+
const value = input[field];
|
|
96
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
97
|
+
throw new Error(`${field} required`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function optionalInputString(input, field) {
|
|
102
|
+
const value = input[field];
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
return undefined;
|
|
105
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
106
|
+
throw new Error(`${field} must be a non-empty string`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
function assertExactInputFields(input, allowed) {
|
|
111
|
+
const allowedFields = new Set(allowed);
|
|
112
|
+
const unsupported = Object.keys(input).find(field => !allowedFields.has(field));
|
|
113
|
+
if (unsupported)
|
|
114
|
+
throw new Error(`unsupported input field: ${unsupported}`);
|
|
115
|
+
}
|
|
116
|
+
function assertToolInputFields(name, input) {
|
|
117
|
+
if (name === 'verify_start') {
|
|
118
|
+
assertExactInputFields(input, [
|
|
119
|
+
'account',
|
|
120
|
+
'base_commit',
|
|
121
|
+
'head_commit',
|
|
122
|
+
'idempotency_key',
|
|
123
|
+
'repository',
|
|
124
|
+
]);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (name === 'verify_status') {
|
|
128
|
+
assertExactInputFields(input, ['account', 'run_id']);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (name === 'verify_wait') {
|
|
132
|
+
assertExactInputFields(input, ['account', 'run_id', 'timeout_seconds']);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`unknown tool: ${name}`);
|
|
136
|
+
}
|
|
137
|
+
function nextCommand(state, account) {
|
|
138
|
+
return TERMINAL_STATUSES.has(state.status)
|
|
139
|
+
? undefined
|
|
140
|
+
: hostedResumeCommand(state.runId, account);
|
|
141
|
+
}
|
|
142
|
+
export async function executeHostedVerifyTool(name, input, context) {
|
|
143
|
+
assertToolInputFields(name, input);
|
|
144
|
+
if (name === 'verify_start') {
|
|
145
|
+
const repository = requireInputString(input, 'repository');
|
|
146
|
+
const started = await startHostedVerification(repository, {
|
|
147
|
+
base: requireInputString(input, 'base_commit'),
|
|
148
|
+
head: requireInputString(input, 'head_commit'),
|
|
149
|
+
idempotencyKey: optionalInputString(input, 'idempotency_key'),
|
|
150
|
+
wait: false,
|
|
151
|
+
}, context.token);
|
|
152
|
+
return buildHostedJson('start', started.state, {
|
|
153
|
+
account: context.account,
|
|
154
|
+
idempotencyKey: started.idempotencyKey,
|
|
155
|
+
nextCommand: nextCommand(started.state, context.account),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (name === 'verify_status') {
|
|
159
|
+
const state = await getHostedVerification(requireInputString(input, 'run_id'), context.token);
|
|
160
|
+
return buildHostedJson('status', state, {
|
|
161
|
+
account: context.account,
|
|
162
|
+
nextCommand: nextCommand(state, context.account),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (name === 'verify_wait') {
|
|
166
|
+
const runId = requireInputString(input, 'run_id');
|
|
167
|
+
const rawTimeout = input.timeout_seconds;
|
|
168
|
+
const timeoutSeconds = rawTimeout === undefined
|
|
169
|
+
? DEFAULT_TIMEOUT_SECONDS
|
|
170
|
+
: rawTimeout;
|
|
171
|
+
if (typeof timeoutSeconds !== 'number'
|
|
172
|
+
|| !Number.isInteger(timeoutSeconds)
|
|
173
|
+
|| timeoutSeconds < 10
|
|
174
|
+
|| timeoutSeconds > MAX_TIMEOUT_SECONDS) {
|
|
175
|
+
throw new Error('timeout_seconds must be an integer from 10 to 3600');
|
|
176
|
+
}
|
|
177
|
+
const waited = await waitForHostedVerification(runId, context.token, {
|
|
178
|
+
intervalMs: 3_000,
|
|
179
|
+
timeoutMs: timeoutSeconds * 1_000,
|
|
180
|
+
});
|
|
181
|
+
return buildHostedJson('status', waited.state, {
|
|
182
|
+
account: context.account,
|
|
183
|
+
nextCommand: nextCommand(waited.state, context.account),
|
|
184
|
+
timedOut: waited.timedOut,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`unknown tool: ${name}`);
|
|
188
|
+
}
|
|
189
|
+
async function resolveToolContext(name, input, options) {
|
|
190
|
+
const requestedAccount = optionalInputString(input, 'account') ?? options.account;
|
|
191
|
+
const repository = name === 'verify_start'
|
|
192
|
+
? parseHostedRepository(requireInputString(input, 'repository'))
|
|
193
|
+
: undefined;
|
|
194
|
+
const auth = await resolveAuthContext({
|
|
195
|
+
preferredLogin: requestedAccount,
|
|
196
|
+
owner: repository?.owner,
|
|
197
|
+
repo: repository?.repository,
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
token: auth.token,
|
|
201
|
+
account: auth.token.startsWith('hsk_live_') ? undefined : auth.login,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
export async function runHostedVerifyMcpServer(options) {
|
|
205
|
+
const mcp = new Server({ name: 'haystack-verify-hosted', version: '1.0.0' }, { capabilities: { tools: {} } });
|
|
206
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: HOSTED_VERIFY_TOOLS }));
|
|
207
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
208
|
+
const { name, arguments: args } = request.params;
|
|
209
|
+
const input = (args ?? {});
|
|
210
|
+
try {
|
|
211
|
+
// Reject undeclared fields before account resolution so callers cannot
|
|
212
|
+
// use an invalid request to trigger credential or repository lookup.
|
|
213
|
+
assertToolInputFields(name, input);
|
|
214
|
+
const context = await resolveToolContext(name, input, options);
|
|
215
|
+
const result = await executeHostedVerifyTool(name, input, context);
|
|
216
|
+
return {
|
|
217
|
+
content: [{
|
|
218
|
+
type: 'text',
|
|
219
|
+
text: JSON.stringify(result, null, 2),
|
|
220
|
+
}],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return {
|
|
225
|
+
isError: true,
|
|
226
|
+
content: [{
|
|
227
|
+
type: 'text',
|
|
228
|
+
text: error instanceof Error ? error.message : String(error),
|
|
229
|
+
}],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
await mcp.connect(new StdioServerTransport());
|
|
234
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type HostedRunState } from './verify-hosted.js';
|
|
2
|
+
export interface HostedReproducibilityOptions {
|
|
3
|
+
base: string;
|
|
4
|
+
head: string;
|
|
5
|
+
runs?: string;
|
|
6
|
+
seriesKey?: string;
|
|
7
|
+
account?: string;
|
|
8
|
+
interval?: string;
|
|
9
|
+
timeout?: string;
|
|
10
|
+
json?: boolean;
|
|
11
|
+
}
|
|
12
|
+
interface HostedReproducibilityRun {
|
|
13
|
+
state: HostedRunState;
|
|
14
|
+
timedOut: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface ParsedAssessment {
|
|
17
|
+
planIdentities: Array<{
|
|
18
|
+
cellId: string;
|
|
19
|
+
capabilityId: string | null;
|
|
20
|
+
}>;
|
|
21
|
+
cellIds: string[];
|
|
22
|
+
status: string;
|
|
23
|
+
outcomes: Array<{
|
|
24
|
+
cellId: string;
|
|
25
|
+
capabilityId: string | null;
|
|
26
|
+
comparisonId: string | null;
|
|
27
|
+
status: string;
|
|
28
|
+
reasonCode: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
interface RunFailure {
|
|
32
|
+
run_id: string;
|
|
33
|
+
status: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
}
|
|
36
|
+
interface Variant {
|
|
37
|
+
fingerprint: string;
|
|
38
|
+
run_ids: string[];
|
|
39
|
+
cell_ids: string[];
|
|
40
|
+
}
|
|
41
|
+
interface OutcomeVariant {
|
|
42
|
+
fingerprint: string;
|
|
43
|
+
run_ids: string[];
|
|
44
|
+
assessment_status: string;
|
|
45
|
+
outcomes: ParsedAssessment['outcomes'];
|
|
46
|
+
}
|
|
47
|
+
export interface HostedReproducibilityReport {
|
|
48
|
+
series_key: string;
|
|
49
|
+
repository: string;
|
|
50
|
+
base_commit: string;
|
|
51
|
+
head_commit: string;
|
|
52
|
+
requested_runs: number;
|
|
53
|
+
analyzable_runs: number;
|
|
54
|
+
run_ids: string[];
|
|
55
|
+
planner_reproducible: boolean;
|
|
56
|
+
assessment_reproducible: boolean;
|
|
57
|
+
reproducible: boolean;
|
|
58
|
+
plan_variants: Variant[];
|
|
59
|
+
assessment_variants: OutcomeVariant[];
|
|
60
|
+
plan_drift_cell_ids: string[];
|
|
61
|
+
assessment_drift_cell_ids: string[];
|
|
62
|
+
inconclusive_cell_ids: string[];
|
|
63
|
+
unrunnable_runs: RunFailure[];
|
|
64
|
+
}
|
|
65
|
+
export declare function hostedReproducibilityRunKey(seriesKey: string, repository: string, baseCommit: string, headCommit: string, ordinal: number): string;
|
|
66
|
+
export declare function analyzeHostedReproducibility(runs: HostedReproducibilityRun[], identity: {
|
|
67
|
+
seriesKey: string;
|
|
68
|
+
repository: string;
|
|
69
|
+
baseCommit: string;
|
|
70
|
+
headCommit: string;
|
|
71
|
+
}): HostedReproducibilityReport;
|
|
72
|
+
export declare function verifyHostedReproducibilityCommand(repositoryArg: string, options: HostedReproducibilityOptions): Promise<void>;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { withSchema } from '../schema.js';
|
|
4
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
5
|
+
import { hostedPollingOptions, parseHostedRepository, startHostedVerification, waitForHostedVerification, } from './verify-hosted.js';
|
|
6
|
+
const CELL_ID = /^cell_[0-9a-f]{32}$/;
|
|
7
|
+
const CAPABILITY_ID = /^repository_driver_[0-9a-f]{64}$/;
|
|
8
|
+
const COMPARISON_ID = /^driver_comparison_[0-9a-f]{64}$/;
|
|
9
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
10
|
+
const SERIES_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$/;
|
|
11
|
+
const TERMINAL_STATUSES = new Set([
|
|
12
|
+
'complete',
|
|
13
|
+
'errored',
|
|
14
|
+
'expired',
|
|
15
|
+
'terminated',
|
|
16
|
+
]);
|
|
17
|
+
const ASSESSMENT_STATUSES = new Set([
|
|
18
|
+
'improvement',
|
|
19
|
+
'inconclusive',
|
|
20
|
+
'regression',
|
|
21
|
+
'unchanged',
|
|
22
|
+
]);
|
|
23
|
+
const ASSESSMENT_REASON_CODES = new Set([
|
|
24
|
+
'case_universe_changed',
|
|
25
|
+
'coverage_increased',
|
|
26
|
+
'coverage_reduced',
|
|
27
|
+
'inconclusive',
|
|
28
|
+
'mixed_outcomes',
|
|
29
|
+
'new_failures',
|
|
30
|
+
'no_bound_driver',
|
|
31
|
+
'resolved_failures',
|
|
32
|
+
'unchanged',
|
|
33
|
+
]);
|
|
34
|
+
const OVERALL_ASSESSMENT_STATUSES = new Set([
|
|
35
|
+
'inconclusive',
|
|
36
|
+
'no_regressions_detected',
|
|
37
|
+
'regressions_detected',
|
|
38
|
+
]);
|
|
39
|
+
const DEFAULT_RUNS = 3;
|
|
40
|
+
const MAX_RUNS = 10;
|
|
41
|
+
function record(value, label) {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
43
|
+
throw new Error(`${label} is malformed`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function array(value, label) {
|
|
48
|
+
if (!Array.isArray(value))
|
|
49
|
+
throw new Error(`${label} is malformed`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function boundedInteger(value, fallback, label, minimum, maximum) {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return fallback;
|
|
55
|
+
const parsed = Number(value);
|
|
56
|
+
if (!Number.isSafeInteger(parsed)
|
|
57
|
+
|| parsed < minimum
|
|
58
|
+
|| parsed > maximum) {
|
|
59
|
+
throw new Error(`${label} must be an integer from ${minimum} through ${maximum}.`);
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
function requireSeriesKey(value) {
|
|
64
|
+
const key = value
|
|
65
|
+
?? `series:${new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14)}:${randomBytes(6).toString('hex')}`;
|
|
66
|
+
if (!SERIES_KEY.test(key)) {
|
|
67
|
+
throw new Error('--series-key must be 8-64 characters and use only letters, numbers, ".", "_", ":", or "-".');
|
|
68
|
+
}
|
|
69
|
+
return key;
|
|
70
|
+
}
|
|
71
|
+
export function hostedReproducibilityRunKey(seriesKey, repository, baseCommit, headCommit, ordinal) {
|
|
72
|
+
const digest = createHash('sha256')
|
|
73
|
+
.update(JSON.stringify([
|
|
74
|
+
seriesKey,
|
|
75
|
+
repository,
|
|
76
|
+
baseCommit,
|
|
77
|
+
headCommit,
|
|
78
|
+
ordinal,
|
|
79
|
+
]))
|
|
80
|
+
.digest('hex');
|
|
81
|
+
return `repro:${digest}`;
|
|
82
|
+
}
|
|
83
|
+
function fingerprint(value) {
|
|
84
|
+
return createHash('sha256')
|
|
85
|
+
.update(JSON.stringify(value))
|
|
86
|
+
.digest('hex');
|
|
87
|
+
}
|
|
88
|
+
function expectedCellStatus(reasonCode) {
|
|
89
|
+
if (reasonCode === 'new_failures'
|
|
90
|
+
|| reasonCode === 'coverage_reduced'
|
|
91
|
+
|| reasonCode === 'mixed_outcomes') {
|
|
92
|
+
return 'regression';
|
|
93
|
+
}
|
|
94
|
+
if (reasonCode === 'resolved_failures'
|
|
95
|
+
|| reasonCode === 'coverage_increased') {
|
|
96
|
+
return 'improvement';
|
|
97
|
+
}
|
|
98
|
+
if (reasonCode === 'unchanged')
|
|
99
|
+
return 'unchanged';
|
|
100
|
+
return 'inconclusive';
|
|
101
|
+
}
|
|
102
|
+
function expectedOverallStatus(outcomes) {
|
|
103
|
+
if (outcomes.some(outcome => outcome.status === 'regression')) {
|
|
104
|
+
return 'regressions_detected';
|
|
105
|
+
}
|
|
106
|
+
if (outcomes.length === 0
|
|
107
|
+
|| outcomes.some(outcome => outcome.status === 'inconclusive')) {
|
|
108
|
+
return 'inconclusive';
|
|
109
|
+
}
|
|
110
|
+
return 'no_regressions_detected';
|
|
111
|
+
}
|
|
112
|
+
function requireCount(receipt, field, expected) {
|
|
113
|
+
if (receipt[field] !== expected) {
|
|
114
|
+
throw new Error(`driver assessment ${field} is inconsistent`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function parseAssessment(state, identity) {
|
|
118
|
+
if (state.status !== 'complete' || !state.output) {
|
|
119
|
+
throw new Error(`run ended with status ${state.status}`);
|
|
120
|
+
}
|
|
121
|
+
if (state.repository !== identity.repository
|
|
122
|
+
|| state.baseCommit !== identity.baseCommit
|
|
123
|
+
|| state.headCommit !== identity.headCommit) {
|
|
124
|
+
throw new Error('run identity does not match the requested exact launch');
|
|
125
|
+
}
|
|
126
|
+
const output = record(state.output, 'terminal output');
|
|
127
|
+
if (output.schemaVersion !== 8
|
|
128
|
+
|| output.status !== 'driver_assessment_recorded') {
|
|
129
|
+
throw new Error('run did not produce a schema-8 driver assessment');
|
|
130
|
+
}
|
|
131
|
+
const riskPlan = record(output.riskPlanReceipt, 'risk plan receipt');
|
|
132
|
+
const assessment = record(output.driverAssessmentReceipt, 'driver assessment receipt');
|
|
133
|
+
const planCells = array(riskPlan.cells, 'risk plan cells');
|
|
134
|
+
const assessmentCells = array(assessment.cells, 'assessment cells');
|
|
135
|
+
if (riskPlan.schemaVersion !== 2
|
|
136
|
+
|| riskPlan.status !== 'risk_plan_ready'
|
|
137
|
+
|| riskPlan.baseCommit !== identity.baseCommit
|
|
138
|
+
|| riskPlan.headCommit !== identity.headCommit
|
|
139
|
+
|| riskPlan.sourceContentPersisted !== false) {
|
|
140
|
+
throw new Error('risk plan receipt identity is malformed');
|
|
141
|
+
}
|
|
142
|
+
const planIdentities = planCells.map((raw) => {
|
|
143
|
+
const cell = record(raw, 'risk plan cell');
|
|
144
|
+
if (typeof cell.cellId !== 'string'
|
|
145
|
+
|| !CELL_ID.test(cell.cellId)
|
|
146
|
+
|| (cell.capabilityId !== null
|
|
147
|
+
&& (typeof cell.capabilityId !== 'string'
|
|
148
|
+
|| !CAPABILITY_ID.test(cell.capabilityId)))) {
|
|
149
|
+
throw new Error('risk plan cell identity is malformed');
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
cellId: cell.cellId,
|
|
153
|
+
capabilityId: cell.capabilityId,
|
|
154
|
+
};
|
|
155
|
+
}).sort((left, right) => left.cellId.localeCompare(right.cellId));
|
|
156
|
+
const cellIds = planIdentities.map(cell => cell.cellId);
|
|
157
|
+
if (cellIds.length === 0
|
|
158
|
+
|| new Set(cellIds).size !== cellIds.length
|
|
159
|
+
|| assessmentCells.length !== cellIds.length) {
|
|
160
|
+
throw new Error('risk plan and assessment cell sets are inconsistent');
|
|
161
|
+
}
|
|
162
|
+
if (assessment.schemaVersion !== 1
|
|
163
|
+
|| typeof assessment.status !== 'string'
|
|
164
|
+
|| !OVERALL_ASSESSMENT_STATUSES.has(assessment.status)
|
|
165
|
+
|| assessment.baseCommit !== identity.baseCommit
|
|
166
|
+
|| assessment.headCommit !== identity.headCommit
|
|
167
|
+
|| typeof assessment.driverComparisonReceiptSha256 !== 'string'
|
|
168
|
+
|| !SHA256.test(assessment.driverComparisonReceiptSha256)
|
|
169
|
+
|| assessment.sourceContentPersisted !== false) {
|
|
170
|
+
throw new Error('driver assessment receipt identity is malformed');
|
|
171
|
+
}
|
|
172
|
+
const outcomes = assessmentCells.map((raw) => {
|
|
173
|
+
const cell = record(raw, 'assessment cell');
|
|
174
|
+
if (typeof cell.cellId !== 'string'
|
|
175
|
+
|| !CELL_ID.test(cell.cellId)
|
|
176
|
+
|| (cell.capabilityId !== null
|
|
177
|
+
&& (typeof cell.capabilityId !== 'string'
|
|
178
|
+
|| !CAPABILITY_ID.test(cell.capabilityId)))
|
|
179
|
+
|| (cell.comparisonId !== null
|
|
180
|
+
&& (typeof cell.comparisonId !== 'string'
|
|
181
|
+
|| !COMPARISON_ID.test(cell.comparisonId)))
|
|
182
|
+
|| typeof cell.status !== 'string'
|
|
183
|
+
|| !ASSESSMENT_STATUSES.has(cell.status)
|
|
184
|
+
|| typeof cell.reasonCode !== 'string'
|
|
185
|
+
|| !ASSESSMENT_REASON_CODES.has(cell.reasonCode)
|
|
186
|
+
|| cell.status !== expectedCellStatus(cell.reasonCode)
|
|
187
|
+
|| (cell.capabilityId === null
|
|
188
|
+
? (cell.comparisonId !== null
|
|
189
|
+
|| cell.reasonCode !== 'no_bound_driver')
|
|
190
|
+
: (cell.comparisonId === null
|
|
191
|
+
|| cell.reasonCode === 'no_bound_driver'))) {
|
|
192
|
+
throw new Error('assessment cell outcome is malformed');
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
cellId: cell.cellId,
|
|
196
|
+
capabilityId: cell.capabilityId,
|
|
197
|
+
comparisonId: cell.comparisonId,
|
|
198
|
+
status: cell.status,
|
|
199
|
+
reasonCode: cell.reasonCode,
|
|
200
|
+
};
|
|
201
|
+
}).sort((left, right) => left.cellId.localeCompare(right.cellId));
|
|
202
|
+
if (new Set(outcomes.map(outcome => outcome.cellId)).size !== outcomes.length
|
|
203
|
+
|| outcomes.some((outcome, index) => outcome.cellId !== cellIds[index]
|
|
204
|
+
|| outcome.capabilityId !== planIdentities[index].capabilityId)) {
|
|
205
|
+
throw new Error('risk plan and assessment cell identities do not match');
|
|
206
|
+
}
|
|
207
|
+
const counts = {
|
|
208
|
+
comparedCellCount: outcomes.filter(outcome => outcome.status !== 'inconclusive').length,
|
|
209
|
+
regressionCellCount: outcomes.filter(outcome => outcome.status === 'regression').length,
|
|
210
|
+
improvementCellCount: outcomes.filter(outcome => outcome.status === 'improvement').length,
|
|
211
|
+
unchangedCellCount: outcomes.filter(outcome => outcome.status === 'unchanged').length,
|
|
212
|
+
inconclusiveCellCount: outcomes.filter(outcome => outcome.status === 'inconclusive').length,
|
|
213
|
+
};
|
|
214
|
+
requireCount(assessment, 'plannedCellCount', outcomes.length);
|
|
215
|
+
for (const [field, count] of Object.entries(counts)) {
|
|
216
|
+
requireCount(assessment, field, count);
|
|
217
|
+
}
|
|
218
|
+
if (assessment.status !== expectedOverallStatus(outcomes)) {
|
|
219
|
+
throw new Error('driver assessment overall status is inconsistent');
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
planIdentities,
|
|
223
|
+
cellIds,
|
|
224
|
+
status: assessment.status,
|
|
225
|
+
outcomes,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function groupedPlanVariants(parsed) {
|
|
229
|
+
const variants = new Map();
|
|
230
|
+
for (const item of parsed) {
|
|
231
|
+
const digest = fingerprint(item.assessment.planIdentities);
|
|
232
|
+
const existing = variants.get(digest);
|
|
233
|
+
if (existing) {
|
|
234
|
+
existing.run_ids.push(item.runId);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
variants.set(digest, {
|
|
238
|
+
fingerprint: digest,
|
|
239
|
+
run_ids: [item.runId],
|
|
240
|
+
cell_ids: item.assessment.cellIds,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return [...variants.values()].sort((left, right) => left.fingerprint.localeCompare(right.fingerprint));
|
|
245
|
+
}
|
|
246
|
+
function groupedOutcomeVariants(parsed) {
|
|
247
|
+
const variants = new Map();
|
|
248
|
+
for (const item of parsed) {
|
|
249
|
+
const digest = fingerprint({
|
|
250
|
+
status: item.assessment.status,
|
|
251
|
+
outcomes: item.assessment.outcomes,
|
|
252
|
+
});
|
|
253
|
+
const existing = variants.get(digest);
|
|
254
|
+
if (existing) {
|
|
255
|
+
existing.run_ids.push(item.runId);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
variants.set(digest, {
|
|
259
|
+
fingerprint: digest,
|
|
260
|
+
run_ids: [item.runId],
|
|
261
|
+
assessment_status: item.assessment.status,
|
|
262
|
+
outcomes: item.assessment.outcomes,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return [...variants.values()].sort((left, right) => left.fingerprint.localeCompare(right.fingerprint));
|
|
267
|
+
}
|
|
268
|
+
function driftIds(parsed, values) {
|
|
269
|
+
const byCell = new Map();
|
|
270
|
+
for (const [runIndex, item] of parsed.entries()) {
|
|
271
|
+
const present = values(item.assessment);
|
|
272
|
+
for (const [cellId, observed] of byCell) {
|
|
273
|
+
observed.add(present.has(cellId)
|
|
274
|
+
? JSON.stringify(present.get(cellId))
|
|
275
|
+
: '__missing__');
|
|
276
|
+
}
|
|
277
|
+
for (const [cellId, value] of present) {
|
|
278
|
+
if (byCell.has(cellId))
|
|
279
|
+
continue;
|
|
280
|
+
const observed = new Set();
|
|
281
|
+
if (runIndex > 0)
|
|
282
|
+
observed.add('__missing__');
|
|
283
|
+
observed.add(JSON.stringify(value));
|
|
284
|
+
byCell.set(cellId, observed);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return [...byCell.entries()]
|
|
288
|
+
.filter(([, observed]) => observed.size > 1)
|
|
289
|
+
.map(([cellId]) => cellId)
|
|
290
|
+
.sort();
|
|
291
|
+
}
|
|
292
|
+
export function analyzeHostedReproducibility(runs, identity) {
|
|
293
|
+
const parsed = [];
|
|
294
|
+
const unrunnableRuns = [];
|
|
295
|
+
for (const run of runs) {
|
|
296
|
+
if (run.timedOut) {
|
|
297
|
+
unrunnableRuns.push({
|
|
298
|
+
run_id: run.state.runId,
|
|
299
|
+
status: run.state.status,
|
|
300
|
+
reason: 'wait_timed_out',
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
parsed.push({
|
|
306
|
+
runId: run.state.runId,
|
|
307
|
+
assessment: parseAssessment(run.state, identity),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
unrunnableRuns.push({
|
|
312
|
+
run_id: run.state.runId,
|
|
313
|
+
status: run.state.status,
|
|
314
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const planVariants = groupedPlanVariants(parsed);
|
|
319
|
+
const assessmentVariants = groupedOutcomeVariants(parsed);
|
|
320
|
+
const plannerReproducible = parsed.length === runs.length && planVariants.length === 1;
|
|
321
|
+
const assessmentReproducible = plannerReproducible && assessmentVariants.length === 1;
|
|
322
|
+
const planDriftCellIds = driftIds(parsed, assessment => new Map(assessment.planIdentities.map(plan => [
|
|
323
|
+
plan.cellId,
|
|
324
|
+
{ capabilityId: plan.capabilityId },
|
|
325
|
+
])));
|
|
326
|
+
const assessmentDriftCellIds = driftIds(parsed, assessment => new Map(assessment.outcomes.map(outcome => [
|
|
327
|
+
outcome.cellId,
|
|
328
|
+
{
|
|
329
|
+
capabilityId: outcome.capabilityId,
|
|
330
|
+
comparisonId: outcome.comparisonId,
|
|
331
|
+
status: outcome.status,
|
|
332
|
+
reasonCode: outcome.reasonCode,
|
|
333
|
+
},
|
|
334
|
+
])));
|
|
335
|
+
const inconclusiveCellIds = [...new Set(parsed.flatMap(item => item.assessment.outcomes
|
|
336
|
+
.filter(outcome => outcome.status === 'inconclusive')
|
|
337
|
+
.map(outcome => outcome.cellId)))].sort();
|
|
338
|
+
return {
|
|
339
|
+
series_key: identity.seriesKey,
|
|
340
|
+
repository: identity.repository,
|
|
341
|
+
base_commit: identity.baseCommit,
|
|
342
|
+
head_commit: identity.headCommit,
|
|
343
|
+
requested_runs: runs.length,
|
|
344
|
+
analyzable_runs: parsed.length,
|
|
345
|
+
run_ids: runs.map(run => run.state.runId),
|
|
346
|
+
planner_reproducible: plannerReproducible,
|
|
347
|
+
assessment_reproducible: assessmentReproducible,
|
|
348
|
+
reproducible: assessmentReproducible && inconclusiveCellIds.length === 0,
|
|
349
|
+
plan_variants: planVariants,
|
|
350
|
+
assessment_variants: assessmentVariants,
|
|
351
|
+
plan_drift_cell_ids: planDriftCellIds,
|
|
352
|
+
assessment_drift_cell_ids: assessmentDriftCellIds,
|
|
353
|
+
inconclusive_cell_ids: inconclusiveCellIds,
|
|
354
|
+
unrunnable_runs: unrunnableRuns,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function renderHostedReproducibility(report) {
|
|
358
|
+
const icon = report.reproducible ? chalk.green('✓') : chalk.yellow('!');
|
|
359
|
+
console.log(`${icon} Hosted reproducibility: ${report.reproducible ? 'reproducible' : 'not established'}`);
|
|
360
|
+
console.log(` ${chalk.dim('Runs:')} ${report.analyzable_runs}/${report.requested_runs} analyzable`);
|
|
361
|
+
console.log(` ${chalk.dim('Plan variants:')} ${report.plan_variants.length}`);
|
|
362
|
+
console.log(` ${chalk.dim('Assessment variants:')} ${report.assessment_variants.length}`);
|
|
363
|
+
if (report.plan_drift_cell_ids.length > 0) {
|
|
364
|
+
console.log(` ${chalk.dim('Plan drift:')} ${report.plan_drift_cell_ids.length} stable cell id(s)`);
|
|
365
|
+
}
|
|
366
|
+
if (report.assessment_drift_cell_ids.length > 0) {
|
|
367
|
+
console.log(` ${chalk.dim('Assessment drift:')} ${report.assessment_drift_cell_ids.length} cell(s)`);
|
|
368
|
+
}
|
|
369
|
+
if (report.inconclusive_cell_ids.length > 0) {
|
|
370
|
+
console.log(` ${chalk.dim('Inconclusive:')} ${report.inconclusive_cell_ids.length} cell(s)`);
|
|
371
|
+
}
|
|
372
|
+
if (report.unrunnable_runs.length > 0) {
|
|
373
|
+
console.log(` ${chalk.dim('Unrunnable:')} ${report.unrunnable_runs.length} run(s)`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
export async function verifyHostedReproducibilityCommand(repositoryArg, options) {
|
|
377
|
+
const repository = parseHostedRepository(repositoryArg);
|
|
378
|
+
const requestedRuns = boundedInteger(options.runs, DEFAULT_RUNS, '--runs', 2, MAX_RUNS);
|
|
379
|
+
const seriesKey = requireSeriesKey(options.seriesKey);
|
|
380
|
+
const auth = await resolveAuthContext({
|
|
381
|
+
preferredLogin: options.account,
|
|
382
|
+
owner: repository.owner,
|
|
383
|
+
repo: repository.repository,
|
|
384
|
+
});
|
|
385
|
+
console.error(chalk.dim(`Hosted reproducibility series: ${seriesKey}`));
|
|
386
|
+
const runs = [];
|
|
387
|
+
for (let index = 0; index < requestedRuns; index += 1) {
|
|
388
|
+
if (!options.json) {
|
|
389
|
+
console.error(chalk.dim(`Starting hosted reproducibility run ${index + 1}/${requestedRuns}…`));
|
|
390
|
+
}
|
|
391
|
+
const started = await startHostedVerification(repositoryArg, {
|
|
392
|
+
base: options.base,
|
|
393
|
+
head: options.head,
|
|
394
|
+
idempotencyKey: hostedReproducibilityRunKey(seriesKey, repository.fullName, options.base, options.head, index + 1),
|
|
395
|
+
wait: false,
|
|
396
|
+
}, auth.token);
|
|
397
|
+
let state = started.state;
|
|
398
|
+
let timedOut = false;
|
|
399
|
+
if (!TERMINAL_STATUSES.has(state.status)) {
|
|
400
|
+
const waited = await waitForHostedVerification(state.runId, auth.token, hostedPollingOptions(options.interval, options.timeout, current => {
|
|
401
|
+
if (!options.json) {
|
|
402
|
+
console.error(chalk.dim(`Run ${index + 1}/${requestedRuns}: ${current.status}…`));
|
|
403
|
+
}
|
|
404
|
+
}));
|
|
405
|
+
state = waited.state;
|
|
406
|
+
timedOut = waited.timedOut;
|
|
407
|
+
}
|
|
408
|
+
runs.push({ state, timedOut });
|
|
409
|
+
}
|
|
410
|
+
const report = analyzeHostedReproducibility(runs, {
|
|
411
|
+
seriesKey,
|
|
412
|
+
repository: repository.fullName,
|
|
413
|
+
baseCommit: options.base,
|
|
414
|
+
headCommit: options.head,
|
|
415
|
+
});
|
|
416
|
+
if (options.json) {
|
|
417
|
+
process.stdout.write(`${JSON.stringify(withSchema('cloud-verifier', {
|
|
418
|
+
operation: 'reproducibility',
|
|
419
|
+
...report,
|
|
420
|
+
}), null, 2)}\n`);
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
renderHostedReproducibility(report);
|
|
424
|
+
}
|
|
425
|
+
if (!report.reproducible)
|
|
426
|
+
process.exitCode = 2;
|
|
427
|
+
}
|
|
@@ -15,7 +15,7 @@ export interface HostedStatusOptions {
|
|
|
15
15
|
timeout?: string;
|
|
16
16
|
json?: boolean;
|
|
17
17
|
}
|
|
18
|
-
interface HostedRunState {
|
|
18
|
+
export interface HostedRunState {
|
|
19
19
|
runId: string;
|
|
20
20
|
status: string;
|
|
21
21
|
repository?: string;
|
|
@@ -26,7 +26,7 @@ interface HostedRunState {
|
|
|
26
26
|
output?: Record<string, unknown>;
|
|
27
27
|
error?: string;
|
|
28
28
|
}
|
|
29
|
-
interface HostedPollOptions {
|
|
29
|
+
export interface HostedPollOptions {
|
|
30
30
|
intervalMs: number;
|
|
31
31
|
timeoutMs: number;
|
|
32
32
|
onStatus?: (state: HostedRunState) => void;
|
|
@@ -42,6 +42,7 @@ export declare function parseHostedRepository(value: string): {
|
|
|
42
42
|
fullName: string;
|
|
43
43
|
};
|
|
44
44
|
export declare function defaultHostedIdempotencyKey(repository: string, baseCommit: string, headCommit: string): string;
|
|
45
|
+
export declare function hostedPollingOptions(interval: string | undefined, timeout: string | undefined, onStatus?: (state: HostedRunState) => void): HostedPollOptions;
|
|
45
46
|
export declare function startHostedVerification(repositoryArg: string, options: HostedStartOptions, token: string): Promise<{
|
|
46
47
|
state: HostedRunState;
|
|
47
48
|
idempotencyKey: string;
|
|
@@ -142,7 +142,7 @@ function parsePositiveNumber(value, fallback, flag, maximum) {
|
|
|
142
142
|
}
|
|
143
143
|
return parsed;
|
|
144
144
|
}
|
|
145
|
-
function
|
|
145
|
+
export function hostedPollingOptions(interval, timeout, onStatus) {
|
|
146
146
|
return {
|
|
147
147
|
intervalMs: parsePositiveNumber(interval, DEFAULT_INTERVAL_SECONDS, '--interval', 300) * 1000,
|
|
148
148
|
timeoutMs: parsePositiveNumber(timeout, DEFAULT_TIMEOUT_MINUTES, '--timeout', 1_440) * 60_000,
|
|
@@ -295,7 +295,7 @@ export async function verifyHostedStartCommand(repositoryArg, options) {
|
|
|
295
295
|
let state = started.state;
|
|
296
296
|
let timedOut;
|
|
297
297
|
if (options.wait !== false && !TERMINAL_STATUSES.has(state.status)) {
|
|
298
|
-
const waited = await waitForHostedVerification(state.runId, auth.token,
|
|
298
|
+
const waited = await waitForHostedVerification(state.runId, auth.token, hostedPollingOptions(options.interval, options.timeout, current => {
|
|
299
299
|
if (!options.json) {
|
|
300
300
|
console.error(chalk.dim(`Hosted verifier: ${current.status}…`));
|
|
301
301
|
}
|
|
@@ -322,7 +322,7 @@ export async function verifyHostedStatusCommand(runId, options) {
|
|
|
322
322
|
let state = await getHostedVerification(runId, auth.token);
|
|
323
323
|
let timedOut;
|
|
324
324
|
if (options.wait && !TERMINAL_STATUSES.has(state.status)) {
|
|
325
|
-
const waited = await waitForHostedVerification(runId, auth.token,
|
|
325
|
+
const waited = await waitForHostedVerification(runId, auth.token, hostedPollingOptions(options.interval, options.timeout, current => {
|
|
326
326
|
if (!options.json) {
|
|
327
327
|
console.error(chalk.dim(`Hosted verifier: ${current.status}…`));
|
|
328
328
|
}
|
package/dist/index.js
CHANGED
|
@@ -256,6 +256,52 @@ Example:
|
|
|
256
256
|
const { verifyHostedStatusCommand } = await import('./commands/verify-hosted.js');
|
|
257
257
|
return runPublicCommand(() => verifyHostedStatusCommand(runId, options), options.json);
|
|
258
258
|
});
|
|
259
|
+
hostedVerify
|
|
260
|
+
.command('reproducibility')
|
|
261
|
+
.description('Repeat an exact hosted verification and report plan or assessment drift')
|
|
262
|
+
.argument('<repository>', 'Exact GitHub owner/repository name')
|
|
263
|
+
.option('--base <sha>', 'Required: exact 40-character lowercase base commit SHA')
|
|
264
|
+
.option('--head <sha>', 'Required: exact 40-character lowercase head commit SHA')
|
|
265
|
+
.option('--runs <n>', 'Number of sequential production runs (default 3; min 2, max 10)')
|
|
266
|
+
.option('--series-key <key>', 'Stable series key for resumable idempotent retries')
|
|
267
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
268
|
+
.option('--interval <seconds>', 'Polling interval for each run (default 5)')
|
|
269
|
+
.option('--timeout <minutes>', 'Maximum wait per run (default 35)')
|
|
270
|
+
.option('--json', 'Versioned machine-readable reproducibility report')
|
|
271
|
+
.addHelpText('after', `
|
|
272
|
+
Every run uses the same repository and exact commit pair with a distinct,
|
|
273
|
+
series-derived idempotency key. The report compares stable risk-cell identities
|
|
274
|
+
and deterministic assessment outcomes; inconclusive cells never count as a
|
|
275
|
+
reproducible verdict.
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
haystack verify hosted reproducibility owner/repo --base <sha> --head <sha> --runs 3
|
|
279
|
+
`)
|
|
280
|
+
.action(async (repository, _options, cmd) => {
|
|
281
|
+
const options = { ...cmd.optsWithGlobals(), ...cmd.opts() };
|
|
282
|
+
const { verifyHostedReproducibilityCommand } = await import('./commands/verify-hosted-reproducibility.js');
|
|
283
|
+
return runPublicCommand(() => verifyHostedReproducibilityCommand(repository, options), options.json);
|
|
284
|
+
});
|
|
285
|
+
hostedVerify
|
|
286
|
+
.command('mcp')
|
|
287
|
+
.description('Run a stdio MCP server for the production verifier lifecycle')
|
|
288
|
+
.option('--account <login>', 'Default saved Haystack account for status and wait tools')
|
|
289
|
+
.addHelpText('after', `
|
|
290
|
+
Tools: verify_start, verify_status, verify_wait.
|
|
291
|
+
No credential, repository source, provider metadata, or sandbox mutation tool
|
|
292
|
+
is exposed through this production server.
|
|
293
|
+
`)
|
|
294
|
+
.action(async (_options, cmd) => {
|
|
295
|
+
const options = { ...cmd.optsWithGlobals(), ...cmd.opts() };
|
|
296
|
+
try {
|
|
297
|
+
const { runHostedVerifyMcpServer } = await import('./commands/verify-hosted-mcp.js');
|
|
298
|
+
await runHostedVerifyMcpServer(options);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
console.error(chalk.red('hosted verify mcp server failed:'), err instanceof Error ? err.message : err);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
259
305
|
verify
|
|
260
306
|
.command('reproducibility')
|
|
261
307
|
.description('Separate frozen-plan drift from executor/judgment nondeterminism')
|