@haystackeditor/cli 0.18.0 → 0.19.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 +14 -10
- package/dist/commands/case-batch.js +3 -1
- package/dist/commands/crawl-contract.js +6 -0
- package/dist/commands/precompute-delivery.js +6 -3
- package/dist/commands/verify-precompute.js +25 -6
- package/dist/commands/verify.js +155 -70
- package/dist/index.js +14 -8
- package/dist/types.js +3 -0
- package/package.json +1 -1
- package/schemas/verify.v1.json +27 -7
package/README.md
CHANGED
|
@@ -106,8 +106,11 @@ acknowledged; it never shows a crawl of another revision or another title.
|
|
|
106
106
|
A crawl builds the app with and without your change, starts from the changed
|
|
107
107
|
code, reaches it in the running app, explores outward with both builds side by
|
|
108
108
|
side, and double-checks and judges every difference. `haystack verify` waits
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
for the crawl, printing each step as it happens and each bug the moment the
|
|
110
|
+
crawl finds it (each read is held by the service until the crawl changes, so
|
|
111
|
+
nothing waits on a polling interval). It finishes when the crawl answers, as
|
|
112
|
+
soon as its time is up, without waiting for its machines to shut down; there
|
|
113
|
+
is no time limit, and Ctrl-C stops waiting, never the crawl. It then prints:
|
|
111
114
|
|
|
112
115
|
- a headline: how many bugs it found, none, or why the crawl could not finish;
|
|
113
116
|
- **Where your change shows up in the app**: every changed spot as
|
|
@@ -120,14 +123,15 @@ limit, and Ctrl-C stops waiting, never the crawl. It then prints:
|
|
|
120
123
|
- **What the crawl never ran**: changed files no path ran, and how many of the
|
|
121
124
|
functions your change affects ran.
|
|
122
125
|
|
|
123
|
-
`--no-wait` prints the crawl's current state and returns. `--
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
`--no-wait` prints the crawl's current state and returns. `--account <login>`
|
|
127
|
+
picks a saved account, and `--repo owner/repo` names the repository when
|
|
128
|
+
`origin` does not. `--json` prints one document, `{ "schema_version", "crawl" }`,
|
|
129
|
+
where `crawl` is the crawl as the service returns it, with its `answer` (what it
|
|
130
|
+
found so far, or its answer once its time is up) while no sealed `manifest`
|
|
131
|
+
exists (or `null` when no crawl of the capture could be started);
|
|
132
|
+
`haystack schema verify` prints its schema. Exit codes follow
|
|
133
|
+
`haystack case-batch status`: 0 when the crawl answered or finished (the bugs
|
|
134
|
+
it found are in the output) or is still running under `--no-wait`; 2 when it ended
|
|
131
135
|
without finishing (stopped early, or cancelled because a newer stop in the
|
|
132
136
|
repository replaced it) or the machines it used could not be proven shut down;
|
|
133
137
|
1 when the command failed or no crawl could be started.
|
|
@@ -90,6 +90,8 @@ function requireRunId(runId) {
|
|
|
90
90
|
}
|
|
91
91
|
return runId;
|
|
92
92
|
}
|
|
93
|
+
/** How long any gateway request may take unless its caller says otherwise. */
|
|
94
|
+
export const GATEWAY_TIMEOUT_MS = 120_000;
|
|
93
95
|
/** Every outbound request. The header set is built here and nowhere else, so
|
|
94
96
|
* the credential surface of this command is one line: the login bearer.
|
|
95
97
|
* `haystack verify` reads crawls through this same client. */
|
|
@@ -104,7 +106,7 @@ export async function gatewayFetch(path, token, init = { method: 'GET' }) {
|
|
|
104
106
|
method: init.method,
|
|
105
107
|
headers,
|
|
106
108
|
...(init.body === undefined ? {} : { body: init.body }),
|
|
107
|
-
signal: AbortSignal.timeout(
|
|
109
|
+
signal: AbortSignal.timeout(init.timeoutMs ?? GATEWAY_TIMEOUT_MS),
|
|
108
110
|
});
|
|
109
111
|
}
|
|
110
112
|
async function gatewayJson(path, token, init = { method: 'GET' }) {
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export const CRAWL_TITLE_MAX_CHARS = 200;
|
|
2
|
+
/** Amendment 8: the time a crawl may be asked to take, whole seconds from 1 to 30 minutes. */
|
|
3
|
+
export const CRAWL_BUDGET_MIN_MS = 60_000;
|
|
4
|
+
export const CRAWL_BUDGET_MAX_MS = 30 * 60_000;
|
|
2
5
|
export const CRAWL_MAX_FINDINGS = 50;
|
|
3
6
|
export const CRAWL_MAX_FINDING_STEPS = 64;
|
|
7
|
+
/** Amendment 9: what a crawl has found so far (`exploring`) or its answer at the budget (`answered`), before the sealed manifest;
|
|
8
|
+
* findings carry no images. A read may pass `waitAfter=<updatedAt>` to be held until the crawl changes (at most CRAWL_WAIT_MAX_MS). */
|
|
9
|
+
export const CRAWL_WAIT_MAX_MS = 25_000;
|
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
|
|
6
|
+
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
|
|
7
7
|
const requestFile = 'request.json';
|
|
8
8
|
const receiptFile = 'receipt.json';
|
|
9
9
|
const deliverySchema = 'precompute-delivery-v1';
|
|
@@ -150,10 +150,13 @@ function validateRequest(value) {
|
|
|
150
150
|
throw new Error('Invalid captured crawl submission.');
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
// Amendment 8: budgetMs only when the capture asked for a time, whole seconds from 1 to 30 minutes.
|
|
154
|
+
if (!object(crawl) || !onlyKeys(crawl, 'budgetMs' in crawl ? ['treeSha', 'changeTitle', 'budgetMs'] : ['treeSha', 'changeTitle'])
|
|
154
155
|
|| !characters(crawl.treeSha, '0123456789abcdef', 40)
|
|
155
156
|
|| request.cacheKey.split('-')[2] !== crawl.treeSha.slice(0, 16)
|
|
156
|
-
|| typeof crawl.changeTitle !== 'string' || crawl.changeTitle.length > CRAWL_TITLE_MAX_CHARS
|
|
157
|
+
|| typeof crawl.changeTitle !== 'string' || crawl.changeTitle.length > CRAWL_TITLE_MAX_CHARS
|
|
158
|
+
|| ('budgetMs' in crawl && (typeof crawl.budgetMs !== 'number' || !Number.isInteger(crawl.budgetMs) || crawl.budgetMs % 1000 !== 0
|
|
159
|
+
|| crawl.budgetMs < CRAWL_BUDGET_MIN_MS || crawl.budgetMs > CRAWL_BUDGET_MAX_MS))) {
|
|
157
160
|
throw new Error('Invalid captured crawl submission.');
|
|
158
161
|
}
|
|
159
162
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { closeSync, constants, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { closeSync, constants, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { createGzip } from 'node:zlib';
|
|
5
5
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
@@ -12,7 +12,7 @@ import { HaystackApiError, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_RE
|
|
|
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';
|
|
15
|
-
import { CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
|
|
15
|
+
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
|
|
16
16
|
// --hook has five seconds to capture the exact source and publish local custody.
|
|
17
17
|
// Its detached sender owns the bounded network delivery and acknowledgement.
|
|
18
18
|
// An explicit invocation still waits synchronously for the server response.
|
|
@@ -259,7 +259,9 @@ async function buildCompressedPatch(gitRoot, baseSha, workCommit, deadline) {
|
|
|
259
259
|
child.stdout.pipe(gzip);
|
|
260
260
|
});
|
|
261
261
|
}
|
|
262
|
-
export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS
|
|
262
|
+
export async function derivePrecomputeRequest(gitRoot, owner, repository, deadline = Date.now() + HOOK_WALL_MS,
|
|
263
|
+
/** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
|
|
264
|
+
budgetMs) {
|
|
263
265
|
// Capture this before git and compression work so delivery latency cannot
|
|
264
266
|
// make an older stop look newer than a later one.
|
|
265
267
|
const observedAt = Date.now();
|
|
@@ -287,7 +289,7 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
|
|
|
287
289
|
cacheKey,
|
|
288
290
|
patchGzBase64: patchGzip.toString('base64'),
|
|
289
291
|
patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
|
|
290
|
-
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle } }),
|
|
292
|
+
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }) } }),
|
|
291
293
|
},
|
|
292
294
|
};
|
|
293
295
|
}
|
|
@@ -452,14 +454,31 @@ function printResult(result) {
|
|
|
452
454
|
}
|
|
453
455
|
/** The hook, an explicit precompute and `haystack verify` all capture here.
|
|
454
456
|
* The repository defaults to origin's, as the hook's does. */
|
|
455
|
-
|
|
457
|
+
/** The repository's crawl time: .haystack.json crawl.minutes at the git root, whole minutes from 1 to 30 (CRAWL-V1 amendment 8),
|
|
458
|
+
* or undefined when the file or the field is absent. A value out of range fails the capture, never silently becomes the default. */
|
|
459
|
+
function configuredCrawlMinutes(gitRoot) {
|
|
460
|
+
const path = join(gitRoot, '.haystack.json');
|
|
461
|
+
if (!existsSync(path))
|
|
462
|
+
return undefined;
|
|
463
|
+
const minutes = JSON.parse(readFileSync(path, 'utf8')).crawl?.minutes;
|
|
464
|
+
if (minutes === undefined)
|
|
465
|
+
return undefined;
|
|
466
|
+
if (typeof minutes !== 'number' || !Number.isInteger(minutes) || minutes * 60_000 < CRAWL_BUDGET_MIN_MS || minutes * 60_000 > CRAWL_BUDGET_MAX_MS) {
|
|
467
|
+
throw new PrecomputeFailure(`.haystack.json crawl.minutes must be a whole number from ${CRAWL_BUDGET_MIN_MS / 60_000} to ${CRAWL_BUDGET_MAX_MS / 60_000}.`);
|
|
468
|
+
}
|
|
469
|
+
return minutes;
|
|
470
|
+
}
|
|
471
|
+
export async function captureCheckout(deadline, repository,
|
|
472
|
+
/** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
|
|
473
|
+
minutes) {
|
|
456
474
|
const gitRoot = findGitRoot();
|
|
457
475
|
if (!gitRoot) {
|
|
458
476
|
throw new PrecomputeFailure('Run this command from a git repository.');
|
|
459
477
|
}
|
|
460
478
|
const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
|
|
461
479
|
const target = repository ?? resolveOriginRepository(gitRoot);
|
|
462
|
-
const
|
|
480
|
+
const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
|
|
481
|
+
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000);
|
|
463
482
|
return { metadataDirectory, derivation };
|
|
464
483
|
}
|
|
465
484
|
/** The explicit (non-hook) submission of a capture: one synchronous
|
package/dist/commands/verify.js
CHANGED
|
@@ -8,20 +8,22 @@
|
|
|
8
8
|
* precompute` sends when there is none, or when the one found ended cancelled
|
|
9
9
|
* or incomplete or is being cancelled (the service runs it again). It follows
|
|
10
10
|
* the acknowledged run by its id. It then waits (no wall-time cap: Ctrl-C
|
|
11
|
-
* stops waiting, never the crawl)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* stops waiting, never the crawl), each read held by the service until the
|
|
12
|
+
* crawl changes (amendment 9), printing each bug as the crawl's answers bring
|
|
13
|
+
* it; it stops at the crawl's answer at its budget, or when the crawl ends, and
|
|
14
|
+
* prints where the change shows up in the running app and what the crawl found.
|
|
15
|
+
* The hosted fleet run of pushed commits is `haystack verify hosted start|status`.
|
|
14
16
|
*/
|
|
15
17
|
import chalk from 'chalk';
|
|
16
18
|
import { withSchema } from '../schema.js';
|
|
17
19
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
18
20
|
import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
|
|
19
|
-
import { gatewayFetch } from './case-batch.js';
|
|
20
|
-
import { CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, } from './crawl-contract.js';
|
|
21
|
+
import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
|
|
22
|
+
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
|
|
21
23
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
22
24
|
const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
+
/** A held read (waitAfter): the service's hold, then the time any read gets. */
|
|
26
|
+
const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
|
|
25
27
|
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
26
28
|
const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
27
29
|
const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
|
|
@@ -136,21 +138,21 @@ function checkSpot(value) {
|
|
|
136
138
|
|| (reached.page !== null && typeof reached.page !== 'string')))
|
|
137
139
|
invalid('how a changed spot was reached');
|
|
138
140
|
}
|
|
139
|
-
|
|
141
|
+
/** The published results' shape, shared by the sealed manifest and (amendment 9) an answer; `what` names which. */
|
|
142
|
+
function checkResults(value, view, what) {
|
|
140
143
|
if (!isRecord(value) || value.schemaVersion !== 1)
|
|
141
|
-
invalid(
|
|
142
|
-
//
|
|
144
|
+
invalid(what);
|
|
145
|
+
// Published results belong to exactly this crawl and this revision.
|
|
143
146
|
if (value.runId !== view.runId || value.repository !== view.repository || value.baseCommit !== view.baseCommit
|
|
144
|
-
|| value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle
|
|
145
|
-
|
|
146
|
-
invalid('results that belong to a different crawl');
|
|
147
|
+
|| value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle) {
|
|
148
|
+
invalid(`${what} from a different crawl`);
|
|
147
149
|
}
|
|
148
150
|
if (typeof value.attemptId !== 'string' || !isCount(value.bugs) || !isCount(value.notFinishedInTime))
|
|
149
|
-
invalid(
|
|
151
|
+
invalid(what);
|
|
150
152
|
if (value.worlds !== null && (!isRecord(value.worlds) || typeof value.worlds.base !== 'string'
|
|
151
153
|
|| typeof value.worlds.head !== 'string'))
|
|
152
|
-
invalid(
|
|
153
|
-
checkReuse(value.reuse,
|
|
154
|
+
invalid(what);
|
|
155
|
+
checkReuse(value.reuse, what);
|
|
154
156
|
if (!isRecord(value.timings) || !Object.values(value.timings)
|
|
155
157
|
.every(ms => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0))
|
|
156
158
|
invalid('its stage timings');
|
|
@@ -170,6 +172,18 @@ function checkManifest(value, view) {
|
|
|
170
172
|
if (value.error !== null)
|
|
171
173
|
checkError(value.error, 'its error');
|
|
172
174
|
}
|
|
175
|
+
function checkManifest(value, view) {
|
|
176
|
+
checkResults(value, view, 'its results');
|
|
177
|
+
if (value.status !== view.status)
|
|
178
|
+
invalid('its results from a different crawl');
|
|
179
|
+
}
|
|
180
|
+
/** Amendment 9: what the crawl found so far, or its answer at the budget: the manifest's shape, of this same crawl. */
|
|
181
|
+
function checkAnswer(value, view) {
|
|
182
|
+
checkResults(value, view, 'its answer');
|
|
183
|
+
if ((value.status !== 'exploring' && value.status !== 'answered') || !Number.isSafeInteger(value.n) || value.n < 1
|
|
184
|
+
|| !isCount(value.atSeconds))
|
|
185
|
+
invalid('its answer');
|
|
186
|
+
}
|
|
173
187
|
/** Accepts only a well-formed crawl of exactly this capture's revision. Another
|
|
174
188
|
* revision's crawl is refused, never shown. The change title is not checked
|
|
175
189
|
* here: the newest crawl of a revision may carry another title, which the
|
|
@@ -209,7 +223,19 @@ export function parseCrawlView(value, expected) {
|
|
|
209
223
|
invalid('results before it finished');
|
|
210
224
|
checkManifest(value.manifest, value);
|
|
211
225
|
}
|
|
212
|
-
|
|
226
|
+
// Amendment 8: a service from before it sends no budgetMs, and every crawl it ran was asked for the default (Astra review).
|
|
227
|
+
if (value.budgetMs !== undefined && value.budgetMs !== null && (typeof value.budgetMs !== 'number' || !Number.isInteger(value.budgetMs)
|
|
228
|
+
|| value.budgetMs < CRAWL_BUDGET_MIN_MS || value.budgetMs > CRAWL_BUDGET_MAX_MS))
|
|
229
|
+
invalid('its time');
|
|
230
|
+
// Amendment 9: a service from before it sends no answer. An answer stands in for results not yet sealed, never beside them.
|
|
231
|
+
let answer = null;
|
|
232
|
+
if (value.answer !== undefined && value.answer !== null) {
|
|
233
|
+
if (value.manifest !== null)
|
|
234
|
+
invalid('an answer beside its sealed results');
|
|
235
|
+
checkAnswer(value.answer, value);
|
|
236
|
+
answer = value.answer;
|
|
237
|
+
}
|
|
238
|
+
return { ...value, budgetMs: value.budgetMs ?? null, answer };
|
|
213
239
|
}
|
|
214
240
|
/* ------------------------------------------------------------------- read */
|
|
215
241
|
/** The change title is part of a crawl's identity and informs its judge: a
|
|
@@ -225,12 +251,19 @@ async function responseBody(response) {
|
|
|
225
251
|
throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
226
252
|
}
|
|
227
253
|
}
|
|
254
|
+
/** A read, held by the service when `waitAfter` (the updatedAt the caller holds) is given: until the
|
|
255
|
+
* crawl's updatedAt changes, or CRAWL_WAIT_MAX_MS passes (amendment 9). */
|
|
256
|
+
function read(path, token, waitAfter) {
|
|
257
|
+
return waitAfter === undefined
|
|
258
|
+
? gatewayFetch(path, token)
|
|
259
|
+
: gatewayFetch(`${path}&waitAfter=${encodeURIComponent(waitAfter)}`, token, { method: 'GET', timeoutMs: HELD_READ_TIMEOUT_MS });
|
|
260
|
+
}
|
|
228
261
|
/** The caller's newest crawl of this capture's revision, or null. Its change
|
|
229
262
|
* title may differ from the capture's (sameChange). */
|
|
230
|
-
async function readCrawl(identity, token) {
|
|
263
|
+
async function readCrawl(identity, token, waitAfter) {
|
|
231
264
|
const path = `${CRAWLS_PATH}?repository=${encodeURIComponent(identity.repository)}`
|
|
232
265
|
+ `&workCommit=${encodeURIComponent(identity.workCommit)}`;
|
|
233
|
-
const response = await
|
|
266
|
+
const response = await read(path, token, waitAfter);
|
|
234
267
|
if (!response.ok)
|
|
235
268
|
throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}`);
|
|
236
269
|
const body = await responseBody(response);
|
|
@@ -239,10 +272,10 @@ async function readCrawl(identity, token) {
|
|
|
239
272
|
return body.crawl === null ? null : parseCrawlView(body.crawl, identity);
|
|
240
273
|
}
|
|
241
274
|
/** One crawl by its run id; it must be exactly this capture, change title included. */
|
|
242
|
-
async function readCrawlRun(runId, identity, token) {
|
|
275
|
+
async function readCrawlRun(runId, identity, token, waitAfter) {
|
|
243
276
|
if (!RUN_ID.test(runId))
|
|
244
277
|
invalid('an acknowledged run id');
|
|
245
|
-
const response = await
|
|
278
|
+
const response = await read(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token, waitAfter);
|
|
246
279
|
if (!response.ok)
|
|
247
280
|
throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}/${runId}`);
|
|
248
281
|
const view = parseCrawlView(await responseBody(response), identity);
|
|
@@ -270,14 +303,23 @@ function errorWords(error) {
|
|
|
270
303
|
function currentStep(view) {
|
|
271
304
|
return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
|
|
272
305
|
}
|
|
306
|
+
/** The crawl's results: its sealed manifest, else (amendment 9) its answer at the budget, which has the same findings and reach. */
|
|
307
|
+
function results(view) {
|
|
308
|
+
return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
|
|
309
|
+
}
|
|
310
|
+
/** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
|
|
311
|
+
* done; only its shutdown remains) reads as completed. */
|
|
312
|
+
function reportedStatus(view) {
|
|
313
|
+
return !TERMINAL.has(view.status) && view.answer?.status === 'answered' ? 'completed' : view.status;
|
|
314
|
+
}
|
|
273
315
|
function headline(view) {
|
|
274
|
-
const bugs = view
|
|
316
|
+
const bugs = results(view)?.bugs ?? view.totals.bugs;
|
|
275
317
|
const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
|
|
276
|
-
switch (view
|
|
318
|
+
switch (reportedStatus(view)) {
|
|
277
319
|
case 'completed':
|
|
278
320
|
return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
|
|
279
321
|
case 'incomplete': {
|
|
280
|
-
const error = view
|
|
322
|
+
const error = results(view)?.error ?? view.error;
|
|
281
323
|
return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
|
|
282
324
|
}
|
|
283
325
|
case 'cancelled':
|
|
@@ -311,15 +353,17 @@ function findingLines(finding, position) {
|
|
|
311
353
|
return lines;
|
|
312
354
|
}
|
|
313
355
|
export function formatCrawl(view) {
|
|
356
|
+
const status = reportedStatus(view);
|
|
314
357
|
const lines = [chalk.bold(headline(view))];
|
|
315
|
-
const
|
|
316
|
-
|
|
358
|
+
const published = results(view);
|
|
359
|
+
const error = published?.error ?? view.error;
|
|
360
|
+
if (error && status !== 'completed')
|
|
317
361
|
lines.push(chalk.dim(` Details: ${safe(error.message)}`));
|
|
318
|
-
lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(`
|
|
362
|
+
lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(` Time: ${view.clocks.crawlBudgetMs > 0 ? `${Math.round(view.clocks.crawlBudgetMs / 1000)} s` : timeWords(view.budgetMs)}`
|
|
363
|
+
+ `${view.budgetMs === null ? ' (the default; set another with --minutes or .haystack.json crawl.minutes)' : ' (asked for)'}.`), chalk.dim(` Crawl ${view.runId} of ${safe(view.repository)}: base ${view.baseCommit.slice(0, 12)},`
|
|
319
364
|
+ ` your change ${view.workCommit.slice(0, 12)}.`));
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
lines.push('', TERMINAL.has(view.status)
|
|
365
|
+
if (published === null) {
|
|
366
|
+
lines.push('', TERMINAL.has(status)
|
|
323
367
|
? 'No results were published for this crawl.'
|
|
324
368
|
: 'Where your change shows up in the app, and what the crawl found, appear when it finishes.');
|
|
325
369
|
return lines.join('\n');
|
|
@@ -330,55 +374,60 @@ export function formatCrawl(view) {
|
|
|
330
374
|
return lines.join('\n');
|
|
331
375
|
}
|
|
332
376
|
lines.push('', chalk.bold('Where your change shows up in the app'));
|
|
333
|
-
if (
|
|
377
|
+
if (published.reach.spots.length === 0)
|
|
334
378
|
lines.push(' No changed spots were recorded.');
|
|
335
|
-
for (const spot of
|
|
379
|
+
for (const spot of published.reach.spots)
|
|
336
380
|
lines.push(...spotLines(spot));
|
|
337
381
|
lines.push('', chalk.bold('What the crawl found'));
|
|
338
382
|
// Bugs first, then the undecided, then what the judge called intended.
|
|
339
|
-
const findings = VERDICTS.flatMap(verdict =>
|
|
383
|
+
const findings = VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict));
|
|
340
384
|
if (findings.length === 0)
|
|
341
385
|
lines.push(' No confirmed differences between the app with and without your change.');
|
|
342
386
|
findings.forEach((finding, index) => lines.push(...findingLines(finding, index + 1)));
|
|
343
|
-
if (
|
|
344
|
-
lines.push(` ${plural(
|
|
387
|
+
if (published.notFinishedInTime > 0) {
|
|
388
|
+
lines.push(` ${plural(published.notFinishedInTime, 'difference')} ${published.notFinishedInTime === 1 ? 'was' : 'were'}`
|
|
345
389
|
+ ' still being checked when its time ran out.');
|
|
346
390
|
}
|
|
347
391
|
lines.push('', chalk.bold('What the crawl never ran'));
|
|
348
|
-
if (
|
|
392
|
+
if (published.reach.unreachedChangedFiles.length === 0)
|
|
349
393
|
lines.push(' Every changed file ran at least once.');
|
|
350
394
|
else {
|
|
351
395
|
lines.push(' Changed files no path ran:');
|
|
352
|
-
for (const file of
|
|
396
|
+
for (const file of published.reach.unreachedChangedFiles)
|
|
353
397
|
lines.push(` ${safe(file)}`);
|
|
354
398
|
}
|
|
355
|
-
const blast =
|
|
399
|
+
const blast = published.reach.blast;
|
|
356
400
|
if (blast !== null)
|
|
357
401
|
lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
|
|
358
|
-
|
|
402
|
+
// Only a finished crawl has proven its cleanup: an answered one is still shutting its machines down (Bugbot).
|
|
403
|
+
if (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0) {
|
|
359
404
|
lines.push('', chalk.yellow(` ${plural(view.totals.cleanupUnproven, 'machine')} it used could not be proven shut down.`));
|
|
360
405
|
}
|
|
361
406
|
return lines.join('\n');
|
|
362
407
|
}
|
|
363
|
-
/** Case-batch status conventions: 0 finished
|
|
364
|
-
*
|
|
408
|
+
/** Case-batch status conventions: 0 finished (or answered: amendment 9, its work
|
|
409
|
+
* is done and only its shutdown remains), or still running with --no-wait;
|
|
410
|
+
* 2 ended without finishing (incomplete, cancelled) or finished with cleanup
|
|
411
|
+
* unproven (an answered crawl has not finished its cleanup, so it is not judged); bugs
|
|
365
412
|
* found never change it. The command's own failures exit 1 elsewhere. */
|
|
366
413
|
export function crawlExitCode(view) {
|
|
367
|
-
|
|
414
|
+
const status = reportedStatus(view);
|
|
415
|
+
if (!TERMINAL.has(status))
|
|
368
416
|
return 0;
|
|
369
|
-
if (
|
|
417
|
+
if (status !== 'completed' || (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0))
|
|
370
418
|
return 2;
|
|
371
419
|
return 0;
|
|
372
420
|
}
|
|
373
421
|
/* ---------------------------------------------------------------- command */
|
|
374
|
-
|
|
422
|
+
/** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
|
|
423
|
+
function minutesOption(value) {
|
|
375
424
|
if (value === undefined)
|
|
376
|
-
return
|
|
377
|
-
const
|
|
378
|
-
if (!Number.
|
|
379
|
-
throw new Error(`--
|
|
425
|
+
return undefined;
|
|
426
|
+
const minutes = Number(value);
|
|
427
|
+
if (!Number.isInteger(minutes) || minutes * 60_000 < CRAWL_BUDGET_MIN_MS || minutes * 60_000 > CRAWL_BUDGET_MAX_MS) {
|
|
428
|
+
throw new Error(`--minutes must be a whole number from ${CRAWL_BUDGET_MIN_MS / 60_000} to ${CRAWL_BUDGET_MAX_MS / 60_000}.`);
|
|
380
429
|
}
|
|
381
|
-
return
|
|
430
|
+
return minutes;
|
|
382
431
|
}
|
|
383
432
|
function repositoryOverride(value) {
|
|
384
433
|
if (value === undefined)
|
|
@@ -389,15 +438,39 @@ function repositoryOverride(value) {
|
|
|
389
438
|
}
|
|
390
439
|
return { owner: parts[0], repository: parts[1] };
|
|
391
440
|
}
|
|
392
|
-
function
|
|
393
|
-
return new
|
|
441
|
+
function clock() {
|
|
442
|
+
return new Date().toTimeString().slice(0, 8);
|
|
394
443
|
}
|
|
395
444
|
function progress(line) {
|
|
396
|
-
console.error(chalk.dim(`${
|
|
445
|
+
console.error(chalk.dim(`${clock()} ${line}`));
|
|
446
|
+
}
|
|
447
|
+
function clip(text, maximum = 60) {
|
|
448
|
+
return text.length <= maximum ? text : `${text.slice(0, maximum - 1)}…`;
|
|
449
|
+
}
|
|
450
|
+
/** A finding's steps in one line: where it starts, then its steps, the middle of a long path elided. */
|
|
451
|
+
function shortSteps(finding) {
|
|
452
|
+
const steps = finding.steps.map(step => clip(safe(step)));
|
|
453
|
+
const shown = steps.length <= 4 ? steps : [steps[0], `(${steps.length - 3} more steps)`, ...steps.slice(-2)];
|
|
454
|
+
return [`from ${clip(safe(finding.start))}`, ...shown].join(' > ');
|
|
397
455
|
}
|
|
398
|
-
/**
|
|
399
|
-
|
|
400
|
-
|
|
456
|
+
/** Prints each bug the view's answer brings that was not printed before (by finding id), one line each. */
|
|
457
|
+
function printNewBugs(view, printed) {
|
|
458
|
+
const answer = view.answer;
|
|
459
|
+
if (answer === null)
|
|
460
|
+
return;
|
|
461
|
+
for (const finding of answer.findings) {
|
|
462
|
+
// Keyed by run and attempt too: another run or a retried attempt may reuse a finding id (Astra review).
|
|
463
|
+
const key = `${view.runId}\0${answer.attemptId}\0${finding.id}`;
|
|
464
|
+
if (finding.verdict !== 'bug' || printed.has(key))
|
|
465
|
+
continue;
|
|
466
|
+
printed.add(key);
|
|
467
|
+
console.error(`${chalk.dim(clock())} ${chalk.red(VERDICT_WORDS.bug)}: ${shortSteps(finding)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
|
|
471
|
+
* service until the crawl changes, so there is no pause between reads. No wall-time cap: an interrupt stops waiting
|
|
472
|
+
* and leaves the crawl running. */
|
|
473
|
+
async function waitForCrawl(first, identity, token) {
|
|
401
474
|
const interrupted = () => {
|
|
402
475
|
console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
|
|
403
476
|
process.exit(130);
|
|
@@ -405,21 +478,27 @@ async function waitForCrawl(first, identity, token, everyMs) {
|
|
|
405
478
|
process.once('SIGINT', interrupted);
|
|
406
479
|
try {
|
|
407
480
|
let view = first;
|
|
481
|
+
const printed = new Set();
|
|
408
482
|
progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
|
|
409
483
|
progress(currentStep(view));
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
// A newer
|
|
416
|
-
|
|
417
|
-
|
|
484
|
+
printNewBugs(view, printed);
|
|
485
|
+
while (!TERMINAL.has(reportedStatus(view))) {
|
|
486
|
+
// The followed crawl's own read is held, so only a change to it wakes the wait (Astra review: a capture read could sit up to
|
|
487
|
+
// 25 s on another title's crawl of the same code).
|
|
488
|
+
let next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
|
|
489
|
+
// A newer stop of the same change supersedes this crawl: once it is superseded or being cancelled, the capture's newest crawl
|
|
490
|
+
// is read at once (not held) and followed if it is this same change.
|
|
491
|
+
if (next.supersededBy !== null || next.cancelRequested) {
|
|
492
|
+
const newest = await readCrawl(identity, token);
|
|
493
|
+
if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
|
|
494
|
+
next = newest;
|
|
495
|
+
}
|
|
418
496
|
if (next.runId !== view.runId)
|
|
419
497
|
progress(`A newer crawl of this same change took over (${next.runId}).`);
|
|
420
|
-
if (!TERMINAL.has(next
|
|
498
|
+
if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
|
|
421
499
|
progress(currentStep(next));
|
|
422
500
|
}
|
|
501
|
+
printNewBugs(next, printed);
|
|
423
502
|
view = next;
|
|
424
503
|
}
|
|
425
504
|
return view;
|
|
@@ -428,9 +507,14 @@ async function waitForCrawl(first, identity, token, everyMs) {
|
|
|
428
507
|
process.removeListener('SIGINT', interrupted);
|
|
429
508
|
}
|
|
430
509
|
}
|
|
510
|
+
/** A found crawl of the same code that is a different crawl (another title, another time): a new one starts, none is run again. */
|
|
511
|
+
const anotherCrawl = (stale) => stale === 'retitled' || stale === 'other-time';
|
|
512
|
+
const timeWords = (budgetMs) => budgetMs === null ? 'the default time' : `${budgetMs / 60_000} min`;
|
|
431
513
|
function staleCrawl(found, identity) {
|
|
432
514
|
if (!sameChange(found, identity))
|
|
433
515
|
return 'retitled';
|
|
516
|
+
if (found.budgetMs !== identity.budgetMs)
|
|
517
|
+
return 'other-time';
|
|
434
518
|
if (found.status === 'cancelled' || found.status === 'incomplete')
|
|
435
519
|
return found.status;
|
|
436
520
|
return !TERMINAL.has(found.status) && found.cancelRequested ? 'cancelling' : null;
|
|
@@ -438,6 +522,7 @@ function staleCrawl(found, identity) {
|
|
|
438
522
|
function staleWords(found, stale) {
|
|
439
523
|
switch (stale) {
|
|
440
524
|
case 'retitled': return `The last crawl of this code ran under another title, "${safe(found.changeTitle)}" (${found.runId})`;
|
|
525
|
+
case 'other-time': return `The last crawl of this code was asked for ${timeWords(found.budgetMs)} (${found.runId})`;
|
|
441
526
|
case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
|
|
442
527
|
case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
|
|
443
528
|
case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
|
|
@@ -475,7 +560,7 @@ async function submitCapture(capture, token, found) {
|
|
|
475
560
|
? `No crawl existed for this change yet; started one (${crawl.runId}).`
|
|
476
561
|
: `The service already had a crawl for this change (${crawl.runId}).` };
|
|
477
562
|
}
|
|
478
|
-
if (found.stale
|
|
563
|
+
if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
|
|
479
564
|
return { runId: crawl.runId, line: `${before}; asked the service to run it again.` };
|
|
480
565
|
}
|
|
481
566
|
return { runId: crawl.runId, line: crawl.status === 'queued'
|
|
@@ -483,13 +568,12 @@ async function submitCapture(capture, token, found) {
|
|
|
483
568
|
: `${before}; the service already had one for this change (${crawl.runId}).` };
|
|
484
569
|
}
|
|
485
570
|
export async function verifyCommand(options) {
|
|
486
|
-
const everyMs = intervalMs(options.interval);
|
|
487
571
|
// In --json mode stdout carries the one document; every other line goes to stderr.
|
|
488
572
|
const note = (line) => { if (options.json)
|
|
489
573
|
console.error(line);
|
|
490
574
|
else
|
|
491
575
|
console.log(line); };
|
|
492
|
-
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo));
|
|
576
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo), minutesOption(options.minutes));
|
|
493
577
|
const request = capture.derivation.request;
|
|
494
578
|
if (!request.crawl) {
|
|
495
579
|
note(`Nothing to verify: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
|
|
@@ -503,6 +587,7 @@ export async function verifyCommand(options) {
|
|
|
503
587
|
workCommit: request.workCommit,
|
|
504
588
|
treeSha: request.crawl.treeSha,
|
|
505
589
|
changeTitle: request.crawl.changeTitle,
|
|
590
|
+
budgetMs: request.crawl.budgetMs ?? null,
|
|
506
591
|
};
|
|
507
592
|
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
|
|
508
593
|
let view = await readCrawl(identity, auth.token);
|
|
@@ -518,12 +603,12 @@ export async function verifyCommand(options) {
|
|
|
518
603
|
return;
|
|
519
604
|
}
|
|
520
605
|
view = await readCrawlRun(submitted.runId, identity, auth.token);
|
|
521
|
-
if (found !== null && found.stale
|
|
606
|
+
if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
|
|
522
607
|
note('The service did not run it again; its last result follows.');
|
|
523
608
|
}
|
|
524
609
|
}
|
|
525
|
-
if (options.wait !== false && !TERMINAL.has(view
|
|
526
|
-
view = await waitForCrawl(view, identity, auth.token
|
|
610
|
+
if (options.wait !== false && !TERMINAL.has(reportedStatus(view))) {
|
|
611
|
+
view = await waitForCrawl(view, identity, auth.token);
|
|
527
612
|
}
|
|
528
613
|
if (options.json)
|
|
529
614
|
process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: view }), null, 2)}\n`);
|
package/dist/index.js
CHANGED
|
@@ -208,7 +208,7 @@ const verify = program
|
|
|
208
208
|
.option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
|
|
209
209
|
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
210
210
|
.option('--no-wait', 'Print the crawl\'s current state and return without waiting')
|
|
211
|
-
.option('--
|
|
211
|
+
.option('--minutes <n>', 'How long the crawl may take, 1-30 (default: .haystack.json crawl.minutes, else 3)')
|
|
212
212
|
.option('--json', 'The crawl as one JSON document on stdout (see `haystack schema verify`)')
|
|
213
213
|
.addHelpText('after', `
|
|
214
214
|
Run inside a git checkout; nothing needs to be committed or pushed. It captures
|
|
@@ -222,15 +222,20 @@ again), says so, and follows that crawl.
|
|
|
222
222
|
|
|
223
223
|
A crawl builds the app with and without your change, starts from the changed
|
|
224
224
|
code, explores outward with both side by side, and double-checks and judges
|
|
225
|
-
every difference
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
225
|
+
every difference it can within its time: 3 minutes unless the repository's
|
|
226
|
+
.haystack.json sets crawl.minutes or --minutes says otherwise (1 to 30).
|
|
227
|
+
Differences it had no time to check are listed as not finished in time. The
|
|
228
|
+
command waits for the crawl, printing each step and each bug the moment the
|
|
229
|
+
crawl finds it; it finishes when the crawl answers, as soon as its time is up,
|
|
230
|
+
without waiting for the crawl's machines to shut down. Ctrl-C stops waiting,
|
|
231
|
+
never the crawl. It then prints where your change shows up in the app (each
|
|
232
|
+
changed spot, how far the crawl got with it, and the steps that reached it),
|
|
233
|
+
what the crawl found (bugs first, with the steps to see each), and the changed
|
|
234
|
+
code it never ran.
|
|
230
235
|
|
|
231
236
|
Exit codes:
|
|
232
|
-
0 the crawl finished (the bugs it found are in the output), or
|
|
233
|
-
still running and --no-wait was given
|
|
237
|
+
0 the crawl answered or finished (the bugs it found are in the output), or
|
|
238
|
+
it is still running and --no-wait was given
|
|
234
239
|
1 the command failed, or no crawl could be started for this change
|
|
235
240
|
2 the crawl ended without finishing: it stopped early or was cancelled
|
|
236
241
|
(a newer stop in the repository replaces an older crawl), or the
|
|
@@ -241,6 +246,7 @@ The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
|
|
|
241
246
|
Examples:
|
|
242
247
|
haystack verify
|
|
243
248
|
haystack verify --no-wait
|
|
249
|
+
haystack verify --minutes 10
|
|
244
250
|
haystack verify --json
|
|
245
251
|
haystack verify history owner/repo --limit 20
|
|
246
252
|
haystack verify hosted status cv_<48 lowercase hex characters> --wait
|
package/dist/types.js
CHANGED
|
@@ -294,6 +294,9 @@ const PreferencesSchema = z.object({
|
|
|
294
294
|
export const HaystackConfigSchema = z.object({
|
|
295
295
|
/** Config version (must be "1") */
|
|
296
296
|
version: z.literal('1'),
|
|
297
|
+
/** Hosted crawls of this repository (`haystack verify`, the stop hook): how long one may take, whole minutes from 1 to 30.
|
|
298
|
+
* Absent: the service's default (3 minutes). `haystack verify --minutes` overrides it for one run. */
|
|
299
|
+
crawl: z.object({ minutes: z.number().int().min(1).max(30).optional() }).strict().optional(),
|
|
297
300
|
/** Project name (for display) */
|
|
298
301
|
name: z.string().optional(),
|
|
299
302
|
/** Repository-level Haystack behavior */
|
package/package.json
CHANGED
package/schemas/verify.v1.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://docs.haystackeditor.com/cli/schemas/verify.v1.json",
|
|
4
4
|
"title": "Haystack verify: the crawl of the current change (CRAWL-V1)",
|
|
5
|
-
"description": "Machine-readable output of `haystack verify --json`: the caller's crawl of the exact captured checkout (its code and its change title), as GET /api/agent/cloud-verifier/crawls?repository=&workCommit= or, for a crawl the command just submitted, GET /api/agent/cloud-verifier/crawls/:runId?repository= returns it, or null when no crawl of the capture could be started. The crawl shapes mirror infra/lambda/haystack-design-verifier-shared/crawl_contracts.ts (with
|
|
5
|
+
"description": "Machine-readable output of `haystack verify --json`: the caller's crawl of the exact captured checkout (its code and its change title), as GET /api/agent/cloud-verifier/crawls?repository=&workCommit= or, for a crawl the command just submitted, GET /api/agent/cloud-verifier/crawls/:runId?repository= returns it, or null when no crawl of the capture could be started. The crawl shapes mirror infra/lambda/haystack-design-verifier-shared/crawl_contracts.ts (with amendments 1, 8 and 9); that file wins where they differ.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": ["schema_version", "crawl"],
|
|
@@ -97,8 +97,8 @@
|
|
|
97
97
|
"findingIds": { "$ref": "#/$defs/strings" }
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
|
-
"
|
|
101
|
-
"description": "
|
|
100
|
+
"results": {
|
|
101
|
+
"description": "The published results' shape, shared by the sealed manifest and an answer; each sets its own status.",
|
|
102
102
|
"type": "object",
|
|
103
103
|
"required": ["schemaVersion", "runId", "attemptId", "repository", "baseCommit", "workCommit", "treeSha",
|
|
104
104
|
"changeTitle", "status", "worlds", "reuse", "timings", "bugs", "findings", "reach", "notFinishedInTime", "error"],
|
|
@@ -111,7 +111,6 @@
|
|
|
111
111
|
"workCommit": { "$ref": "#/$defs/commit" },
|
|
112
112
|
"treeSha": { "$ref": "#/$defs/commit" },
|
|
113
113
|
"changeTitle": { "type": "string", "maxLength": 200 },
|
|
114
|
-
"status": { "enum": ["completed", "incomplete", "cancelled"] },
|
|
115
114
|
"worlds": {
|
|
116
115
|
"oneOf": [
|
|
117
116
|
{ "type": "null" },
|
|
@@ -157,10 +156,27 @@
|
|
|
157
156
|
"error": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/error" }] }
|
|
158
157
|
}
|
|
159
158
|
},
|
|
159
|
+
"manifest": {
|
|
160
|
+
"description": "Present once the crawl is terminal and published.",
|
|
161
|
+
"allOf": [{ "$ref": "#/$defs/results" }],
|
|
162
|
+
"properties": {
|
|
163
|
+
"status": { "enum": ["completed", "incomplete", "cancelled"] }
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"answer": {
|
|
167
|
+
"description": "CRAWL-V1 amendment 9: the current attempt's latest answer while no sealed manifest exists: what the crawl found so far (exploring), or its answer once its time is up (answered), with the same findings and reach the sealed manifest will carry. Its findings carry no images.",
|
|
168
|
+
"allOf": [{ "$ref": "#/$defs/results" }],
|
|
169
|
+
"required": ["n", "atSeconds"],
|
|
170
|
+
"properties": {
|
|
171
|
+
"status": { "enum": ["exploring", "answered"] },
|
|
172
|
+
"n": { "type": "integer", "minimum": 1, "description": "The attempt's answers are numbered from 1." },
|
|
173
|
+
"atSeconds": { "type": "integer", "minimum": 0, "description": "How far into the crawl stage it was written." }
|
|
174
|
+
}
|
|
175
|
+
},
|
|
160
176
|
"crawlView": {
|
|
161
177
|
"type": "object",
|
|
162
|
-
"required": ["runId", "repository", "baseCommit", "workCommit", "treeSha", "changeTitle", "observedAt",
|
|
163
|
-
"createdAt", "updatedAt", "supersededBy", "cancelRequested", "manifest", "status", "stage", "clocks",
|
|
178
|
+
"required": ["runId", "repository", "baseCommit", "workCommit", "treeSha", "changeTitle", "budgetMs", "observedAt",
|
|
179
|
+
"createdAt", "updatedAt", "supersededBy", "cancelRequested", "manifest", "answer", "status", "stage", "clocks",
|
|
164
180
|
"reuse", "worlds", "totals"],
|
|
165
181
|
"properties": {
|
|
166
182
|
"runId": { "$ref": "#/$defs/runId" },
|
|
@@ -169,6 +185,8 @@
|
|
|
169
185
|
"workCommit": { "$ref": "#/$defs/commit" },
|
|
170
186
|
"treeSha": { "$ref": "#/$defs/commit" },
|
|
171
187
|
"changeTitle": { "type": "string", "maxLength": 200 },
|
|
188
|
+
"budgetMs": { "description": "The time the crawl was asked for (ms), or null for the service's default.",
|
|
189
|
+
"oneOf": [{ "type": "null" }, { "type": "integer", "minimum": 60000, "maximum": 1800000 }] },
|
|
172
190
|
"observedAt": { "$ref": "#/$defs/count" },
|
|
173
191
|
"createdAt": { "type": "string", "format": "date-time" },
|
|
174
192
|
"updatedAt": { "type": "string", "format": "date-time" },
|
|
@@ -210,7 +228,9 @@
|
|
|
210
228
|
}
|
|
211
229
|
},
|
|
212
230
|
"error": { "$ref": "#/$defs/error" },
|
|
213
|
-
"manifest": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/manifest" }] }
|
|
231
|
+
"manifest": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/manifest" }] },
|
|
232
|
+
"answer": { "description": "Null once the sealed manifest exists, and before the crawl's first answer.",
|
|
233
|
+
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/answer" }] }
|
|
214
234
|
}
|
|
215
235
|
}
|
|
216
236
|
}
|