@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.
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
7
7
  import chalk from 'chalk';
8
8
  import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
9
9
  import { findGitRoot, parseRemoteUrl, resolveDiffBaseRef, } from '../utils/git.js';
10
- import { HaystackApiError, haystackJson } from '../utils/haystack-api.js';
10
+ import { HaystackApiError, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
11
11
  import { trackPrecomputeEvent } from '../utils/telemetry.js';
12
12
  import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
13
13
  import { startPrecomputeDelivery } from './precompute-delivery.js';
@@ -368,6 +368,12 @@ function normalModeFailure(error) {
368
368
  return new PrecomputeFailure('Authentication is required. Run `haystack login` first.');
369
369
  }
370
370
  if (error instanceof HaystackApiError) {
371
+ // Entitlement denials are not authentication problems: classifyHttpError
372
+ // already turned the server body into its one-line message, so show it.
373
+ if (error.status === 403
374
+ && (error.code === REPO_NOT_ENTITLED_CODE || error.code === SUBSCRIPTION_REQUIRED_CODE)) {
375
+ return new PrecomputeFailure(error.message);
376
+ }
371
377
  if (error.status === 401 || error.status === 403) {
372
378
  return new PrecomputeFailure('Authentication was rejected. Run `haystack login` and try again.');
373
379
  }
package/dist/index.js CHANGED
@@ -421,9 +421,11 @@ caseBatch
421
421
  .requiredOption('--combinations <file>', 'JSON file: the combination space each case start resolves against')
422
422
  .option('--base-world <id>', 'Existing frozen base world (frz_ plus 16 hex); omit to prepare it')
423
423
  .option('--head-world <id>', 'Existing frozen head world (frz_ plus 16 hex); omit to prepare it')
424
+ .option('--driver-bundle <sha256>', 'The worlds carry a resident driver running this probe bundle; sealed cases run on it')
424
425
  .option('--max-concurrent <n>', 'Concurrency ceiling, 1-500 (default: the case count, capped at 500)')
425
426
  .option('--per-case-wall-ms <ms>', 'Per-case wall budget, 1000-600000 (default 120000)')
426
427
  .option('--total-budget-ms <ms>', 'Launch budget for the batch, 10000-1800000 (default 1800000)')
428
+ .option('--warm-settle-ms <ms>', 'Adopt only warm clones ready at least this long, 0 to the per-case wall (default: absent = 0)')
427
429
  .option('--idempotency-key <key>', 'Stable retry key (default: deterministic from the whole submission)')
428
430
  .option('--source-patch <file>', 'JSON file with the precompute-captured { cacheKey, patchSha256, patchGzBase64 }')
429
431
  .option('--account <login>', 'Use a specific saved Haystack account')
@@ -485,6 +487,31 @@ cleanup and posts its terminal checkpoint.
485
487
  const { caseBatchCancelCommand } = await import('./commands/case-batch.js');
486
488
  return runPublicCommand(() => caseBatchCancelCommand(runId, options), options.json);
487
489
  });
490
+ caseBatch
491
+ .command('finalize')
492
+ .description('Make a stranded owned case batch terminal (cancelled or incomplete) and free its world-pair lock')
493
+ .argument('<run-id>', 'Case batch run id (cv_ followed by 48 lowercase hex characters)')
494
+ .requiredOption('--repository <owner/repo>', 'Exact GitHub owner/repository name')
495
+ .requiredOption('--status <status>', 'Terminal status to record: cancelled or incomplete')
496
+ .requiredOption('--reason <text>', 'Why the batch is stranded; recorded as the batch error')
497
+ .option('--account <login>', 'Use a specific saved Haystack account')
498
+ .option('--json', 'Versioned machine-readable finalize receipt')
499
+ .addHelpText('after', `
500
+ For a batch no owner can finish: a retired version no runner claims, a
501
+ checkpoint the service keeps refusing, a dead runner. Accepted only when the
502
+ batch was cancelled, or has gone 15 minutes without an update, and no runner
503
+ holds a live claim on it; otherwise refused with a typed reason. Finalize does
504
+ not assert cleanup: the receipt reports cleanup exactly as the batch last
505
+ recorded it.
506
+
507
+ Example:
508
+ haystack case-batch finalize cv_<48 hex> --repository owner/repo \\
509
+ --status cancelled --reason "v1 batch stranded after the v2 roll" --json
510
+ `)
511
+ .action(async (runId, options) => {
512
+ const { caseBatchFinalizeCommand } = await import('./commands/case-batch.js');
513
+ return runPublicCommand(() => caseBatchFinalizeCommand(runId, options), options.json);
514
+ });
488
515
  caseBatch
