@haystackeditor/cli 0.26.0 → 0.27.0
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 +0 -8
- package/dist/capture/app-config.js +5 -5
- package/dist/commands/capture-brief.js +4 -3
- package/dist/commands/capture-contract.js +4 -4
- package/dist/commands/crawl-report.js +196 -0
- package/dist/commands/db-profile-upload.js +3 -3
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/init-telemetry.js +2 -2
- package/dist/commands/init.js +1 -1
- package/dist/commands/telemetry-token.js +3 -3
- package/dist/commands/tokens.js +4 -4
- package/dist/commands/verify-explore.js +3 -3
- package/dist/commands/verify-history.js +2 -2
- package/dist/commands/verify-onboarding.js +13 -16
- package/dist/commands/verify-precompute.js +3 -4
- package/dist/commands/verify.js +9 -151
- package/dist/index.js +28 -45
- package/dist/utils/haystack-api.js +8 -0
- package/package.json +1 -1
- package/dist/commands/status.js +0 -35
- package/dist/types.js +0 -326
- package/dist/utils/config.js +0 -54
package/README.md
CHANGED
|
@@ -151,14 +151,6 @@ Exit codes: 0 set up, 1 failed, 2 changes shown but not made, 3 onboarding
|
|
|
151
151
|
blocked, 4 not logged in, 5 the Haystack GitHub App is not installed, 6 set up
|
|
152
152
|
but onboarding stopped before finishing (run it again).
|
|
153
153
|
|
|
154
|
-
### `haystack status`
|
|
155
|
-
|
|
156
|
-
Check if your project is configured:
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
haystack status
|
|
160
|
-
```
|
|
161
|
-
|
|
162
154
|
### `haystack login`
|
|
163
155
|
|
|
164
156
|
Authenticate with GitHub (required for setup and secrets):
|
|
@@ -16,7 +16,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import { join, posix } from 'node:path';
|
|
18
18
|
import { CAPTURE_KEY_PATTERN, CAPTURE_STATUS_PATH, CAPTURE_VERSION } from '../commands/capture-contract.js';
|
|
19
|
-
import { haystackFetch, haystackJson } from '../utils/haystack-api.js';
|
|
19
|
+
import { haystackFetch, haystackJson, repoApiPath } from '../utils/haystack-api.js';
|
|
20
20
|
export const CAPTURE_CONFIG_PATH = '.haystack/capture.json';
|
|
21
21
|
export function captureConfigFile(appDir) {
|
|
22
22
|
return join(appDir, CAPTURE_CONFIG_PATH);
|
|
@@ -100,8 +100,8 @@ export function parseOrigins(given) {
|
|
|
100
100
|
/** Registers the application and its production origins (rule 8b): POST /api/agent/cloud-verifier/capture/key, a
|
|
101
101
|
* repository member's call, idempotent (the same application gets the same key; new origins replace the old). */
|
|
102
102
|
export async function registerCaptureApp(token, repository, app, origins) {
|
|
103
|
-
const query = new URLSearchParams({
|
|
104
|
-
const response = await haystackJson(`/
|
|
103
|
+
const query = new URLSearchParams({ app });
|
|
104
|
+
const response = await haystackJson(repoApiPath(repository, `/capture/key?${query}`), token, {
|
|
105
105
|
method: 'POST',
|
|
106
106
|
headers: { 'Content-Type': 'application/json' },
|
|
107
107
|
body: JSON.stringify({ origins }),
|
|
@@ -116,8 +116,8 @@ export async function registerCaptureApp(token, repository, app, origins) {
|
|
|
116
116
|
/** The application's registration as Haystack holds it now (GET /api/agent/cloud-verifier/capture/status, lane A): its key
|
|
117
117
|
* and production origins, or null when the application is not registered (404). */
|
|
118
118
|
export async function readCaptureRegistration(token, repository, app) {
|
|
119
|
-
const query = new URLSearchParams({
|
|
120
|
-
const response = await haystackFetch(`${CAPTURE_STATUS_PATH}?${query}
|
|
119
|
+
const query = new URLSearchParams({ app });
|
|
120
|
+
const response = await haystackFetch(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?${query}`), token, {}, [404]);
|
|
121
121
|
if (response.status === 404) {
|
|
122
122
|
await response.body?.cancel();
|
|
123
123
|
return null;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { join, posix } from 'node:path';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { CAPTURE_CONFIG_PATH, captureConfigDirs, readCaptureConfig } from '../capture/app-config.js';
|
|
10
|
-
import { classifyHttpError } from '../utils/haystack-api.js';
|
|
10
|
+
import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
|
|
11
11
|
import { gatewayFetch } from './case-batch.js';
|
|
12
12
|
import { CAPTURE_APPLICATION_ID, CAPTURE_WINDOW_PATH, captureChangedRoutes, } from './capture-contract.js';
|
|
13
13
|
/** An app's application id from its record, or why the record cannot give one. */
|
|
@@ -34,9 +34,10 @@ async function readApp(gitRoot, app, repository, token, files, now) {
|
|
|
34
34
|
return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
35
35
|
const { applicationId } = application;
|
|
36
36
|
try {
|
|
37
|
-
const
|
|
37
|
+
const path = repoApiPath(repository, `${CAPTURE_WINDOW_PATH}?app=${encodeURIComponent(applicationId)}`);
|
|
38
|
+
const response = await gatewayFetch(path, token);
|
|
38
39
|
if (!response.ok)
|
|
39
|
-
throw await classifyHttpError(response, `Haystack API ${
|
|
40
|
+
throw await classifyHttpError(response, `Haystack API ${path}`);
|
|
40
41
|
const answer = await response.json();
|
|
41
42
|
if (answer.version !== 'capture-v1' || answer.applicationId !== applicationId)
|
|
42
43
|
throw new Error('the capture window answer names another application');
|
|
@@ -10,7 +10,7 @@ export const CAPTURE_SCRIPT_MAX_BYTES = 8 * 1024;
|
|
|
10
10
|
export const CAPTURE_ROUTE_ID_MAX = 200;
|
|
11
11
|
/** Rule 4 Publishing: the largest route manifest (current.json and <release>.json), in bytes. */
|
|
12
12
|
export const CAPTURE_MANIFEST_MAX_BYTES = 256 * 1024;
|
|
13
|
-
export const CAPTURE_WINDOW_PATH = '/
|
|
13
|
+
export const CAPTURE_WINDOW_PATH = '/capture/window';
|
|
14
14
|
/** Rule 8(e): telemetry older than this is reported stale; rule 7b: a capture window older than this is stale. */
|
|
15
15
|
export const CAPTURE_EVIDENCE_FRESH_HOURS = 24;
|
|
16
16
|
export const CAPTURE_ROUTE_UNKNOWN = 'unknown';
|
|
@@ -39,8 +39,8 @@ export function captureChangedRoutes(window, files, now) {
|
|
|
39
39
|
}
|
|
40
40
|
export const TELEMETRY_TOKEN_VERSION = 'telemetry-token-v1';
|
|
41
41
|
export const TELEMETRY_ENVIRONMENT = 'production';
|
|
42
|
-
export const TELEMETRY_TOKEN_PATH = '/
|
|
43
|
-
export const TELEMETRY_SERVER_STATUS_PATH = '/
|
|
42
|
+
export const TELEMETRY_TOKEN_PATH = '/telemetry/token';
|
|
43
|
+
export const TELEMETRY_SERVER_STATUS_PATH = '/telemetry/server';
|
|
44
44
|
export const TELEMETRY_TOKEN_SHA256 = /^[0-9a-f]{64}$/;
|
|
45
45
|
/** Lane A's capture status: GET /api/agent/cloud-verifier/capture/status?repository=&app= (rule 8b's key registration). */
|
|
46
|
-
export const CAPTURE_STATUS_PATH = '/
|
|
46
|
+
export const CAPTURE_STATUS_PATH = '/capture/status';
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
export const TERMINAL = new Set(['completed', 'prepared', 'incomplete', 'cancelled']);
|
|
2
|
+
export const VERDICTS = ['bug', 'cant-tell', 'being-judged', 'not-a-bug'];
|
|
3
|
+
export const STATUS_WORDS = {
|
|
4
|
+
queued: 'waiting for a machine',
|
|
5
|
+
preparing: 'getting ready',
|
|
6
|
+
crawling: 'exploring the app',
|
|
7
|
+
draining: 'finishing the last checks',
|
|
8
|
+
completed: 'finished',
|
|
9
|
+
prepared: 'built and ready to check',
|
|
10
|
+
incomplete: 'stopped before finishing',
|
|
11
|
+
cancelled: 'cancelled',
|
|
12
|
+
};
|
|
13
|
+
export const STAGE_WORDS = {
|
|
14
|
+
reconcile: 'cleaning up after an earlier try',
|
|
15
|
+
source: 'rebuilding your change',
|
|
16
|
+
worlds: 'building and starting the app with and without your change',
|
|
17
|
+
blast: 'waiting for the analysis of what your change touches',
|
|
18
|
+
knobs: "reading the app's settings",
|
|
19
|
+
prepare: 'setting up starting points next to your change',
|
|
20
|
+
crawl: 'exploring the app',
|
|
21
|
+
publish: 'saving the results',
|
|
22
|
+
cleanup: 'shutting down the machines it used',
|
|
23
|
+
};
|
|
24
|
+
export const ERROR_WORDS = {
|
|
25
|
+
'source-mismatch': 'your change could not be rebuilt exactly as it was captured',
|
|
26
|
+
'blast-unavailable': 'the analysis of what your change touches was not available',
|
|
27
|
+
'world-build-failed': 'the app did not build or start, with or without your change',
|
|
28
|
+
'prepare-failed': 'setting up starting points next to your change failed',
|
|
29
|
+
'crawl-failed': 'exploring the app failed',
|
|
30
|
+
'no-capacity': 'there was no room on the machines for it',
|
|
31
|
+
stalled: 'it stopped making progress',
|
|
32
|
+
'lease-lost': 'the machine running it stopped responding',
|
|
33
|
+
cancelled: 'it was cancelled',
|
|
34
|
+
};
|
|
35
|
+
export const SPOT_WORDS = {
|
|
36
|
+
resolved: 'never reached',
|
|
37
|
+
loaded: 'loaded but never ran',
|
|
38
|
+
exposed: 'shown on screen',
|
|
39
|
+
activated: 'ran',
|
|
40
|
+
pressed: 'ran when its control was pressed',
|
|
41
|
+
observed: 'a difference seen',
|
|
42
|
+
confirmed: 'a difference seen and confirmed',
|
|
43
|
+
// The stage, not the verdict: a judged difference may be judged not a bug. The findings carry the verdicts.
|
|
44
|
+
judged: 'a difference seen, confirmed and judged',
|
|
45
|
+
};
|
|
46
|
+
export const VERDICT_WORDS = {
|
|
47
|
+
bug: 'Bug',
|
|
48
|
+
'cant-tell': "Can't tell",
|
|
49
|
+
'being-judged': 'Still being judged',
|
|
50
|
+
'not-a-bug': 'Not a bug',
|
|
51
|
+
};
|
|
52
|
+
export function plural(count, noun) {
|
|
53
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
54
|
+
}
|
|
55
|
+
export function errorWords(error) {
|
|
56
|
+
return error.code === 'stalled'
|
|
57
|
+
? `${ERROR_WORDS.stalled} while ${STAGE_WORDS[error.stage]}`
|
|
58
|
+
: ERROR_WORDS[error.code];
|
|
59
|
+
}
|
|
60
|
+
/** What a running crawl is doing now: its stage, except while it waits for a
|
|
61
|
+
* machine or finishes its last checks, which its status says better. */
|
|
62
|
+
export function currentStep(view) {
|
|
63
|
+
return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
|
|
64
|
+
}
|
|
65
|
+
/** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
|
|
66
|
+
export function results(view) {
|
|
67
|
+
return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
|
|
68
|
+
}
|
|
69
|
+
/** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
|
|
70
|
+
* done; only its shutdown remains) reads as completed. */
|
|
71
|
+
export function reportedStatus(view) {
|
|
72
|
+
return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
|
|
73
|
+
}
|
|
74
|
+
export function headline(view) {
|
|
75
|
+
const bugs = results(view)?.bugs ?? view.totals.bugs;
|
|
76
|
+
const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
|
|
77
|
+
switch (reportedStatus(view)) {
|
|
78
|
+
case 'completed':
|
|
79
|
+
return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
|
|
80
|
+
case 'incomplete': {
|
|
81
|
+
const error = results(view)?.error ?? view.error;
|
|
82
|
+
return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
|
|
83
|
+
}
|
|
84
|
+
case 'prepared':
|
|
85
|
+
// Amendment 12: the turn-end hook built and froze this change; `haystack verify` asks for the crawl itself.
|
|
86
|
+
return 'Your change is built and ready to check, but was not crawled yet. Run `haystack verify` to check it.';
|
|
87
|
+
case 'cancelled':
|
|
88
|
+
return `The crawl was cancelled.${found}`;
|
|
89
|
+
default: {
|
|
90
|
+
const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
|
|
91
|
+
if (view.cancelRequested) {
|
|
92
|
+
return `Being cancelled: ${currentStep(view)}.${so}`;
|
|
93
|
+
}
|
|
94
|
+
return `Still running: ${currentStep(view)}.${so}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function verifyReport(view) {
|
|
99
|
+
const status = reportedStatus(view);
|
|
100
|
+
const published = results(view);
|
|
101
|
+
const error = published?.error ?? view.error;
|
|
102
|
+
const explored = published !== null && view.clocks.crawlStartedAt !== null;
|
|
103
|
+
const findings = explored ? VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict)) : [];
|
|
104
|
+
const ideaWords = { covered: 'tried', blocked: 'could not try', unfinished: 'not finished' };
|
|
105
|
+
return {
|
|
106
|
+
runId: view.runId,
|
|
107
|
+
repository: view.repository,
|
|
108
|
+
title: view.changeTitle,
|
|
109
|
+
baseCommit: view.baseCommit,
|
|
110
|
+
workCommit: view.workCommit,
|
|
111
|
+
status,
|
|
112
|
+
headline: headline(view),
|
|
113
|
+
bugs: published?.bugs ?? view.totals.bugs,
|
|
114
|
+
findings: findings.map(finding => ({
|
|
115
|
+
id: finding.id,
|
|
116
|
+
verdict: finding.verdict,
|
|
117
|
+
why: finding.why,
|
|
118
|
+
start: finding.start,
|
|
119
|
+
steps: finding.steps,
|
|
120
|
+
outsideServices: finding.standIns && (finding.standIns.old.length > 0 || finding.standIns.new.length > 0)
|
|
121
|
+
? { withoutChange: finding.standIns.old, withChange: finding.standIns.new }
|
|
122
|
+
: null,
|
|
123
|
+
})),
|
|
124
|
+
spots: explored ? published.reach.spots.map(spot => ({
|
|
125
|
+
file: spot.file,
|
|
126
|
+
line: spot.line,
|
|
127
|
+
reached: spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest],
|
|
128
|
+
reachedBy: spot.reachedBy && { start: spot.reachedBy.start, steps: spot.reachedBy.steps, page: spot.reachedBy.page },
|
|
129
|
+
findingIds: spot.findingIds,
|
|
130
|
+
})) : [],
|
|
131
|
+
ideas: explored && published.ideas !== undefined
|
|
132
|
+
? published.ideas.map(idea => ({ text: idea.text, result: ideaWords[idea.state], reason: idea.reason, findingIds: idea.findingIds }))
|
|
133
|
+
: null,
|
|
134
|
+
neverRan: explored ? {
|
|
135
|
+
files: published.reach.unreachedChangedFiles,
|
|
136
|
+
functions: published.reach.blast === null ? null : { ran: published.reach.blast.ran, total: published.reach.blast.functions },
|
|
137
|
+
} : null,
|
|
138
|
+
notFinishedInTime: explored ? published.notFinishedInTime : 0,
|
|
139
|
+
outsideServiceGaps: explored ? published.standIns?.gaps ?? [] : [],
|
|
140
|
+
error: error ? { code: error.code, message: error.message } : null,
|
|
141
|
+
machinesNotProvenShutDown: TERMINAL.has(view.status) ? view.totals.cleanupUnproven : 0,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** The report as markdown: headings an agent can navigate, bugs first, each with the steps to see it. */
|
|
145
|
+
export function reportMarkdown(report) {
|
|
146
|
+
const lines = [`# ${report.headline}`, '',
|
|
147
|
+
`Change: ${report.title}`, `Check ${report.runId} of ${report.repository}: base ${report.baseCommit.slice(0, 12)}, change ${report.workCommit.slice(0, 12)}.`];
|
|
148
|
+
if (report.error && report.status !== 'completed')
|
|
149
|
+
lines.push(`Details: ${report.error.message}`);
|
|
150
|
+
if (report.neverRan === null) {
|
|
151
|
+
lines.push('', TERMINAL.has(report.status)
|
|
152
|
+
? 'Nothing about the change was measured: the crawl did not explore the app.'
|
|
153
|
+
: 'Where the change shows up, and what the crawl found, appear when it finishes.');
|
|
154
|
+
return lines.join('\n') + '\n';
|
|
155
|
+
}
|
|
156
|
+
lines.push('', '## Where the change shows up in the app');
|
|
157
|
+
if (report.spots.length === 0)
|
|
158
|
+
lines.push('No changed spots were recorded.');
|
|
159
|
+
for (const spot of report.spots) {
|
|
160
|
+
lines.push(`- \`${spot.file}:${spot.line}\`: ${spot.reached}${spot.findingIds.length ? ` (${plural(spot.findingIds.length, 'finding')})` : ''}`);
|
|
161
|
+
if (spot.reachedBy)
|
|
162
|
+
lines.push(` - reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].join(' > ')}${spot.reachedBy.page ? ` (page ${spot.reachedBy.page})` : ''}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('', '## What the crawl found');
|
|
165
|
+
if (report.findings.length === 0)
|
|
166
|
+
lines.push('No confirmed differences between the app with and without the change.');
|
|
167
|
+
report.findings.forEach((finding, index) => {
|
|
168
|
+
lines.push(`${index + 1}. **${VERDICT_WORDS[finding.verdict]}** (\`${finding.id}\`): ${finding.why ?? 'no reason was recorded.'}`, ` Starting from ${finding.start}:`, ...finding.steps.map((step, n) => ` ${n + 1}. ${step}`));
|
|
169
|
+
if (finding.outsideServices) {
|
|
170
|
+
lines.push(` Outside services on this path, answered by stand-ins: without the change ${finding.outsideServices.withoutChange.join('; ') || 'none'};`
|
|
171
|
+
+ ` with it ${finding.outsideServices.withChange.join('; ') || 'none'}.`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
if (report.notFinishedInTime > 0) {
|
|
175
|
+
lines.push(`${plural(report.notFinishedInTime, 'difference')} ${report.notFinishedInTime === 1 ? 'was' : 'were'} still being checked when its time ran out.`);
|
|
176
|
+
}
|
|
177
|
+
if (report.ideas !== null) {
|
|
178
|
+
lines.push('', '## Your ideas');
|
|
179
|
+
for (const idea of report.ideas)
|
|
180
|
+
lines.push(`- ${idea.result}: ${idea.text}. ${idea.reason}`);
|
|
181
|
+
}
|
|
182
|
+
lines.push('', '## What the crawl never ran');
|
|
183
|
+
if (report.neverRan.files.length === 0)
|
|
184
|
+
lines.push('Every changed file ran at least once.');
|
|
185
|
+
else
|
|
186
|
+
lines.push('Changed files no path ran:', ...report.neverRan.files.map(file => `- \`${file}\``));
|
|
187
|
+
if (report.neverRan.functions)
|
|
188
|
+
lines.push(`${report.neverRan.functions.ran} of ${plural(report.neverRan.functions.total, 'affected function')} ran.`);
|
|
189
|
+
if (report.outsideServiceGaps.length > 0) {
|
|
190
|
+
lines.push('', '## Outside services the copy could not imitate', 'Missing coverage of the environment, not bugs in the change:', ...report.outsideServiceGaps.map(gap => `- ${gap}`));
|
|
191
|
+
}
|
|
192
|
+
if (report.machinesNotProvenShutDown > 0) {
|
|
193
|
+
lines.push('', `${plural(report.machinesNotProvenShutDown, 'machine')} it used could not be proven shut down.`);
|
|
194
|
+
}
|
|
195
|
+
return lines.join('\n') + '\n';
|
|
196
|
+
}
|
|
@@ -14,8 +14,8 @@ import chalk from 'chalk';
|
|
|
14
14
|
import { DATABASE_PROFILE_UPLOAD_REQUEST_VERSION, parseDatabaseProfileUpload, } from './db-profile-contract.js';
|
|
15
15
|
import { readDatabaseProfileFile } from './db-profile-show.js';
|
|
16
16
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
17
|
-
import { classifyHttpError, haystackApiUrl, readWithRetries, transientReadFailure } from '../utils/haystack-api.js';
|
|
18
|
-
export const DATABASE_PROFILES_PATH = '/
|
|
17
|
+
import { classifyHttpError, haystackApiUrl, readWithRetries, transientReadFailure, repoApiPath } from '../utils/haystack-api.js';
|
|
18
|
+
export const DATABASE_PROFILES_PATH = '/database-profiles';
|
|
19
19
|
/** GitHub's owner and repository name alphabets. */
|
|
20
20
|
const REPOSITORY = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9_.-]{1,100})$/u;
|
|
21
21
|
/** Run-map dependency ids are stable kebab-case ids (the same check as
|
|
@@ -60,7 +60,7 @@ export async function uploadDatabaseProfile(request, token) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
async function uploadDatabaseProfileOnce(request, token) {
|
|
63
|
-
const response = await fetch(haystackApiUrl(DATABASE_PROFILES_PATH), {
|
|
63
|
+
const response = await fetch(haystackApiUrl(repoApiPath(request.repository, DATABASE_PROFILES_PATH)), {
|
|
64
64
|
method: 'POST',
|
|
65
65
|
headers: {
|
|
66
66
|
Authorization: `Bearer ${token}`,
|
|
@@ -53,7 +53,7 @@ export async function feedbackCommand(words, options) {
|
|
|
53
53
|
const message = words.length === 1 && words[0] === '-' ? readFileSync(0, 'utf8') : words.join(' ');
|
|
54
54
|
const request = buildFeedbackRequest(message, options, currentRepository());
|
|
55
55
|
const { token } = await resolveAuthContext({ preferredLogin: options.account });
|
|
56
|
-
await haystackJson('/
|
|
56
|
+
await haystackJson('/v1/feedback', token, {
|
|
57
57
|
method: 'POST',
|
|
58
58
|
headers: { 'Content-Type': 'application/json' },
|
|
59
59
|
body: JSON.stringify(request),
|
|
@@ -26,7 +26,7 @@ import { createRequire } from 'node:module';
|
|
|
26
26
|
import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
import chalk from 'chalk';
|
|
29
|
-
import { classifyHttpError } from '../utils/haystack-api.js';
|
|
29
|
+
import { classifyHttpError, repoApiPath } from '../utils/haystack-api.js';
|
|
30
30
|
import { gatewayFetch } from './case-batch.js';
|
|
31
31
|
import { CAPTURE_EVIDENCE_FRESH_HOURS, CAPTURE_STATUS_PATH, } from './capture-contract.js';
|
|
32
32
|
import { captureAnswers, planCapture } from './init-capture.js';
|
|
@@ -961,7 +961,7 @@ async function readJson(path, token) {
|
|
|
961
961
|
}
|
|
962
962
|
/** Lane A's registration of the application: its newest capture event, or null when it is not registered (404) or has none. */
|
|
963
963
|
async function captureReceived(repository, app, token) {
|
|
964
|
-
const status = await readJson(`${CAPTURE_STATUS_PATH}?
|
|
964
|
+
const status = await readJson(repoApiPath(repository, `${CAPTURE_STATUS_PATH}?app=${encodeURIComponent(app)}`), token);
|
|
965
965
|
return status === null ? null : received(status.lastSeenAt, status.release);
|
|
966
966
|
}
|
|
967
967
|
function withReceived(part, evidence) {
|
package/dist/commands/init.js
CHANGED
|
@@ -56,7 +56,7 @@ export const AGENT_NOTE = [
|
|
|
56
56
|
' `haystack verify answer <question-id> <choice>`.',
|
|
57
57
|
'- Read its short report. `--json` prints the same report as data; `--raw` prints the crawl\'s whole record (large), for scripts;',
|
|
58
58
|
' `haystack verify --help` explains the output and exit codes.',
|
|
59
|
-
'- If `haystack` is not installed, run each command as `npx -y @haystackeditor/cli@latest <command>`.',
|
|
59
|
+
'- If `haystack` is not installed, run each command as `npx -y -p @haystackeditor/cli@latest haystack <command>`.',
|
|
60
60
|
'- If Haystack fails, confuses you or gets in your way, say so in a sentence with',
|
|
61
61
|
' `haystack feedback "<what happened>"` and keep going: the Haystack team sees it at once.',
|
|
62
62
|
NOTE_END,
|
|
@@ -22,7 +22,7 @@ import { basename, dirname, join, resolve } from 'node:path';
|
|
|
22
22
|
import { createInterface } from 'node:readline/promises';
|
|
23
23
|
import chalk from 'chalk';
|
|
24
24
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
25
|
-
import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
|
|
25
|
+
import { classifyHttpError, HaystackApiError, repoApiPath } from '../utils/haystack-api.js';
|
|
26
26
|
import { gatewayFetch } from './case-batch.js';
|
|
27
27
|
import { TELEMETRY_ENVIRONMENT, TELEMETRY_SERVER_STATUS_PATH, TELEMETRY_TOKEN_PATH, TELEMETRY_TOKEN_SHA256, TELEMETRY_TOKEN_VERSION, } from './capture-contract.js';
|
|
28
28
|
/** GitHub's owner and repository name alphabets. */
|
|
@@ -51,7 +51,7 @@ async function confirmed(question) {
|
|
|
51
51
|
}
|
|
52
52
|
/** GET the repository's server-telemetry status: its active token's hash, the endpoint, the newest flush. */
|
|
53
53
|
export async function readTelemetryServerStatus(repository, token) {
|
|
54
|
-
const path =
|
|
54
|
+
const path = repoApiPath(repository, TELEMETRY_SERVER_STATUS_PATH);
|
|
55
55
|
const response = await gatewayFetch(path, token);
|
|
56
56
|
if (!response.ok)
|
|
57
57
|
throw await classifyHttpError(response, `Haystack API ${TELEMETRY_SERVER_STATUS_PATH}`);
|
|
@@ -199,7 +199,7 @@ async function mintUnderLock(repository, file, authToken, options) {
|
|
|
199
199
|
let answer;
|
|
200
200
|
try {
|
|
201
201
|
const request = { repository, tokenSha256, rotate: options.rotate === true };
|
|
202
|
-
const response = await gatewayFetch(
|
|
202
|
+
const response = await gatewayFetch(repoApiPath(repository, TELEMETRY_TOKEN_PATH), authToken, {
|
|
203
203
|
method: 'POST', body: JSON.stringify(request),
|
|
204
204
|
});
|
|
205
205
|
if (!response.ok)
|
package/dist/commands/tokens.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* - `haystack login --headless [--label NAME] [--scope repo:owner/name,...]`
|
|
6
6
|
* Opens the browser to mint a token via the auth-worker, then writes it
|
|
7
7
|
* locally so subsequent CLI calls auth via Bearer.
|
|
8
|
-
* - `haystack tokens
|
|
8
|
+
* - `haystack auth tokens`
|
|
9
9
|
* Lists tokens on the server side (the local file only stores ONE
|
|
10
10
|
* active token at a time; the server may have many tied to your user).
|
|
11
|
-
* - `haystack
|
|
11
|
+
* - `haystack auth revoke <id>`
|
|
12
12
|
* Revokes a server-side token. If the revoked token is the one
|
|
13
13
|
* currently on disk, the local file is wiped so the next CLI call
|
|
14
14
|
* doesn't keep using a dead token.
|
|
@@ -123,12 +123,12 @@ export async function headlessLoginCommand(opts) {
|
|
|
123
123
|
console.log(` ${chalk.dim('path:')} ${path}`);
|
|
124
124
|
console.log(` ${chalk.dim('id:')} ${result.id}`);
|
|
125
125
|
console.log(chalk.dim(' Subsequent `haystack` calls will use this token.'));
|
|
126
|
-
console.log(chalk.dim(' Revoke server-side with: haystack
|
|
126
|
+
console.log(chalk.dim(' Revoke server-side with: haystack auth revoke ' + result.id));
|
|
127
127
|
}
|
|
128
128
|
export async function headlessLogoutCommand() {
|
|
129
129
|
await clearHeadlessToken();
|
|
130
130
|
console.log(chalk.green('✓ Headless token removed from disk.'));
|
|
131
|
-
console.log(chalk.dim(' Server-side token is still active. Revoke it with `haystack
|
|
131
|
+
console.log(chalk.dim(' Server-side token is still active. Revoke it with `haystack auth revoke <id>`.'));
|
|
132
132
|
}
|
|
133
133
|
export async function listTokensCommand(opts) {
|
|
134
134
|
const token = await loadToken();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
3
|
-
import { haystackJson } from '../utils/haystack-api.js';
|
|
3
|
+
import { haystackJson, repoApiPath } from '../utils/haystack-api.js';
|
|
4
4
|
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
5
5
|
const CASE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
6
6
|
const ACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
@@ -27,8 +27,8 @@ async function context(runId, repository) {
|
|
|
27
27
|
const { token } = await resolveAuthContext({ owner, repo });
|
|
28
28
|
return {
|
|
29
29
|
token,
|
|
30
|
-
path: `/
|
|
31
|
-
query:
|
|
30
|
+
path: repoApiPath(repository, `/runs/${runId}/cases`),
|
|
31
|
+
query: '',
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
export async function verifyCasesCommand(runId, options) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
2
|
-
import { haystackJson, HaystackApiError } from '../utils/haystack-api.js';
|
|
2
|
+
import { haystackJson, HaystackApiError, repoApiPath } from '../utils/haystack-api.js';
|
|
3
3
|
const HEALTH = ['healthy', 'degraded', 'infrastructure-error', 'not-executed', 'unknown'];
|
|
4
4
|
const LIFECYCLES = ['complete', 'failed', 'cancelled', 'budget-stopped', 'account-action'];
|
|
5
5
|
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
@@ -139,7 +139,7 @@ export async function verifyHistoryCommand(repository, options) {
|
|
|
139
139
|
const auth = await resolveAuthContext({ preferredLogin: options.account, owner, repo: name });
|
|
140
140
|
let response;
|
|
141
141
|
try {
|
|
142
|
-
response = await haystackJson(
|
|
142
|
+
response = await haystackJson(repoApiPath(`${owner}/${name}`, `/runs?limit=${limit}`), auth.token, { method: 'GET' });
|
|
143
143
|
}
|
|
144
144
|
catch (error) {
|
|
145
145
|
if (error instanceof HaystackApiError && error.status === 404) {
|
|
@@ -10,17 +10,14 @@
|
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { withSchema } from '../schema.js';
|
|
12
12
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
13
|
-
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
|
|
13
|
+
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, repoApiPath, } from '../utils/haystack-api.js';
|
|
14
14
|
import { trackOnboardingEvent } from '../utils/telemetry.js';
|
|
15
15
|
import { gatewayFetch } from './case-batch.js';
|
|
16
16
|
import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
|
|
17
17
|
import { sameNotes } from './onboarding-notes.js';
|
|
18
18
|
import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
|
|
19
19
|
import { findGitRoot } from '../utils/hooks.js';
|
|
20
|
-
const
|
|
21
|
-
const FACTS_PATH = `${ONBOARDING_PATH}/facts`;
|
|
22
|
-
const ANSWERS_PATH = `${ONBOARDING_PATH}/answers`;
|
|
23
|
-
const NOTES_PATH = `${ONBOARDING_PATH}/notes`;
|
|
20
|
+
const onboardingPath = (repository, rest = '') => repoApiPath(repository, `/onboarding${rest}`);
|
|
24
21
|
const ONBOARD_RUN_ID = /^ob_[0-9a-f]{48}$/;
|
|
25
22
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
26
23
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
@@ -155,11 +152,11 @@ async function body(response, what) {
|
|
|
155
152
|
export async function readOnboardingStatus(repository, baseCommit, token, onboardRunId = null) {
|
|
156
153
|
if (!COMMIT.test(baseCommit))
|
|
157
154
|
throw new Error(`Invalid base commit ${baseCommit}.`);
|
|
158
|
-
const path =
|
|
159
|
-
+ (onboardRunId === null ? '' : `&
|
|
155
|
+
const path = onboardingPath(repository, `?base=${baseCommit}`)
|
|
156
|
+
+ (onboardRunId === null ? '' : `&run=${encodeURIComponent(onboardRunId)}`);
|
|
160
157
|
const response = await gatewayFetch(path, token);
|
|
161
158
|
if (!response.ok)
|
|
162
|
-
throw await classifyHttpError(response, `Haystack API ${
|
|
159
|
+
throw await classifyHttpError(response, `Haystack API ${path}`);
|
|
163
160
|
return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
|
|
164
161
|
}
|
|
165
162
|
/** Rule 12's start POST: onboard this base now, before any change exists. Idempotent: a
|
|
@@ -167,11 +164,11 @@ export async function readOnboardingStatus(repository, baseCommit, token, onboar
|
|
|
167
164
|
export async function startOnboarding(repository, baseCommit, token) {
|
|
168
165
|
if (!COMMIT.test(baseCommit))
|
|
169
166
|
throw new Error(`Invalid base commit ${baseCommit}.`);
|
|
170
|
-
const response = await gatewayFetch(
|
|
167
|
+
const response = await gatewayFetch(onboardingPath(repository), token, {
|
|
171
168
|
method: 'POST', body: JSON.stringify({ repository, baseCommit }),
|
|
172
169
|
});
|
|
173
170
|
if (!response.ok)
|
|
174
|
-
throw await classifyHttpError(response, `Haystack API ${
|
|
171
|
+
throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository)}`);
|
|
175
172
|
return parseOnboardingStatus(await body(response, 'a status'), { repository, baseCommit });
|
|
176
173
|
}
|
|
177
174
|
/** How often a command reads onboarding status while it runs. The status route holds no read (a crawl's reads wait for a
|
|
@@ -357,9 +354,9 @@ function repositoryOf(value) {
|
|
|
357
354
|
return `${origin.owner}/${origin.repository}`;
|
|
358
355
|
}
|
|
359
356
|
export async function readFacts(repository, token) {
|
|
360
|
-
const response = await gatewayFetch(
|
|
357
|
+
const response = await gatewayFetch(onboardingPath(repository, '/facts'), token);
|
|
361
358
|
if (!response.ok)
|
|
362
|
-
throw await classifyHttpError(response, `Haystack API ${
|
|
359
|
+
throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/facts')}`);
|
|
363
360
|
return parseFactsView(await body(response, 'facts'), repository);
|
|
364
361
|
}
|
|
365
362
|
/** `haystack verify answer <question-id> <choice>`: files the answer to an open
|
|
@@ -379,7 +376,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
|
|
|
379
376
|
if (!question.choices.includes(choice)) {
|
|
380
377
|
throw new Error(`"${choice}" is not one of the question's choices: ${question.choices.map(item => JSON.stringify(item)).join(', ')}.`);
|
|
381
378
|
}
|
|
382
|
-
const response = await gatewayFetch(
|
|
379
|
+
const response = await gatewayFetch(onboardingPath(repository, '/answers'), auth.token, {
|
|
383
380
|
method: 'POST', body: JSON.stringify({ repository, expectedFactsDigest: facts.factsDigest, questionId, answer: choice }),
|
|
384
381
|
});
|
|
385
382
|
if (response.status === 409 && attempt < 3) {
|
|
@@ -387,7 +384,7 @@ export async function verifyAnswerCommand(questionId, choice, options) {
|
|
|
387
384
|
continue;
|
|
388
385
|
}
|
|
389
386
|
if (!response.ok)
|
|
390
|
-
throw await classifyHttpError(response, `Haystack API ${
|
|
387
|
+
throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/answers')}`);
|
|
391
388
|
const view = parseFactsView(await body(response, 'facts'), repository);
|
|
392
389
|
if (options.json) {
|
|
393
390
|
process.stdout.write(`${JSON.stringify(withSchema('verify-answer', { facts: view }), null, 2)}\n`);
|
|
@@ -407,7 +404,7 @@ export async function writeOnboardingNotes(repository, notes, token) {
|
|
|
407
404
|
if (sameNotes(facts.facts.notes, notes))
|
|
408
405
|
return 'unchanged';
|
|
409
406
|
const request = { repository, expectedFactsDigest: facts.factsDigest, notes };
|
|
410
|
-
const response = await gatewayFetch(
|
|
407
|
+
const response = await gatewayFetch(onboardingPath(repository, '/notes'), token, {
|
|
411
408
|
method: 'POST', body: JSON.stringify(request),
|
|
412
409
|
});
|
|
413
410
|
if (response.status === 409 && attempt < 3) {
|
|
@@ -415,7 +412,7 @@ export async function writeOnboardingNotes(repository, notes, token) {
|
|
|
415
412
|
continue;
|
|
416
413
|
}
|
|
417
414
|
if (!response.ok)
|
|
418
|
-
throw await classifyHttpError(response, `Haystack API ${
|
|
415
|
+
throw await classifyHttpError(response, `Haystack API ${onboardingPath(repository, '/notes')}`);
|
|
419
416
|
if (!sameNotes(parseFactsView(await body(response, 'facts'), repository).facts.notes, notes))
|
|
420
417
|
invalid('the notes it answered with');
|
|
421
418
|
return 'written';
|
|
@@ -8,7 +8,7 @@ import chalk from 'chalk';
|
|
|
8
8
|
import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
|
|
9
9
|
import { findGitRoot, parseRemoteUrl, } from '../utils/git.js';
|
|
10
10
|
import { resolveVerifyBaseSha } from '../utils/verify-base.js';
|
|
11
|
-
import { HaystackApiError, haystackFetch, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
|
|
11
|
+
import { HaystackApiError, haystackFetch, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, repoApiPath, } from '../utils/haystack-api.js';
|
|
12
12
|
import { trackPrecomputeEvent } from '../utils/telemetry.js';
|
|
13
13
|
import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
|
|
14
14
|
import { startPrecomputeDelivery } from './precompute-delivery.js';
|
|
@@ -325,8 +325,7 @@ function precomputeResult(response) {
|
|
|
325
325
|
* service no longer has it). */
|
|
326
326
|
async function uploadSourcePatch(request, token, signal, force = false) {
|
|
327
327
|
const gzipped = Buffer.from(request.patchGzBase64, 'base64');
|
|
328
|
-
const path = `/
|
|
329
|
-
+ `?owner=${encodeURIComponent(request.owner)}&repository=${encodeURIComponent(request.repository)}`;
|
|
328
|
+
const path = repoApiPath(`${request.owner}/${request.repository}`, `/patches/${request.patchSha256}`);
|
|
330
329
|
const found = force ? null : await haystackFetch(path, token, { method: 'HEAD', signal }, [404]);
|
|
331
330
|
if (found === null || found.status === 404) {
|
|
332
331
|
await haystackFetch(path, token, { method: 'PUT', headers: { 'Content-Type': 'application/gzip' }, body: gzipped, signal });
|
|
@@ -339,7 +338,7 @@ async function submitCapture(request, token, signal) {
|
|
|
339
338
|
const { patchGzBase64: _inline, ...named } = request;
|
|
340
339
|
const submit = async (force) => {
|
|
341
340
|
const patchBytes = await uploadSourcePatch(request, token, signal, force);
|
|
342
|
-
return haystackJson('/
|
|
341
|
+
return haystackJson(repoApiPath(`${request.owner}/${request.repository}`, '/checks'), token, { method: 'POST',
|
|
343
342
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...named, patchBytes }), signal });
|
|
344
343
|
};
|
|
345
344
|
try {
|