@haystackeditor/cli 0.16.0 → 0.17.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 +31 -2
- package/dist/commands/case-batch-contract.js +93 -7
- package/dist/commands/case-batch.js +327 -29
- package/dist/commands/submit.js +32 -69
- package/dist/commands/verify-precompute.js +7 -1
- package/dist/index.js +56 -20
- package/dist/schema.js +2 -2
- package/dist/triage/astra.js +199 -0
- package/dist/triage/prompts.js +145 -180
- package/dist/triage/runner.js +99 -381
- package/dist/triage/types.js +2 -2
- package/dist/types.js +2 -6
- package/dist/utils/haystack-api.js +12 -0
- package/package.json +2 -1
- package/schemas/case-batch.v1.json +54 -3
- package/schemas/submit.v1.json +4 -2
package/README.md
CHANGED
|
@@ -218,7 +218,7 @@ haystack logout
|
|
|
218
218
|
|
|
219
219
|
### `haystack submit`
|
|
220
220
|
|
|
221
|
-
Create a PR from current changes. Runs pre-PR triage (code review, rules validation
|
|
221
|
+
Create a PR from current changes. Runs pre-PR triage (code review, rules validation), pushes your branch, and opens the PR.
|
|
222
222
|
|
|
223
223
|
```bash
|
|
224
224
|
haystack submit # Triage -> create PR -> wait for analysis
|
|
@@ -229,6 +229,28 @@ haystack submit --force # Skip triage checks
|
|
|
229
229
|
haystack submit --no-wait # Don't wait for analysis results
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
+
**Pre-PR triage**: two checkers run in parallel, each one structured call to
|
|
233
|
+
`gpt-6-astra` over the OpenAI Responses API at reasoning effort `max` (the
|
|
234
|
+
highest the API accepts for that model). The key comes from `OPENAI_API_KEY`
|
|
235
|
+
if set, else from the SSM SecureString `/haystack/secrets/shared/prod/OPENAI_API_KEY`
|
|
236
|
+
(us-west-2) through the default AWS credential chain (e.g. `AWS_PROFILE=prod`).
|
|
237
|
+
With neither, each checker reports `no OpenAI key (...)` and submit continues.
|
|
238
|
+
|
|
239
|
+
- **code-review** reads `git diff -U10 origin/<base>...HEAD` and reports objective bugs.
|
|
240
|
+
- **rules-validator** runs when `.haystack/pr-rules.yml` or an agent
|
|
241
|
+
instruction file (`CLAUDE.md`, `AGENTS.md`, `COPILOT.md`, `.cursorrules`,
|
|
242
|
+
`.github/copilot-instructions.md`) exists, and checks the diff against them.
|
|
243
|
+
|
|
244
|
+
Each finding carries `file`, `line`, `severity` (`error`/`warning`/`info`),
|
|
245
|
+
`category`, a one-sentence summary and a concrete failure scenario; results
|
|
246
|
+
are saved to `.haystack/triage/<checker>.json`. A finding with severity
|
|
247
|
+
`error` stops the submit (`--force` skips triage). A checker that cannot run
|
|
248
|
+
(missing key, API error, stalled stream) prints `code review failed: <error>`
|
|
249
|
+
and the submit continues. The reviewer sees only the diff, not the rest of the
|
|
250
|
+
repository. `--triage-timeout <sec>` (or `.haystack.json` `triage.timeoutMs`)
|
|
251
|
+
aborts a checker whose response stream has been silent that long (default
|
|
252
|
+
300s); there is no total-time cap.
|
|
253
|
+
|
|
232
254
|
The `--json` payload (`haystack schema submit`) reports the PR ref, the
|
|
233
255
|
resolved title/body and where each came from, auto-merge/auto-fix state with
|
|
234
256
|
its source (flag vs `.haystack.json`), and the analysis outcome in the shared
|
|
@@ -483,13 +505,20 @@ Knobs and what turning them does:
|
|
|
483
505
|
| `--max-concurrent <n>` | the case count, capped at 500 | Ceiling only. Admitted concurrency is the smaller of it and the fleet's free capacity; the run's receipt reports both. |
|
|
484
506
|
| `--per-case-wall-ms <ms>` | 120000 | A case that overruns is killed and recorded `UNKNOWN`/`wall-budget`, never dropped. Above the MVP's measured 103.8 s maximum, so it does not kill cases running at today's cost. |
|
|
485
507
|
| `--total-budget-ms <ms>` | 1800000 (the contract ceiling) | Bounds launches from the first launch. Cases already launched run to their own wall budget. |
|
|
508
|
+
| `--warm-settle-ms <ms>` | absent (0) | Each arm adopts only a warm clone that has been ready at least this long, 0 to the per-case wall. A clone adopted right after its restore attaches cold (quiet fleet: connectMs 5.8 s at 0 vs 1.1 s after 3.5-6 s of idle); each second of settle can delay a case's first create by up to a second. |
|
|
486
509
|
| `--idempotency-key <key>` | derived from the whole submission | An exact retry returns the original run; a new key is a new execution. |
|
|
487
510
|
| `--interval <seconds>` | 10 | Polling interval while `--watch` is set. |
|
|
488
511
|
| `--max-wall <minutes>` | 45 | Total polling wall. Hitting it prints the snapshot, says `timed_out`, and exits 2. |
|
|
489
512
|
|
|
490
513
|
`status` exits 0 for a `completed` batch with cleanup proven, and 2 for
|
|
491
514
|
`incomplete`, `cancelled`, unproven cleanup, or a wait that hits `--max-wall`.
|
|
492
|
-
Either way every per-case row is printed.
|
|
515
|
+
Either way every per-case row is printed. A `--json` row from a v4 batch also
|
|
516
|
+
carries `node_ids: { base1, head }` (the node each arm's VM was placed on) and
|
|
517
|
+
`failure: { source, arm, error_class, error_code, node_id }` (the typed fact
|
|
518
|
+
behind an `UNKNOWN` that is not an arrival or wall outcome, and behind an
|
|
519
|
+
`arrival-failed` whose two arms stalled on the same step, `same-step-stall`); each
|
|
520
|
+
is `null` when the outcome has none. A case in `--cases` may carry `repeatIndex` (1-16), which
|
|
521
|
+
must be part of its `contentDigest`, to run as a repeat next to its original.
|
|
493
522
|
|
|
494
523
|
### `haystack policy`
|
|
495
524
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
/** Bounds, copied verbatim from the frozen contract. */
|
|
18
|
-
export const CASE_BATCH_VERSION = 'case-batch-
|
|
18
|
+
export const CASE_BATCH_VERSION = 'case-batch-v4';
|
|
19
19
|
export const CASE_BATCH_MAX_CONCURRENT_CASES = 500;
|
|
20
20
|
export const CASE_BATCH_MAX_CASES = 5_000;
|
|
21
21
|
export const CASE_BATCH_MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
|
@@ -25,6 +25,8 @@ export const CASE_BATCH_MIN_TOTAL_BUDGET_MS = 10_000;
|
|
|
25
25
|
export const CASE_BATCH_MAX_TOTAL_BUDGET_MS = 30 * 60_000;
|
|
26
26
|
export const CASE_BATCH_VM_SLOTS_PER_CASE = 2;
|
|
27
27
|
export const CASE_BATCH_ID_PATTERN = /^[A-Za-z0-9_-]{1,160}$/u;
|
|
28
|
+
/** v4: `ProductCase.repeatIndex` is an integer from 1 to this; absent is the original experiment. */
|
|
29
|
+
export const CASE_BATCH_MAX_REPEAT_INDEX = 16;
|
|
28
30
|
/** `CombinationSearchStatus`, reused by the batch without change. */
|
|
29
31
|
export const CASE_BATCH_STATUSES = [
|
|
30
32
|
'queued', 'preparing', 'searching', 'draining', 'completed', 'incomplete', 'cancelled',
|
|
@@ -41,6 +43,18 @@ export const KNOWN_CASE_UNKNOWN_REASONS = [
|
|
|
41
43
|
'missing-state', 'missing-action', 'missing-harness', 'arrival-failed',
|
|
42
44
|
'comparison-unavailable', 'infrastructure', 'wall-budget', 'wall-watchdog', 'worker-exited',
|
|
43
45
|
];
|
|
46
|
+
/** MIRRORED RULES (v2) — `case_batch_contracts.ts`: a start value is a reference id or two joined by
|
|
47
|
+
* one '='; the action and observation kinds the runtime executes; the execution paths an outcome names. */
|
|
48
|
+
export const CASE_START_VALUE_PATTERN = /^(?=.{1,256}$)[A-Za-z0-9._:/-]+(?:=[A-Za-z0-9._:/-]+)?$/u;
|
|
49
|
+
export const CASE_BATCH_ACTION_KINDS = ['navigate', 'click', 'type', 'wait-for', 'exec', 'raw-pointer', 'key', 'focus',
|
|
50
|
+
'pointer-move', 'pointer-down', 'pointer-up', 'type-focused'];
|
|
51
|
+
export const CASE_BATCH_OBSERVATION_KINDS = ['dom-inventory', 'visible-text-digest', 'url', 'main-document',
|
|
52
|
+
'element-count', 'element-state', 'focused-element', 'storage-snapshot'];
|
|
53
|
+
export const CASE_EXECUTION_PATHS = ['resident-sealed-plan', 'sealed-plan', 'record-replay'];
|
|
54
|
+
/** v4: the sources a failure fact names today (`CASE_FAILURE_SOURCES` in the contract). Read-side, a
|
|
55
|
+
* source added later is still rendered, as an unrecognised unknown reason is: the list documents, it
|
|
56
|
+
* does not refuse. */
|
|
57
|
+
export const KNOWN_CASE_FAILURE_SOURCES = ['control-plane', 'transport', 'harness', 'runner'];
|
|
44
58
|
export class CaseBatchRequestValidationError extends TypeError {
|
|
45
59
|
name = 'CaseBatchRequestValidationError';
|
|
46
60
|
}
|
|
@@ -186,7 +200,9 @@ function jsonObject(value, what, maximumBytes = CASE_BATCH_MAX_INPUT_BYTES) {
|
|
|
186
200
|
export function parseProductCase(value, index) {
|
|
187
201
|
const row = object(value, `case ${index}`);
|
|
188
202
|
keys(row, ['id', 'contentDigest', 'conditionId', 'start', 'actions', 'arrivalCheck',
|
|
189
|
-
'observations', 'variantOf', 'provenance', 'surfaceId', 'finalControlId', 'staticReach', 'label'], ['arrivalActionIndex']);
|
|
203
|
+
'observations', 'variantOf', 'provenance', 'surfaceId', 'finalControlId', 'staticReach', 'label'], ['arrivalActionIndex', 'settingsDigest', 'startStateSnapshotIds', 'armCommits', 'publishScreenshots', 'repeatIndex']);
|
|
204
|
+
if (row.settingsDigest !== undefined && row.startStateSnapshotIds !== undefined)
|
|
205
|
+
fail(`case ${index} names its start state twice (settingsDigest and startStateSnapshotIds)`);
|
|
190
206
|
const id = text(row.id, `case ${index} id`, 160);
|
|
191
207
|
if (!CASE_BATCH_ID_PATTERN.test(id))
|
|
192
208
|
fail(`case ${index} id (queue identity charset is [A-Za-z0-9_-], 1-160)`);
|
|
@@ -199,8 +215,11 @@ export function parseProductCase(value, index) {
|
|
|
199
215
|
for (const [position, axis] of axes.entries()) {
|
|
200
216
|
if (['__proto__', 'constructor', 'prototype'].includes(axis))
|
|
201
217
|
fail(`case ${index} start (unsafe key)`);
|
|
202
|
-
|
|
203
|
-
|
|
218
|
+
const startValue = startRow[axis];
|
|
219
|
+
if (typeof startValue !== 'string' || !CASE_START_VALUE_PATTERN.test(startValue))
|
|
220
|
+
fail(`case ${index} start value for axis ${position} (${excerpt(axis)}) is not a reference id `
|
|
221
|
+
+ `or two joined by one '=': ${excerpt(String(startValue))}`);
|
|
222
|
+
start[referenceIdentity(axis, `case ${index} start axis ${position}`)] = startValue;
|
|
204
223
|
}
|
|
205
224
|
if (!Array.isArray(row.actions))
|
|
206
225
|
fail(`case ${index} actions`);
|
|
@@ -234,13 +253,45 @@ export function parseProductCase(value, index) {
|
|
|
234
253
|
contentDigest: hex(row.contentDigest, 64, `case ${index} contentDigest`),
|
|
235
254
|
conditionId: referenceIdentity(row.conditionId, `case ${index} conditionId`),
|
|
236
255
|
start,
|
|
237
|
-
actions: row.actions.map((action, position) =>
|
|
256
|
+
actions: row.actions.map((action, position) => {
|
|
257
|
+
const checked = jsonObject(action, `case ${index} action ${position}`, CASE_VALUE_MAX_BYTES);
|
|
258
|
+
if (typeof checked.kind !== 'string' || !CASE_BATCH_ACTION_KINDS.includes(checked.kind))
|
|
259
|
+
fail(`case ${index} action ${position} kind ${excerpt(String(checked.kind))} is not one the runtime executes`);
|
|
260
|
+
if (checked.coverage !== undefined && typeof checked.coverage !== 'boolean')
|
|
261
|
+
fail(`case ${index} action ${position} coverage must be a boolean`);
|
|
262
|
+
return checked;
|
|
263
|
+
}),
|
|
238
264
|
// Optional and additive: refused here when it does not name an action this
|
|
239
265
|
// case carries, rather than at execution where it costs two VMs to find.
|
|
240
266
|
...(row.arrivalActionIndex === undefined ? {}
|
|
241
267
|
: { arrivalActionIndex: integer(row.arrivalActionIndex, `case ${index} arrivalActionIndex`, 0, row.actions.length - 1) }),
|
|
242
268
|
arrivalCheck: jsonObject(row.arrivalCheck, `case ${index} arrivalCheck`, CASE_VALUE_MAX_BYTES),
|
|
243
|
-
observations: row.observations.map((observation, position) =>
|
|
269
|
+
observations: row.observations.map((observation, position) => {
|
|
270
|
+
const checked = jsonObject(observation, `case ${index} observation ${position}`, CASE_VALUE_MAX_BYTES);
|
|
271
|
+
if (typeof checked.kind !== 'string' || !CASE_BATCH_OBSERVATION_KINDS.includes(checked.kind))
|
|
272
|
+
fail(`case ${index} observation ${position} kind ${excerpt(String(checked.kind))} is not one the runtime reads`);
|
|
273
|
+
return checked;
|
|
274
|
+
}),
|
|
275
|
+
...(row.settingsDigest === undefined ? {} : { settingsDigest: hex(row.settingsDigest, 64, `case ${index} settingsDigest`) }),
|
|
276
|
+
...(row.startStateSnapshotIds === undefined ? {} : { startStateSnapshotIds: (() => {
|
|
277
|
+
const ids = object(row.startStateSnapshotIds, `case ${index} startStateSnapshotIds`);
|
|
278
|
+
keys(ids, ['base1', 'head']);
|
|
279
|
+
return { base1: hex(ids.base1, 16, `case ${index} startStateSnapshotIds.base1`, 'frz_'),
|
|
280
|
+
head: hex(ids.head, 16, `case ${index} startStateSnapshotIds.head`, 'frz_') };
|
|
281
|
+
})() }),
|
|
282
|
+
...(row.armCommits === undefined ? {} : { armCommits: (() => {
|
|
283
|
+
const commits = object(row.armCommits, `case ${index} armCommits`);
|
|
284
|
+
keys(commits, ['base1', 'head']);
|
|
285
|
+
return { base1: hex(commits.base1, 40, `case ${index} armCommits.base1`),
|
|
286
|
+
head: hex(commits.head, 40, `case ${index} armCommits.head`) };
|
|
287
|
+
})() }),
|
|
288
|
+
...(row.publishScreenshots === undefined ? {} : { publishScreenshots: (() => {
|
|
289
|
+
if (typeof row.publishScreenshots !== 'boolean')
|
|
290
|
+
fail(`case ${index} publishScreenshots must be a boolean`);
|
|
291
|
+
return row.publishScreenshots;
|
|
292
|
+
})() }),
|
|
293
|
+
...(row.repeatIndex === undefined ? {}
|
|
294
|
+
: { repeatIndex: integer(row.repeatIndex, `case ${index} repeatIndex`, 1, CASE_BATCH_MAX_REPEAT_INDEX) }),
|
|
244
295
|
variantOf: nullableIdentity(row.variantOf, `case ${index} variantOf`),
|
|
245
296
|
provenance: jsonObject(row.provenance, `case ${index} provenance`, CASE_VALUE_MAX_BYTES),
|
|
246
297
|
surfaceId: nullableIdentity(row.surfaceId, `case ${index} surfaceId`),
|
|
@@ -283,10 +334,12 @@ export function parseProductCases(value) {
|
|
|
283
334
|
return cases;
|
|
284
335
|
}
|
|
285
336
|
export function parseCaseBatchLimits(value) {
|
|
337
|
+
const perCaseWallMs = integer(value.perCaseWallMs, 'perCaseWallMs', CASE_BATCH_MIN_CASE_WALL_MS, CASE_BATCH_MAX_CASE_WALL_MS);
|
|
286
338
|
return {
|
|
287
339
|
maxConcurrentCases: integer(value.maxConcurrentCases, 'maxConcurrentCases', 1, CASE_BATCH_MAX_CONCURRENT_CASES),
|
|
288
|
-
perCaseWallMs
|
|
340
|
+
perCaseWallMs,
|
|
289
341
|
totalBudgetMs: integer(value.totalBudgetMs, 'totalBudgetMs', CASE_BATCH_MIN_TOTAL_BUDGET_MS, CASE_BATCH_MAX_TOTAL_BUDGET_MS),
|
|
342
|
+
...(value.warmSettleMs === undefined ? {} : { warmSettleMs: integer(value.warmSettleMs, 'warmSettleMs', 0, perCaseWallMs) }),
|
|
290
343
|
};
|
|
291
344
|
}
|
|
292
345
|
export function parseCaseBatchRepository(value) {
|
|
@@ -353,6 +406,14 @@ export function parseCaseBatchSource(value, baseCommit, headCommit) {
|
|
|
353
406
|
}
|
|
354
407
|
/** Assemble and bound the request exactly as it goes on the wire. */
|
|
355
408
|
export function buildCaseBatchRequest(input) {
|
|
409
|
+
// v3, as the gateway checks it: every declared arm commit is the batch's base or head commit.
|
|
410
|
+
for (const [index, row] of input.cases.entries()) {
|
|
411
|
+
for (const arm of ['base1', 'head']) {
|
|
412
|
+
const commit = row.armCommits?.[arm];
|
|
413
|
+
if (commit !== undefined && commit !== input.baseCommit && commit !== input.headCommit)
|
|
414
|
+
fail(`case ${index} armCommits.${arm} ${commit} is neither --base nor --head`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
356
417
|
const request = {
|
|
357
418
|
repository: input.repository,
|
|
358
419
|
baseCommit: input.baseCommit,
|
|
@@ -360,6 +421,7 @@ export function buildCaseBatchRequest(input) {
|
|
|
360
421
|
idempotencyKey: input.idempotencyKey,
|
|
361
422
|
...(input.baseWorld ? { baseWorld: input.baseWorld } : {}),
|
|
362
423
|
...(input.headWorld ? { headWorld: input.headWorld } : {}),
|
|
424
|
+
...(input.driver ? { driver: input.driver } : {}),
|
|
363
425
|
combinationsRef: { inline: input.combinations },
|
|
364
426
|
casesRef: { inline: input.cases },
|
|
365
427
|
limits: input.limits,
|
|
@@ -405,6 +467,9 @@ const RECORD_ARRAY_MAX = 10_000;
|
|
|
405
467
|
* string at 128 rather than refusing a reason added later. Looser, never
|
|
406
468
|
* tighter, than the gateway. */
|
|
407
469
|
const RECORD_UNKNOWN_REASON_MAX = 128;
|
|
470
|
+
/** v4: `CASE_FAILURE_FIELD_MAX` in `outcome()`: every node id, failure class and subcode is bounded
|
|
471
|
+
* by length only. */
|
|
472
|
+
const RECORD_FAILURE_FIELD_MAX = 256;
|
|
408
473
|
/** `validSearchRunId`: `cv_` plus 48 hex characters. */
|
|
409
474
|
const SNAPSHOT_RUN_ID_MAX = 51;
|
|
410
475
|
/** `hex(worlds.base, [16], 'base world', 'frz_')`: 4 + 16. */
|
|
@@ -446,6 +511,23 @@ function armPhases(value, what) {
|
|
|
446
511
|
bindingWaitMs: counter('bindingWaitMs'),
|
|
447
512
|
};
|
|
448
513
|
}
|
|
514
|
+
function caseArmNodeIds(value) {
|
|
515
|
+
const row = isJsonObject(value) ? value : bad('case outcome nodeIds');
|
|
516
|
+
return { base1: responseNullableString(row.base1, 'case outcome nodeIds.base1', RECORD_FAILURE_FIELD_MAX),
|
|
517
|
+
head: responseNullableString(row.head, 'case outcome nodeIds.head', RECORD_FAILURE_FIELD_MAX) };
|
|
518
|
+
}
|
|
519
|
+
function caseFailureFact(value) {
|
|
520
|
+
const row = isJsonObject(value) ? value : bad('case outcome failure');
|
|
521
|
+
if (row.arm !== null && row.arm !== 'base1' && row.arm !== 'head')
|
|
522
|
+
bad('case outcome failure.arm');
|
|
523
|
+
return {
|
|
524
|
+
source: responseText(row.source, 'case outcome failure.source', RECORD_FAILURE_FIELD_MAX),
|
|
525
|
+
arm: row.arm,
|
|
526
|
+
errorClass: responseText(row.errorClass, 'case outcome failure.errorClass', RECORD_FAILURE_FIELD_MAX),
|
|
527
|
+
errorCode: responseNullableString(row.errorCode, 'case outcome failure.errorCode', RECORD_FAILURE_FIELD_MAX),
|
|
528
|
+
nodeId: responseNullableString(row.nodeId, 'case outcome failure.nodeId', RECORD_FAILURE_FIELD_MAX),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
449
531
|
function caseOutcome(value, caseId) {
|
|
450
532
|
const row = isJsonObject(value) ? value : bad('case outcome');
|
|
451
533
|
if (row.caseId !== caseId)
|
|
@@ -477,6 +559,10 @@ function caseOutcome(value, caseId) {
|
|
|
477
559
|
cleanupProven: row.cleanupProven,
|
|
478
560
|
differingOutcomes: responseStringArray(row.differingOutcomes, 'case outcome differingOutcomes', RECORD_DIFFERING_OUTCOME_MAX),
|
|
479
561
|
detail: responseNullableString(row.detail, 'case outcome detail', RECORD_DETAIL_MAX),
|
|
562
|
+
...(typeof row.executionPath === 'string' && CASE_EXECUTION_PATHS.includes(row.executionPath)
|
|
563
|
+
? { executionPath: row.executionPath } : {}),
|
|
564
|
+
...(row.nodeIds === undefined ? {} : { nodeIds: caseArmNodeIds(row.nodeIds) }),
|
|
565
|
+
...(row.failure === undefined ? {} : { failure: row.failure === null ? null : caseFailureFact(row.failure) }),
|
|
480
566
|
};
|
|
481
567
|
}
|
|
482
568
|
export function parseCaseBatchCaseRecord(value) {
|