489
516
  .command('bundle')
490
517
  .description('Download a case batch replay bundle, verifying every artifact digest')
@@ -492,14 +519,22 @@ caseBatch
492
519
  .requiredOption('--repository <owner/repo>', 'Exact GitHub owner/repository name')
493
520
  .requiredOption('--out <dir>', 'Directory to write the verified bundle into')
494
521
  .option('--account <login>', 'Use a specific saved Haystack account')
522
+ .option('--only <path>', 'Fetch only this listed artifact, or every artifact under this directory prefix '
523
+ + '(ending in /); repeatable. The manifest and its pages are always fetched.', (value, previous = []) => [...previous, value])
495
524
  .option('--json', 'Versioned machine-readable list of verified artifacts')
496
525
  .addHelpText('after', `
497
526
  The manifest is fetched first and checked against the digest the service sealed
498
527
  it under; that sealed digest is required, because the manifest is the one
499
528
  artifact with no parent digest to check it against. Every artifact the manifest
500
- lists (and every artifact those pages list) is then fetched and checked against
501
- the manifest's own sha256 and byte length before it is written. Any failure
502
- stops the command; it never writes unverified bytes.
529
+ lists (and every PNG and case records.json those pages list) is then fetched and
530
+ checked against the digest (and, where declared, the byte length) its listing
531
+ names before it is written. Any failure stops the command; it never writes
532
+ unverified bytes.
533
+
534
+ A batch's PNGs are uploaded after it is terminal. A PNG the service reports as
535
+ still uploading is not written: it is listed as pending (\`pending\` in --json)
536
+ and the command exits 75 once everything else is verified. Rerun it, or fetch
537
+ just those paths with --only.
503
538
  `)
