@haystackeditor/cli 0.16.0 → 0.17.1

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 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, instruction drift), pushes your branch, and opens the PR.
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 `xhigh`.
234
+ 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-v1';
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
- start[referenceIdentity(axis, `case ${index} start axis ${position}`)] =
203
- referenceIdentity(startRow[axis], `case ${index} start value for axis ${position} (${excerpt(axis)})`);
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) => boundedCaseBatchJson(action, `case ${index} action ${position}`, CASE_VALUE_MAX_BYTES)),
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) => jsonObject(observation, `case ${index} observation ${position}`, CASE_VALUE_MAX_BYTES)),
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: integer(value.perCaseWallMs, 'perCaseWallMs', CASE_BATCH_MIN_CASE_WALL_MS, CASE_BATCH_MAX_CASE_WALL_MS),
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) {