@haystackeditor/cli 0.15.30 → 0.15.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/design-verify.d.ts +2 -0
- package/dist/commands/design-verify.js +27 -1
- package/dist/commands/verify-history.d.ts +14 -0
- package/dist/commands/verify-history.js +152 -0
- package/dist/commands/verify-hosted.js +119 -0
- package/dist/index.js +28 -0
- package/dist/types/verify-history.d.ts +38 -0
- package/dist/types/verify-history.js +1 -0
- package/dist/utils/design-verifier-api.d.ts +8 -0
- package/dist/utils/design-verifier-api.js +23 -0
- package/dist/utils/design-verifier-history.d.ts +46 -0
- package/dist/utils/design-verifier-history.js +79 -0
- package/package.json +1 -1
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import { loadToken } from '../utils/auth.js';
|
|
18
18
|
import { parsePrRef } from '../utils/pr-ref.js';
|
|
19
|
-
import { getDesignVerifierUniverseAction, startDesignVerifierUniverseAction, triggerDesignVerifier, waitForDesignVerifier, waitForDesignVerifierUniverseAction, } from '../utils/design-verifier-api.js';
|
|
19
|
+
import { getDesignVerifierHistory, getDesignVerifierUniverseAction, startDesignVerifierUniverseAction, triggerDesignVerifier, waitForDesignVerifier, waitForDesignVerifierUniverseAction, } from '../utils/design-verifier-api.js';
|
|
20
20
|
import { buildDesignVerifierAgentReport, designVerifierStageLabel, formatDesignVerifierAgentReport, } from '../utils/design-verifier-result.js';
|
|
21
|
+
import { formatDesignVerifierHistory } from '../utils/design-verifier-history.js';
|
|
21
22
|
function emit(json, payload, human) {
|
|
22
23
|
if (json) {
|
|
23
24
|
console.log(JSON.stringify(payload));
|
|
@@ -189,6 +190,31 @@ export async function designVerifyCommand(identifier, options) {
|
|
|
189
190
|
process.exitCode = 1;
|
|
190
191
|
return;
|
|
191
192
|
}
|
|
193
|
+
if (options.history || options.limit !== undefined) {
|
|
194
|
+
try {
|
|
195
|
+
if (!options.history)
|
|
196
|
+
throw new Error('--limit requires --history');
|
|
197
|
+
if (options.run || options.case || options.universe || options.replay || options.execJson ||
|
|
198
|
+
options.cwd || options.commandTimeout || options.action || options.wait === false) {
|
|
199
|
+
throw new Error('--history cannot be combined with run or universe operations');
|
|
200
|
+
}
|
|
201
|
+
const limit = Number(options.limit ?? '20');
|
|
202
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
203
|
+
throw new Error('--limit must be between 1 and 100');
|
|
204
|
+
}
|
|
205
|
+
const loaded = await getDesignVerifierHistory(pr.owner, pr.repo, token, limit);
|
|
206
|
+
if (loaded.status !== 'ok')
|
|
207
|
+
throw new Error(loaded.message);
|
|
208
|
+
emit(options.json, { ...loaded.history }, () => console.log(formatDesignVerifierHistory(loaded.history)));
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
emit(options.json, { ok: false, error: error.message }, () => {
|
|
212
|
+
console.error(chalk.red(error.message));
|
|
213
|
+
});
|
|
214
|
+
process.exitCode = 2;
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
192
218
|
const pollIntervalSeconds = Number(options.pollInterval || '3');
|
|
193
219
|
const timeoutSeconds = Number(options.timeout || '7200');
|
|
194
220
|
if (!(Number.isFinite(pollIntervalSeconds) && pollIntervalSeconds >= 1 && pollIntervalSeconds <= 60)) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VerifyHistoryReport } from '../types/verify-history.js';
|
|
2
|
+
export interface VerifyHistoryOptions {
|
|
3
|
+
limit?: string;
|
|
4
|
+
account?: string;
|
|
5
|
+
json?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** Reject contradictory or mismatched data before printing health or findings. */
|
|
8
|
+
export declare function parseVerifyHistoryReport(value: unknown, expected: {
|
|
9
|
+
owner: string;
|
|
10
|
+
name: string;
|
|
11
|
+
limit: number;
|
|
12
|
+
}): VerifyHistoryReport;
|
|
13
|
+
export declare function formatVerifyHistory(report: VerifyHistoryReport): string;
|
|
14
|
+
export declare function verifyHistoryCommand(repository: string, options: VerifyHistoryOptions): Promise<void>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
2
|
+
import { haystackJson, HaystackApiError } from '../utils/haystack-api.js';
|
|
3
|
+
const HEALTH = ['healthy', 'degraded', 'infrastructure-error', 'not-executed', 'unknown'];
|
|
4
|
+
const LIFECYCLES = ['complete', 'failed', 'cancelled', 'budget-stopped', 'account-action'];
|
|
5
|
+
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
6
|
+
const COMMIT = /^[0-9a-f]{40}$/;
|
|
7
|
+
const REPO_COMPONENT = /^(?!\.{1,2}$)[A-Za-z0-9_.-]{1,100}$/;
|
|
8
|
+
function record(value) {
|
|
9
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function count(value) {
|
|
12
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
13
|
+
}
|
|
14
|
+
function timestamp(value) {
|
|
15
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
16
|
+
}
|
|
17
|
+
/** Reject contradictory or mismatched data before printing health or findings. */
|
|
18
|
+
export function parseVerifyHistoryReport(value, expected) {
|
|
19
|
+
const invalid = () => {
|
|
20
|
+
throw new Error('The hosted verifier returned invalid or mismatched history. No reliability summary was accepted.');
|
|
21
|
+
};
|
|
22
|
+
if (!record(value) || value.version !== 'dv-verify-history-v1'
|
|
23
|
+
|| value.provider !== 'haystack' || value.scope !== 'user'
|
|
24
|
+
|| !record(value.repository)
|
|
25
|
+
|| typeof value.repository.owner !== 'string' || typeof value.repository.name !== 'string'
|
|
26
|
+
|| value.repository.owner !== expected.owner
|
|
27
|
+
|| value.repository.name !== expected.name
|
|
28
|
+
|| typeof value.repository.repositoryId !== 'string'
|
|
29
|
+
|| !/^github\.com:[1-9]\d*$/.test(value.repository.repositoryId)
|
|
30
|
+
|| value.limit !== expected.limit || typeof value.hasMore !== 'boolean'
|
|
31
|
+
|| !Array.isArray(value.runs) || value.runs.length > expected.limit
|
|
32
|
+
|| (value.hasMore && value.runs.length !== expected.limit)
|
|
33
|
+
|| !record(value.summary) || !record(value.summary.counts))
|
|
34
|
+
return invalid();
|
|
35
|
+
const counts = {
|
|
36
|
+
healthy: 0, degraded: 0, 'infrastructure-error': 0, 'not-executed': 0, unknown: 0,
|
|
37
|
+
};
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
let usable = 0;
|
|
40
|
+
let previousCompletedAt = Infinity;
|
|
41
|
+
for (const run of value.runs) {
|
|
42
|
+
if (!record(run) || typeof run.runId !== 'string' || !RUN_ID.test(run.runId)
|
|
43
|
+
|| seen.has(run.runId)
|
|
44
|
+
|| typeof run.baseCommit !== 'string' || !COMMIT.test(run.baseCommit)
|
|
45
|
+
|| typeof run.headCommit !== 'string' || !COMMIT.test(run.headCommit)
|
|
46
|
+
|| !timestamp(run.createdAt) || !timestamp(run.completedAt)
|
|
47
|
+
|| Date.parse(run.completedAt) < Date.parse(run.createdAt)
|
|
48
|
+
|| Date.parse(run.completedAt) > previousCompletedAt
|
|
49
|
+
|| (run.status !== 'complete' && run.status !== 'errored')
|
|
50
|
+
|| (run.lifecycle !== null && !LIFECYCLES.includes(run.lifecycle))
|
|
51
|
+
|| !HEALTH.includes(run.health)
|
|
52
|
+
|| (run.regressionCount !== null && !count(run.regressionCount))
|
|
53
|
+
|| (run.executedCaseCount !== null && !count(run.executedCaseCount))
|
|
54
|
+
|| !Array.isArray(run.degradationCodes)
|
|
55
|
+
|| run.degradationCodes.some(code => typeof code !== 'string' || code.length === 0)
|
|
56
|
+
|| new Set(run.degradationCodes).size !== run.degradationCodes.length
|
|
57
|
+
|| (run.supersededBy !== null && (typeof run.supersededBy !== 'string'
|
|
58
|
+
|| !RUN_ID.test(run.supersededBy) || run.supersededBy === run.runId)))
|
|
59
|
+
return invalid();
|
|
60
|
+
if (run.health === 'unknown') {
|
|
61
|
+
if (run.status !== 'errored' || run.lifecycle !== null || run.regressionCount !== null
|
|
62
|
+
|| run.executedCaseCount !== null || run.degradationCodes.length !== 0)
|
|
63
|
+
return invalid();
|
|
64
|
+
}
|
|
65
|
+
else if (run.lifecycle === null || run.regressionCount === null || run.executedCaseCount === null) {
|
|
66
|
+
return invalid();
|
|
67
|
+
}
|
|
68
|
+
else if ((run.lifecycle === 'complete') !== (run.status === 'complete')) {
|
|
69
|
+
return invalid();
|
|
70
|
+
}
|
|
71
|
+
if (run.health === 'healthy' && (run.lifecycle !== 'complete'
|
|
72
|
+
|| run.executedCaseCount === 0 || run.degradationCodes.length !== 0))
|
|
73
|
+
return invalid();
|
|
74
|
+
if (run.health === 'not-executed' && (run.executedCaseCount !== 0 || run.regressionCount !== 0))
|
|
75
|
+
return invalid();
|
|
76
|
+
if (run.executedCaseCount === 0 && run.regressionCount !== null && run.regressionCount !== 0)
|
|
77
|
+
return invalid();
|
|
78
|
+
seen.add(run.runId);
|
|
79
|
+
previousCompletedAt = Date.parse(run.completedAt);
|
|
80
|
+
counts[run.health]++;
|
|
81
|
+
if ((run.health === 'healthy' || run.health === 'degraded')
|
|
82
|
+
&& typeof run.executedCaseCount === 'number' && run.executedCaseCount > 0)
|
|
83
|
+
usable++;
|
|
84
|
+
}
|
|
85
|
+
const usableRate = value.runs.length === 0 ? null : usable / value.runs.length;
|
|
86
|
+
const summaryCounts = value.summary.counts;
|
|
87
|
+
if (value.summary.total !== value.runs.length || value.summary.usableRate !== usableRate
|
|
88
|
+
|| HEALTH.some(health => summaryCounts[health] !== counts[health]))
|
|
89
|
+
return invalid();
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
function terminalSafe(value) {
|
|
93
|
+
return Array.from(value).filter(character => {
|
|
94
|
+
const point = character.codePointAt(0);
|
|
95
|
+
return point >= 32 && !(point >= 127 && point <= 159);
|
|
96
|
+
}).join('');
|
|
97
|
+
}
|
|
98
|
+
export function formatVerifyHistory(report) {
|
|
99
|
+
const { summary } = report;
|
|
100
|
+
const lines = [
|
|
101
|
+
`Your verification history for ${report.repository.owner}/${report.repository.name}`,
|
|
102
|
+
`Latest ${summary.total} finished runs for the selected account (limit ${report.limit}).`,
|
|
103
|
+
];
|
|
104
|
+
if (summary.total === 0) {
|
|
105
|
+
lines.push('No finished hosted fleet runs found for this account and repository.');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
const usable = report.runs.filter(run => (run.health === 'healthy' || run.health === 'degraded')
|
|
109
|
+
&& run.executedCaseCount !== null && run.executedCaseCount > 0).length;
|
|
110
|
+
lines.push(`Usable evidence: ${Math.round(summary.usableRate * 100)}% of these runs (${usable}/${summary.total}).`, `Healthy: ${summary.counts.healthy}; partially degraded: ${summary.counts.degraded}; infrastructure error: ${summary.counts['infrastructure-error']}; not executed: ${summary.counts['not-executed']}; unknown: ${summary.counts.unknown}.`, 'Usable means healthy or partially degraded with at least one executed check.', 'This measures execution reliability. Bugs found are listed separately; they do not lower this rate.', '');
|
|
111
|
+
for (const run of report.runs) {
|
|
112
|
+
const health = run.health === 'degraded' ? 'partially degraded' : run.health;
|
|
113
|
+
const checks = run.executedCaseCount === null ? 'unknown checks executed' : `${run.executedCaseCount} checks executed`;
|
|
114
|
+
const regressions = run.regressionCount === null ? 'bugs found unknown'
|
|
115
|
+
: `${run.regressionCount} ${run.regressionCount === 1 ? 'bug' : 'bugs'} found`;
|
|
116
|
+
lines.push(`${terminalSafe(run.completedAt)} ${health} ${checks} ${regressions}`, ` ${run.runId} ${run.baseCommit.slice(0, 12)} -> ${run.headCommit.slice(0, 12)}`);
|
|
117
|
+
if (run.lifecycle && run.lifecycle !== 'complete')
|
|
118
|
+
lines.push(` Run outcome: ${run.lifecycle}`);
|
|
119
|
+
if (run.degradationCodes.length)
|
|
120
|
+
lines.push(` Limitations: ${run.degradationCodes.map(terminalSafe).join(', ')}`);
|
|
121
|
+
if (run.supersededBy)
|
|
122
|
+
lines.push(` Replaced by: ${run.supersededBy}`);
|
|
123
|
+
}
|
|
124
|
+
if (report.hasMore)
|
|
125
|
+
lines.push('', 'Older finished runs exist. Increase --limit to show up to 100.');
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
export async function verifyHistoryCommand(repository, options) {
|
|
129
|
+
const parts = repository.split('/');
|
|
130
|
+
if (parts.length !== 2 || !parts.every(part => REPO_COMPONENT.test(part))) {
|
|
131
|
+
throw new Error('Use an exact GitHub owner/repository name for verification history.');
|
|
132
|
+
}
|
|
133
|
+
const limitText = options.limit ?? '20';
|
|
134
|
+
if (!/^[1-9]\d*$/.test(limitText) || Number(limitText) > 100) {
|
|
135
|
+
throw new Error('--limit must be a whole number from 1 to 100.');
|
|
136
|
+
}
|
|
137
|
+
const [owner, name] = parts;
|
|
138
|
+
const limit = Number(limitText);
|
|
139
|
+
const auth = await resolveAuthContext({ preferredLogin: options.account, owner, repo: name });
|
|
140
|
+
let response;
|
|
141
|
+
try {
|
|
142
|
+
response = await haystackJson(`/api/agent/cloud-verifier/history/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?limit=${limit}`, auth.token, { method: 'GET' });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof HaystackApiError && error.status === 404) {
|
|
146
|
+
throw new Error('Verification history was not found for this repository and account. Check repository access and use --account <login> if needed.');
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
const report = parseVerifyHistoryReport(response, { owner, name, limit });
|
|
151
|
+
process.stdout.write((options.json ? JSON.stringify(report, null, 2) : formatVerifyHistory(report)) + '\n');
|
|
152
|
+
}
|
|
@@ -132,6 +132,9 @@ export function parseHostedRunState(value, expectedRunId) {
|
|
|
132
132
|
requireString(finding, 'caseId');
|
|
133
133
|
requireString(finding, 'judgment');
|
|
134
134
|
}
|
|
135
|
+
if (fleetResult.savedChecks !== undefined) {
|
|
136
|
+
parseHostedSavedChecks(fleetResult.savedChecks, runId, headCommit);
|
|
137
|
+
}
|
|
135
138
|
requireString(provenance, 'intent', /^(user|inferred)$/);
|
|
136
139
|
if (status === 'complete' && fleetResult.lifecycle !== 'complete') {
|
|
137
140
|
throw new Error('Hosted fleet lifecycle disagrees with its completed job.');
|
|
@@ -216,6 +219,92 @@ export function parseHostedRepository(value) {
|
|
|
216
219
|
fullName: value,
|
|
217
220
|
};
|
|
218
221
|
}
|
|
222
|
+
/** The service validates the shared full contract. Check every additional
|
|
223
|
+
* field used by this published CLI before displaying or accepting a clean run. */
|
|
224
|
+
function parseHostedSavedChecks(value, runId, headSha) {
|
|
225
|
+
const saved = requireRecord(value, 'Hosted saved checks');
|
|
226
|
+
if (saved.version !== 'dv-fleet-saved-check-result-v1'
|
|
227
|
+
|| !Array.isArray(saved.descriptors) || !Array.isArray(saved.outcomes)
|
|
228
|
+
|| !Array.isArray(saved.unresolvedFindings) || !Array.isArray(saved.conflicts)) {
|
|
229
|
+
throw new Error('Hosted saved checks have an invalid version or records.');
|
|
230
|
+
}
|
|
231
|
+
const completeness = requireRecord(saved.completeness, 'Hosted saved check completeness');
|
|
232
|
+
requireString(completeness, 'status', /^(complete|incomplete)$/);
|
|
233
|
+
if (completeness.status === 'incomplete' && (!Array.isArray(completeness.reasons) || completeness.reasons.length === 0)) {
|
|
234
|
+
throw new Error('Hosted saved check history has no explanation for missing evidence.');
|
|
235
|
+
}
|
|
236
|
+
const ref = (record) => {
|
|
237
|
+
requireString(record, 'checkId', /^[0-9a-f]{64}$/);
|
|
238
|
+
requireString(record, 'planRevisionId', /^[0-9a-f]{64}$/);
|
|
239
|
+
return `${record.checkId}:${record.planRevisionId}`;
|
|
240
|
+
};
|
|
241
|
+
const descriptors = new Map();
|
|
242
|
+
for (const value of saved.descriptors) {
|
|
243
|
+
const descriptor = requireRecord(value, 'Hosted saved check descriptor');
|
|
244
|
+
const key = ref(descriptor);
|
|
245
|
+
if (descriptor.version !== 'dv-fleet-saved-check-v1' || descriptors.has(key)) {
|
|
246
|
+
throw new Error('Hosted saved check descriptor identity is invalid or duplicated.');
|
|
247
|
+
}
|
|
248
|
+
requireString(descriptor, 'captureStatus', /^(captured|unavailable)$/);
|
|
249
|
+
descriptors.set(key, descriptor);
|
|
250
|
+
}
|
|
251
|
+
const outcomes = new Map();
|
|
252
|
+
for (const value of saved.outcomes) {
|
|
253
|
+
const outcome = requireRecord(value, 'Hosted saved check outcome');
|
|
254
|
+
const key = ref(outcome);
|
|
255
|
+
requireString(outcome, 'status', /^(passed|reproduced|blocked|inconclusive)$/);
|
|
256
|
+
if (outcome.version !== 'dv-fleet-saved-check-outcome-v1'
|
|
257
|
+
|| (!descriptors.has(key) && completeness.status !== 'incomplete') || outcomes.has(key)
|
|
258
|
+
|| outcome.publicRunId !== runId || outcome.headSha !== headSha
|
|
259
|
+
|| (outcome.currentCaseId !== null && typeof outcome.currentCaseId !== 'string')
|
|
260
|
+
|| (outcome.blockedCode !== null && typeof outcome.blockedCode !== 'string')) {
|
|
261
|
+
throw new Error('Hosted saved check outcome does not match its current run or original check.');
|
|
262
|
+
}
|
|
263
|
+
if ((outcome.status === 'blocked') !== (outcome.blockedCode !== null)
|
|
264
|
+
|| (outcome.status === 'passed' && (descriptors.get(key)?.captureStatus !== 'captured'
|
|
265
|
+
|| outcome.currentCaseId === null || !outcome.receipt || completeness.status !== 'complete'))) {
|
|
266
|
+
throw new Error('Hosted saved check outcome claims a pass without original execution evidence.');
|
|
267
|
+
}
|
|
268
|
+
outcomes.set(key, outcome);
|
|
269
|
+
}
|
|
270
|
+
const findings = new Set();
|
|
271
|
+
for (const value of saved.unresolvedFindings) {
|
|
272
|
+
const finding = requireRecord(value, 'Hosted unresolved finding');
|
|
273
|
+
const key = ref(finding);
|
|
274
|
+
requireString(finding, 'findingId');
|
|
275
|
+
const source = requireRecord(finding.firstObserved, 'Hosted original finding evidence');
|
|
276
|
+
requireString(source, 'publicRunId', RUN_ID);
|
|
277
|
+
requireString(source, 'caseId');
|
|
278
|
+
requireString(source, 'headSha', COMMIT_SHA);
|
|
279
|
+
if (finding.version !== 'dv-fleet-unresolved-finding-v1'
|
|
280
|
+
|| (!descriptors.has(key) && completeness.status !== 'incomplete')
|
|
281
|
+
|| findings.has(finding.findingId) || !Array.isArray(source.evidence) || source.evidence.length === 0
|
|
282
|
+
|| outcomes.get(key)?.status === 'passed') {
|
|
283
|
+
throw new Error('Hosted unresolved finding has invalid original evidence or check identity.');
|
|
284
|
+
}
|
|
285
|
+
findings.add(finding.findingId);
|
|
286
|
+
if (finding.lastOutcome !== null) {
|
|
287
|
+
const last = requireRecord(finding.lastOutcome, 'Hosted unresolved finding outcome');
|
|
288
|
+
if (ref(last) !== key || last.status === 'passed')
|
|
289
|
+
throw new Error('Hosted unresolved finding lost its exact original check.');
|
|
290
|
+
requireString(last, 'status', /^(reproduced|blocked|inconclusive)$/);
|
|
291
|
+
if (last.blockedCode !== null)
|
|
292
|
+
requireString(last, 'blockedCode');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const value of saved.conflicts) {
|
|
296
|
+
const conflict = requireRecord(value, 'Hosted saved check conflict');
|
|
297
|
+
requireString(conflict, 'checkId', /^[0-9a-f]{64}$/);
|
|
298
|
+
if (!Array.isArray(conflict.planRevisionIds) || conflict.planRevisionIds.length < 2
|
|
299
|
+
|| conflict.planRevisionIds.some(id => typeof id !== 'string' || !/^[0-9a-f]{64}$/.test(id))) {
|
|
300
|
+
throw new Error('Hosted saved check conflict is invalid.');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return saved;
|
|
304
|
+
}
|
|
305
|
+
function savedCheckDisplay(value) {
|
|
306
|
+
return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
|
|
307
|
+
}
|
|
219
308
|
function requireCommit(value, flag) {
|
|
220
309
|
if (typeof value !== 'string' || !COMMIT_SHA.test(value)) {
|
|
221
310
|
throw new Error(`${flag} must be an exact 40-character lowercase commit SHA.`);
|
|
@@ -499,6 +588,26 @@ function printHostedState(operation, state, options) {
|
|
|
499
588
|
const finding = requireRecord(value, 'Hosted fleet finding');
|
|
500
589
|
console.log(` ${chalk.dim('Finding:')} ${finding.findingId} / ${finding.caseId}: ${finding.judgment}`);
|
|
501
590
|
}
|
|
591
|
+
if (result.savedChecks !== undefined) {
|
|
592
|
+
const saved = requireRecord(result.savedChecks, 'Hosted saved checks');
|
|
593
|
+
const completeness = requireRecord(saved.completeness, 'Hosted saved check completeness');
|
|
594
|
+
const unresolved = saved.unresolvedFindings;
|
|
595
|
+
console.log(` ${chalk.dim('Saved checks:')} ${completeness.status}; ${saved.conflicts.length} conflicts; ${unresolved.length} unresolved bugs`);
|
|
596
|
+
for (const value of unresolved) {
|
|
597
|
+
const finding = requireRecord(value, 'Hosted unresolved finding');
|
|
598
|
+
const source = requireRecord(finding.firstObserved, 'Hosted original finding evidence');
|
|
599
|
+
const last = finding.lastOutcome === null ? null : requireRecord(finding.lastOutcome, 'Hosted unresolved finding outcome');
|
|
600
|
+
console.log(` ${chalk.yellow('Unresolved:')} ${savedCheckDisplay(finding.findingId)} / ${savedCheckDisplay(source.caseId)}`);
|
|
601
|
+
console.log(` First reproduced at ${String(source.headSha).slice(0, 12)}; latest check: ${savedCheckDisplay(last?.status ?? 'not executed')}${last?.blockedCode ? ` (${savedCheckDisplay(last.blockedCode)})` : ''}`);
|
|
602
|
+
}
|
|
603
|
+
if (unresolved.length)
|
|
604
|
+
console.log(' Earlier bug evidence remains open until the exact saved check passes.');
|
|
605
|
+
if (completeness.status === 'incomplete')
|
|
606
|
+
console.log(' Saved check history is incomplete; this run cannot confirm earlier bugs are fixed.');
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
console.log(` ${chalk.dim('Saved checks:')} unavailable for this run`);
|
|
610
|
+
}
|
|
502
611
|
}
|
|
503
612
|
if (state.error)
|
|
504
613
|
console.log(` ${chalk.dim('Error:')} ${state.error}`);
|
|
@@ -516,6 +625,16 @@ function markNonSuccess(state, timedOut = false) {
|
|
|
516
625
|
process.exitCode = 2;
|
|
517
626
|
return;
|
|
518
627
|
}
|
|
628
|
+
if (result.savedChecks !== undefined) {
|
|
629
|
+
const saved = requireRecord(result.savedChecks, 'Hosted saved checks');
|
|
630
|
+
const completeness = requireRecord(saved.completeness, 'Hosted saved check completeness');
|
|
631
|
+
if (completeness.status !== 'complete' || saved.conflicts.length > 0) {
|
|
632
|
+
process.exitCode = 2;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (saved.unresolvedFindings.length > 0)
|
|
636
|
+
process.exitCode = 1;
|
|
637
|
+
}
|
|
519
638
|
if (result.findings.some(value => requireRecord(value, 'Hosted fleet finding').judgment === 'regression')) {
|
|
520
639
|
process.exitCode = 1;
|
|
521
640
|
}
|
package/dist/index.js
CHANGED
|
@@ -202,6 +202,7 @@ For launch specimens, run the local control plane from the Haystack repository:
|
|
|
202
202
|
|
|
203
203
|
Examples:
|
|
204
204
|
haystack verify --repo owner/repo --base main --head feature --json
|
|
205
|
+
haystack verify history owner/repo --limit 20
|
|
205
206
|
haystack verify hosted start owner/repo --base <sha> --head <sha> --intent-file <path>
|
|
206
207
|
haystack verify posthog-js-478 # start a run, then:
|
|
207
208
|
haystack verify reproducibility posthog-js-478 --runs 3
|
|
@@ -220,6 +221,26 @@ Examples:
|
|
|
220
221
|
`)
|
|
221
222
|
.action((specimen, options) => runPublicCommand(() => verifyCommand(specimen, options), options.json));
|
|
222
223
|
const verifyRunArg = 'Run id, chapter id (newest run of that chapter), or "latest" (default)';
|
|
224
|
+
verify
|
|
225
|
+
.command('history')
|
|
226
|
+
.description('Read your recent finished hosted verification runs without starting a run')
|
|
227
|
+
.argument('<repository>', 'Exact GitHub owner/repository name')
|
|
228
|
+
.option('--limit <runs>', 'Number of recent finished runs to show, from 1 to 100 (default 20)')
|
|
229
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
230
|
+
.option('--json', 'Versioned history with execution health and separate bug counts')
|
|
231
|
+
.addHelpText('after', `
|
|
232
|
+
History covers your hosted fleet runs for this repository. Reliability counts
|
|
233
|
+
only these returned runs; it does not measure whether the code is bug-free.
|
|
234
|
+
|
|
235
|
+
Examples:
|
|
236
|
+
haystack verify history owner/repo
|
|
237
|
+
haystack verify history owner/repo --limit 50 --account my-login --json
|
|
238
|
+
`)
|
|
239
|
+
.action(async (repository, _options, cmd) => {
|
|
240
|
+
const options = { ...cmd.optsWithGlobals(), ...cmd.opts() };
|
|
241
|
+
const { verifyHistoryCommand } = await import('./commands/verify-history.js');
|
|
242
|
+
return runPublicCommand(() => verifyHistoryCommand(repository, options), options.json);
|
|
243
|
+
});
|
|
223
244
|
verify
|
|
224
245
|
.command('precompute')
|
|
225
246
|
.description('Precompute reusable verification inputs for the current working tree')
|
|
@@ -901,6 +922,8 @@ program
|
|
|
901
922
|
// keeps retired auto-fix/verification wording out of the CLI surface).
|
|
902
923
|
.description('Run a blind design-by-execution review of a PR')
|
|
903
924
|
.option('--json', 'Machine-readable output')
|
|
925
|
+
.option('--history', 'Show recent execution reliability for this repository without starting a run')
|
|
926
|
+
.option('--limit <attempts>', 'Maximum recorded terminal attempts to show with --history (1–100, default 20)')
|
|
904
927
|
.option('--no-wait', 'Return after the run is admitted instead of waiting for its verdict')
|
|
905
928
|
.option('--run <run-id>', 'Resume and wait for an existing exact run id')
|
|
906
929
|
.option('--poll-interval <seconds>', 'Status polling interval', '3')
|
|
@@ -921,6 +944,11 @@ outside the registered intent.
|
|
|
921
944
|
The command waits for a behavioral verdict by default. Use --no-wait to
|
|
922
945
|
return the run id immediately, or --run <run-id> to resume waiting.
|
|
923
946
|
|
|
947
|
+
Read recent execution reliability across this repository's PRs:
|
|
948
|
+
haystack design-verify owner/repo#123 --history --limit 20
|
|
949
|
+
History covers recorded terminal attempts, including partial or failed runs;
|
|
950
|
+
it does not measure bug-detection accuracy or whether changes are bug-free.
|
|
951
|
+
|
|
924
952
|
After a completed run, inspect or replay one retained case universe without
|
|
925
953
|
receiving provider credentials:
|
|
926
954
|
haystack design-verify owner/repo#123 --run RUN --case case-00001 --universe before --replay
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Read-only history from the active hosted verifier, scoped to the caller's
|
|
2
|
+
* stable GitHub user and repository IDs. Counts describe the returned window,
|
|
3
|
+
* never bug correctness. Missing results remain unknown. */
|
|
4
|
+
export type VerifyHistoryHealth = 'healthy' | 'degraded' | 'infrastructure-error' | 'not-executed' | 'unknown';
|
|
5
|
+
export interface VerifyHistoryRun {
|
|
6
|
+
runId: string;
|
|
7
|
+
baseCommit: string;
|
|
8
|
+
headCommit: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
completedAt: string;
|
|
11
|
+
status: 'complete' | 'errored';
|
|
12
|
+
lifecycle: 'complete' | 'failed' | 'cancelled' | 'budget-stopped' | 'account-action' | null;
|
|
13
|
+
health: VerifyHistoryHealth;
|
|
14
|
+
regressionCount: number | null;
|
|
15
|
+
executedCaseCount: number | null;
|
|
16
|
+
degradationCodes: string[];
|
|
17
|
+
supersededBy: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface VerifyHistoryReport {
|
|
20
|
+
version: 'dv-verify-history-v1';
|
|
21
|
+
provider: 'haystack';
|
|
22
|
+
scope: 'user';
|
|
23
|
+
repository: {
|
|
24
|
+
owner: string;
|
|
25
|
+
name: string;
|
|
26
|
+
repositoryId: string;
|
|
27
|
+
};
|
|
28
|
+
limit: number;
|
|
29
|
+
hasMore: boolean;
|
|
30
|
+
runs: VerifyHistoryRun[];
|
|
31
|
+
summary: {
|
|
32
|
+
total: number;
|
|
33
|
+
counts: Record<VerifyHistoryHealth, number>;
|
|
34
|
+
/** Healthy/degraded runs with executedCaseCount > 0, divided by total;
|
|
35
|
+
* null for an empty window. */
|
|
36
|
+
usableRate: number | null;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* the server-side shared secret. This client just POSTs and interprets the
|
|
8
8
|
* response.
|
|
9
9
|
*/
|
|
10
|
+
import { type DesignVerifierHistoryReport } from './design-verifier-history.js';
|
|
10
11
|
export type DesignVerifierTriggerResult = {
|
|
11
12
|
status: 'started' | 'queued' | 'duplicate';
|
|
12
13
|
runId: string;
|
|
@@ -184,6 +185,13 @@ export declare function waitForDesignVerifierUniverseAction(owner: string, repo:
|
|
|
184
185
|
}>;
|
|
185
186
|
export declare function triggerDesignVerifier(owner: string, repo: string, prNumber: number, token: string): Promise<DesignVerifierTriggerResult>;
|
|
186
187
|
export declare function getDesignVerifierStatus(owner: string, repo: string, prNumber: number, runId: string, token: string): Promise<DesignVerifierStatusFetchResult>;
|
|
188
|
+
export declare function getDesignVerifierHistory(owner: string, repo: string, token: string, limit?: number): Promise<{
|
|
189
|
+
status: 'ok';
|
|
190
|
+
history: DesignVerifierHistoryReport;
|
|
191
|
+
} | {
|
|
192
|
+
status: 'denied' | 'unavailable' | 'error';
|
|
193
|
+
message: string;
|
|
194
|
+
}>;
|
|
187
195
|
export declare function waitForDesignVerifier(owner: string, repo: string, prNumber: number, runId: string, token: string, options?: {
|
|
188
196
|
intervalMs?: number;
|
|
189
197
|
timeoutMs?: number;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* response.
|
|
9
9
|
*/
|
|
10
10
|
import { haystackApiUrl } from './haystack-api.js';
|
|
11
|
+
import { isDesignVerifierHistoryReport } from './design-verifier-history.js';
|
|
11
12
|
function isDuration(value) {
|
|
12
13
|
return value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
|
|
13
14
|
}
|
|
@@ -239,6 +240,28 @@ export async function getDesignVerifierStatus(owner, repo, prNumber, runId, toke
|
|
|
239
240
|
return { status: 'unavailable', message };
|
|
240
241
|
return { status: 'error', message };
|
|
241
242
|
}
|
|
243
|
+
export async function getDesignVerifierHistory(owner, repo, token, limit = 20) {
|
|
244
|
+
const url = haystackApiUrl(`/api/design-verifier/history/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}?limit=${limit}`);
|
|
245
|
+
try {
|
|
246
|
+
const response = await fetch(url, { method: 'GET', headers: apiHeaders(token) });
|
|
247
|
+
const data = await response.json();
|
|
248
|
+
if (response.ok) {
|
|
249
|
+
if (!isDesignVerifierHistoryReport(data, { owner, repo, limit })) {
|
|
250
|
+
return { status: 'error', message: 'History endpoint returned an invalid or mismatched result.' };
|
|
251
|
+
}
|
|
252
|
+
return { status: 'ok', history: data };
|
|
253
|
+
}
|
|
254
|
+
const message = data?.message || data?.error || `HTTP ${response.status}`;
|
|
255
|
+
if (response.status === 401 || response.status === 403)
|
|
256
|
+
return { status: 'denied', message };
|
|
257
|
+
if ([404, 502, 503].includes(response.status))
|
|
258
|
+
return { status: 'unavailable', message };
|
|
259
|
+
return { status: 'error', message };
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
return { status: 'error', message: error.message };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
242
265
|
export async function waitForDesignVerifier(owner, repo, prNumber, runId, token, options = {}) {
|
|
243
266
|
const intervalMs = options.intervalMs ?? 3000;
|
|
244
267
|
const timeoutMs = options.timeoutMs ?? 2 * 60 * 60 * 1000;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** FIXED CONTRACT: historical execution health, never a score for bug-detection accuracy. */
|
|
2
|
+
export type DesignVerifierHistoryOutcome = 'healthy' | 'degraded' | 'invalid' | 'failed' | 'unknown';
|
|
3
|
+
export interface DesignVerifierHistoryRun {
|
|
4
|
+
runId: string;
|
|
5
|
+
pr: number;
|
|
6
|
+
baseSha: string | null;
|
|
7
|
+
headSha: string | null;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
finishedAt: string | null;
|
|
10
|
+
status: string;
|
|
11
|
+
outcome: DesignVerifierHistoryOutcome;
|
|
12
|
+
admitted: number | null;
|
|
13
|
+
losses: Array<{
|
|
14
|
+
code: string;
|
|
15
|
+
owner: string;
|
|
16
|
+
impact: 'degraded' | 'invalid';
|
|
17
|
+
count: number;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
export interface DesignVerifierHistoryReport {
|
|
21
|
+
version: 'dv-cli-history-v1';
|
|
22
|
+
owner: string;
|
|
23
|
+
repo: string;
|
|
24
|
+
retentionDays: number;
|
|
25
|
+
limit: number;
|
|
26
|
+
runs: DesignVerifierHistoryRun[];
|
|
27
|
+
summary: {
|
|
28
|
+
total: number;
|
|
29
|
+
healthy: number;
|
|
30
|
+
degraded: number;
|
|
31
|
+
invalid: number;
|
|
32
|
+
failed: number;
|
|
33
|
+
unknown: number;
|
|
34
|
+
usableRate: number | null;
|
|
35
|
+
topBlockers: Array<{
|
|
36
|
+
code: string;
|
|
37
|
+
affectedRuns: number;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export declare function isDesignVerifierHistoryReport(value: unknown, expected: {
|
|
42
|
+
owner: string;
|
|
43
|
+
repo: string;
|
|
44
|
+
limit: number;
|
|
45
|
+
}): value is DesignVerifierHistoryReport;
|
|
46
|
+
export declare function formatDesignVerifierHistory(report: DesignVerifierHistoryReport): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const outcomes = ['healthy', 'degraded', 'invalid', 'failed', 'unknown'];
|
|
2
|
+
export function isDesignVerifierHistoryReport(value, expected) {
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4
|
+
return false;
|
|
5
|
+
const report = value;
|
|
6
|
+
if (report.version !== 'dv-cli-history-v1' || report.owner !== expected.owner ||
|
|
7
|
+
report.repo !== expected.repo || report.limit !== expected.limit ||
|
|
8
|
+
!Number.isSafeInteger(report.retentionDays) || report.retentionDays <= 0 ||
|
|
9
|
+
!Array.isArray(report.runs) || report.runs.length > report.limit || !report.summary)
|
|
10
|
+
return false;
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const counts = { healthy: 0, degraded: 0, invalid: 0, failed: 0, unknown: 0 };
|
|
13
|
+
const blockers = new Map();
|
|
14
|
+
for (const run of report.runs) {
|
|
15
|
+
if (!run || !Number.isSafeInteger(run.pr) || run.pr < 1 || typeof run.runId !== 'string')
|
|
16
|
+
return false;
|
|
17
|
+
const prefix = `${expected.owner}--${expected.repo}--${run.pr}--`;
|
|
18
|
+
if (!run.runId.startsWith(prefix) ||
|
|
19
|
+
!/^[0-9a-f]{12}(?:--attempt-[1-9]\d*)?$/.test(run.runId.slice(prefix.length)) ||
|
|
20
|
+
seen.has(run.runId) || !outcomes.includes(run.outcome) ||
|
|
21
|
+
!['succeeded', 'failed', 'failed-stale'].includes(run.status) ||
|
|
22
|
+
(run.outcome === 'failed') !== (run.status !== 'succeeded') ||
|
|
23
|
+
typeof run.createdAt !== 'string' || !Number.isFinite(Date.parse(run.createdAt)) ||
|
|
24
|
+
(run.finishedAt !== null && (typeof run.finishedAt !== 'string' || !Number.isFinite(Date.parse(run.finishedAt)))) ||
|
|
25
|
+
(run.baseSha !== null && typeof run.baseSha !== 'string') ||
|
|
26
|
+
(run.headSha !== null && typeof run.headSha !== 'string') ||
|
|
27
|
+
(run.admitted !== null && (!Number.isSafeInteger(run.admitted) || run.admitted < 0)) ||
|
|
28
|
+
!Array.isArray(run.losses) || run.losses.some(loss => !loss || typeof loss.code !== 'string' || !loss.code || typeof loss.owner !== 'string' ||
|
|
29
|
+
!loss.owner || !['degraded', 'invalid'].includes(loss.impact) ||
|
|
30
|
+
!Number.isSafeInteger(loss.count) || loss.count < 1))
|
|
31
|
+
return false;
|
|
32
|
+
if (['healthy', 'degraded'].includes(run.outcome) && (run.admitted === null || run.admitted === 0))
|
|
33
|
+
return false;
|
|
34
|
+
seen.add(run.runId);
|
|
35
|
+
counts[run.outcome]++;
|
|
36
|
+
for (const code of new Set(run.losses.map(loss => loss.code))) {
|
|
37
|
+
blockers.set(code, (blockers.get(code) || 0) + 1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const summary = report.summary;
|
|
41
|
+
const usableRate = report.runs.length ? (counts.healthy + counts.degraded) / report.runs.length : null;
|
|
42
|
+
if (summary.total !== report.runs.length || summary.usableRate !== usableRate ||
|
|
43
|
+
outcomes.some(outcome => summary[outcome] !== counts[outcome]) ||
|
|
44
|
+
!Array.isArray(summary.topBlockers) || summary.topBlockers.length !== blockers.size)
|
|
45
|
+
return false;
|
|
46
|
+
const seenBlockers = new Set();
|
|
47
|
+
return summary.topBlockers.every(blocker => {
|
|
48
|
+
if (!blocker || seenBlockers.has(blocker.code) || blockers.get(blocker.code) !== blocker.affectedRuns)
|
|
49
|
+
return false;
|
|
50
|
+
seenBlockers.add(blocker.code);
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function terminalSafe(value) {
|
|
55
|
+
return Array.from(value).filter(character => {
|
|
56
|
+
const point = character.codePointAt(0);
|
|
57
|
+
return point >= 32 && !(point >= 127 && point <= 159);
|
|
58
|
+
}).join('');
|
|
59
|
+
}
|
|
60
|
+
export function formatDesignVerifierHistory(report) {
|
|
61
|
+
const { summary } = report;
|
|
62
|
+
const lines = [`Execution history for ${terminalSafe(report.owner)}/${terminalSafe(report.repo)}`,
|
|
63
|
+
`Latest ${summary.total} recorded terminal attempts within ${report.retentionDays} days (limit ${report.limit}).`];
|
|
64
|
+
if (summary.total === 0) {
|
|
65
|
+
lines.push('No recorded attempts in this window. History starts when recording is deployed.');
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
lines.push(`Usable evidence: ${Math.round(summary.usableRate * 100)}% of attempts (${summary.healthy + summary.degraded}/${summary.total}).`, `Fully healthy: ${summary.healthy}; partially degraded: ${summary.degraded}; invalid: ${summary.invalid}; failed: ${summary.failed}; unknown: ${summary.unknown}.`, 'Usable includes partial evidence from degraded attempts. This measures execution reliability, not bug-detection accuracy.', '');
|
|
69
|
+
for (const run of report.runs) {
|
|
70
|
+
lines.push(`${run.finishedAt || run.createdAt} PR #${run.pr} ${run.outcome} ${run.admitted ?? '?'} admitted cases`, ` ${run.runId}`);
|
|
71
|
+
}
|
|
72
|
+
if (summary.topBlockers.length) {
|
|
73
|
+
lines.push('', 'Most frequent blockers (attempts affected):');
|
|
74
|
+
for (const blocker of summary.topBlockers.slice(0, 5)) {
|
|
75
|
+
lines.push(` ${terminalSafe(blocker.code)}: ${blocker.affectedRuns}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|