504
539
  .action(async (runId, options) => {
505
540
  const { caseBatchBundleCommand } = await import('./commands/case-batch.js');
@@ -600,37 +635,39 @@ program
600
635
  .option('--no-auto-merge', 'Do not apply the auto-merge label')
601
636
  .option('--no-wait', 'Skip waiting for analysis results')
602
637
  .option('--json', 'Machine-readable: one JSON document on stdout (see `haystack schema submit`), progress on stderr')
603
- .option('--max-turns <n>', 'Max agentic turns per triage checker (overrides .haystack.json triage.maxTurns)', (v) => parseInt(v, 10))
604
- .option('--triage-timeout <seconds>', 'Wall-clock timeout per triage checker in seconds (overrides .haystack.json triage.timeoutMs)', (v) => parseInt(v, 10))
638
+ .option('--triage-timeout <seconds>', 'Abort a triage checker after this many seconds with no data from the model (overrides .haystack.json triage.timeoutMs)', (v) => parseInt(v, 10))
605
639
  .addHelpText('after', `
606
640
  This command is designed for AI coding agents to submit PRs.
607
641
 
608
- 1. Runs pre-PR triage (code review and rules) via sub-agents
642
+ 1. Runs pre-PR triage (code review and rules) on gpt-6-astra
609
643
  2. Pushes the current branch to origin
610
644
  3. Creates a pull request on GitHub
611
645
  4. Waits for Haystack analysis results (triggered via GitHub App webhook)
612
646
 
613
647
  Pre-PR Triage:
614
- Before creating the PR, haystack spawns parallel sub-agents to check for:
615
- • Code review bugs (logic errors, null crashes, security issues)
616
- • Rule violations (from .haystack/pr-rules.yml)
648
+ Before creating the PR, haystack sends the diff to gpt-6-astra (OpenAI
649
+ Responses API, reasoning effort "max", structured output) in parallel for:
650
+ • Code review bugs (logic errors, null crashes, security issues, secrets)
651
+ • Rule violations (from .haystack/pr-rules.yml and CLAUDE.md/AGENTS.md etc.)
652
+
653
+ Uses OPENAI_API_KEY if set, else the SSM parameter
654
+ /haystack/secrets/shared/prod/OPENAI_API_KEY (us-west-2) via the default
655
+ AWS credential chain. If a checker call fails, submit prints
656
+ "<checker> failed: <error>" and continues; only findings with severity
657
+ "error" block the PR.
617
658
 
618
659
  Use --force to skip triage entirely.
619
660
  Use --no-wait to skip waiting for analysis results.
620
661
 
621
- Triage budgets:
622
- • --max-turns <n> Raise/lower the per-checker tool-use turn cap.
623
- Defaults: code-review 8, rules-validator 10.
624
- Applies the same N to both.
625
- • --triage-timeout <sec> Raise/lower the per-checker wall-clock timeout
626
- (default: 180s).
662
+ • --triage-timeout <sec> Abort a checker when the model stream has been
663
+ silent this long (default: 300s). There is no
664
+ total-time cap.
627
665
 
628
- Persist these in .haystack.json to apply per project:
666
+ Persist it in .haystack.json to apply per project:
629
667
 
630
668
  {
631
669
  "triage": {
632
- "maxTurns": { "code-review": 12 },
633
- "timeoutMs": 240000
670
+ "timeoutMs": 300000
634
671
  }
635
672
  }
636
673
 
@@ -674,8 +711,7 @@ Examples:
674
711
  haystack submit --review # ⚠ Blocks auto-merge, needs human approval
675
712
  haystack submit --review octocat # ⚠ Blocks auto-merge, requests review from octocat
676
713
  haystack submit --account octocat # Use a specific saved Haystack account for this submit
677
- haystack submit --max-turns 12 # Raise triage turn cap to 12 per checker
678
- haystack submit --triage-timeout 300 # Raise triage wall-clock to 5 minutes
714
+ haystack submit --triage-timeout 600 # Allow 10 minutes of model silence per checker
679
715
  `)
680
716
  .action(async (options, command) => {
681
717
  // Resolve --auto-merge / --auto-fix defaults from .haystack.json when not
package/dist/schema.js CHANGED
@@ -16,10 +16,10 @@ export const SCHEMA_VERSIONS = {
16
16
  'pr-status': '1.0.0',
17
17
  inbox: '1.0.0',
18
18
  ask: '1.0.0',
19
- submit: '1.0.0',
19
+ submit: '1.0.1',
20
20
  action: '1.0.0',
21
21
  'cloud-verifier': '1.0.0',
22
- 'case-batch': '1.0.0',
22
+ 'case-batch': '1.0.1',
23
23
  error: '1.0.0',
24
24
  };
25
25
  /** Wrap a payload with the `schema_version` envelope. */
@@ -0,0 +1,199 @@
1
+ /**
2
+ * One structured Responses API call to gpt-6-astra, the pre-PR reviewer.
3
+ *
4
+ * Streams the response so a silent connection can be told apart from a model
5
+ * that is still reasoning: the call is aborted only when no data has arrived
6
+ * for `stallMs`, never on total elapsed time. Uses node:https rather than
7
+ * fetch because fetch's transport imposes its own 300s body timeout, which
8
+ * would override a larger stall limit. Any failure throws; the runner reports
9
+ * it and submit continues without that checker.
10
+ */
11
+ import { request as httpsRequest } from 'node:https';
12
+ export const TRIAGE_MODEL = 'gpt-6-astra';
13
+ /** The highest effort the Responses API accepts for gpt-6-astra (none|minimal|low|medium|high|xhigh|max). */
14
+ export const TRIAGE_REASONING_EFFORT = 'max';
15
+ const RESPONSES_URL = 'https://api.openai.com/v1/responses';
16
+ /** SSM parameter holding the shared OpenAI key, read when OPENAI_API_KEY is unset. */
17
+ export const OPENAI_KEY_PARAMETER = '/haystack/secrets/shared/prod/OPENAI_API_KEY';
18
+ const OPENAI_KEY_PARAMETER_REGION = 'us-west-2';
19
+ export const NO_OPENAI_KEY_MESSAGE = `no OpenAI key (set OPENAI_API_KEY or AWS access to ${OPENAI_KEY_PARAMETER})`;
20
+ /**
21
+ * Default reader: AWS SDK v3 SSM with the default credential chain
22
+ * (AWS_PROFILE, env keys, SSO, instance role). Imported lazily so the SDK is
23
+ * off the startup path of every other command.
24
+ */
25
+ const readSsmParameter = async (name, region) => {
26
+ const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
27
+ const client = new SSMClient({ region, maxAttempts: 2 });
28
+ try {
29
+ const out = await client.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
30
+ return out.Parameter?.Value;
31
+ }
32
+ finally {
33
+ client.destroy();
34
+ }
35
+ };
36
+ /**
37
+ * Resolve the OpenAI key: OPENAI_API_KEY if set, else the shared SSM
38
+ * parameter. Throws NO_OPENAI_KEY_MESSAGE (with the AWS cause appended) when
39
+ * neither yields a key. The key itself is never logged or put in an error.
40
+ */
41
+ export async function resolveOpenAiKey(env = process.env, readParameter = readSsmParameter) {
42
+ const fromEnv = env.OPENAI_API_KEY?.trim();
43
+ if (fromEnv)
44
+ return fromEnv;
45
+ let cause;
46
+ try {
47
+ const fromSsm = (await readParameter(OPENAI_KEY_PARAMETER, OPENAI_KEY_PARAMETER_REGION))?.trim();
48
+ if (fromSsm)
49
+ return fromSsm;
50
+ cause = 'parameter is empty';
51
+ }
52
+ catch (err) {
53
+ cause = (err instanceof Error ? `${err.name}: ${err.message}` : String(err)).slice(0, 200);
54
+ }
55
+ throw new Error(`${NO_OPENAI_KEY_MESSAGE}; AWS lookup: ${cause}`);
56
+ }
57
+ let apiKeyPromise;
58
+ /** One resolution per process, shared by the parallel checkers. */
59
+ function readApiKey() {
60
+ apiKeyPromise ??= resolveOpenAiKey();
61
+ return apiKeyPromise;
62
+ }
63
+ /** The API's own error message from a non-2xx body, else the body's first 300 chars. */
64
+ function apiErrorMessage(body) {
65
+ try {
66
+ const message = JSON.parse(body).error?.message;
67
+ if (typeof message === 'string' && message)
68
+ return message;
69
+ }
70
+ catch {
71
+ // Not JSON (proxy or gateway page); report the raw text below.
72
+ }
73
+ return body.slice(0, 300);
74
+ }
75
+ /** Pull the final JSON text out of a completed response. */
76
+ function outputText(response) {
77
+ const parts = (response.output ?? [])
78
+ .filter(item => item.type === 'message')
79
+ .flatMap(item => item.content ?? []);
80
+ const refusal = parts.find(part => part.type === 'refusal');
81
+ if (refusal)
82
+ throw new Error(`${TRIAGE_MODEL} refused: ${refusal.refusal ?? '(no reason)'}`);
83
+ const text = parts.filter(part => part.type === 'output_text').map(part => part.text ?? '').join('');
84
+ if (!text)
85
+ throw new Error(`${TRIAGE_MODEL} returned no output text`);
86
+ return text;
87
+ }
88
+ /**
89
+ * Handle one SSE event block. Returns the parsed reply on response.completed,
90
+ * throws on a terminal failure event, and returns undefined otherwise.
91
+ */
92
+ function handleEventBlock(block) {
93
+ const data = block
94
+ .split('\n')
95
+ .filter(line => line.startsWith('data:'))
96
+ .map(line => line.slice(5).trim())
97
+ .join('');
98
+ if (!data || data === '[DONE]')
99
+ return undefined;
100
+ const event = JSON.parse(data);
101
+ switch (event.type) {
102
+ case 'response.completed':
103
+ return { reply: JSON.parse(outputText(event.response ?? {})) };
104
+ case 'response.incomplete':
105
+ throw new Error(`${TRIAGE_MODEL} response incomplete: ${event.response?.incomplete_details?.reason ?? 'unknown reason'}`);
106
+ case 'response.failed':
107
+ throw new Error(`${TRIAGE_MODEL} response failed: ${event.response?.error?.message ?? 'unknown error'}`);
108
+ case 'error':
109
+ throw new Error(`OpenAI stream error: ${event.message ?? event.error?.message ?? data.slice(0, 300)}`);
110
+ default:
111
+ return undefined;
112
+ }
113
+ }
114
+ /** Run one structured call and return the parsed JSON object. */
115
+ export async function callAstra(request) {
116
+ const apiKey = await readApiKey();
117
+ const body = JSON.stringify({
118
+ model: TRIAGE_MODEL,
119
+ reasoning: { effort: TRIAGE_REASONING_EFFORT },
120
+ store: false,
121
+ stream: true,
122
+ instructions: request.instructions,
123
+ input: request.input,
124
+ text: {
125
+ format: { type: 'json_schema', name: request.schemaName, strict: true, schema: request.schema },
126
+ },
127
+ });
128
+ return new Promise((resolve, reject) => {
129
+ let settled = false;
130
+ let stallTimer;
131
+ const finish = (err, reply) => {
132
+ if (settled)
133
+ return;
134
+ settled = true;
135
+ clearTimeout(stallTimer);
136
+ req.destroy();
137
+ if (err)
138
+ reject(err);
139
+ else
140
+ resolve(reply);
141
+ };
142
+ const armStallTimer = () => {
143
+ // A chunk that lands after the call settled must not start a new timer:
144
+ // nothing would clear it and it would hold the process open.
145
+ if (settled)
146
+ return;
147
+ clearTimeout(stallTimer);
148
+ stallTimer = setTimeout(() => {
149
+ finish(new Error(`no data from ${TRIAGE_MODEL} for ${Math.round(request.stallMs / 1000)}s; aborted`));
150
+ }, request.stallMs);
151
+ };
152
+ const req = httpsRequest(RESPONSES_URL, {
153
+ method: 'POST',
154
+ headers: {
155
+ Authorization: `Bearer ${apiKey}`,
156
+ 'Content-Type': 'application/json',
157
+ 'Content-Length': Buffer.byteLength(body),
158
+ Accept: 'text/event-stream',
159
+ },
160
+ }, (res) => {
161
+ res.setEncoding('utf8');
162
+ const status = res.statusCode ?? 0;
163
+ let buffer = '';
164
+ res.on('data', (chunk) => {
165
+ armStallTimer();
166
+ buffer += chunk;
167
+ if (status < 200 || status >= 300)
168
+ return;
169
+ // SSE allows CRLF, LF or CR line endings; normalize before splitting events.
170
+ buffer = buffer.replace(/\r\n?/g, '\n');
171
+ let boundary;
172
+ while ((boundary = buffer.indexOf('\n\n')) >= 0) {
173
+ const block = buffer.slice(0, boundary);
174
+ buffer = buffer.slice(boundary + 2);
175
+ try {
176
+ const outcome = handleEventBlock(block);
177
+ if (outcome)
178
+ return finish(null, outcome.reply);
179
+ }
180
+ catch (err) {
181
+ return finish(err instanceof Error ? err : new Error(String(err)));
182
+ }
183
+ }
184
+ });
185
+ res.on('end', () => {
186
+ if (status < 200 || status >= 300) {
187
+ finish(new Error(`OpenAI ${status}: ${apiErrorMessage(buffer)}`));
188
+ }
189
+ else {
190
+ finish(new Error('OpenAI stream ended without a completed response'));
191
+ }
192
+ });
193
+ res.on('error', err => finish(err));
194
+ });
195
+ req.on('error', err => finish(err));
196
+ armStallTimer();
197
+ req.end(body);
198
+ });
199
+ }