@haystackeditor/cli 0.15.30 → 0.16.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.
Files changed (135) hide show
  1. package/README.md +111 -60
  2. package/dist/assets/hooks/scripts/commit-msg.sh +3 -0
  3. package/dist/assets/hooks/scripts/post-commit.sh +3 -0
  4. package/dist/assets/hooks/scripts/pre-commit.sh +11 -6
  5. package/dist/assets/hooks/scripts/pre-push.sh +3 -0
  6. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
  7. package/dist/commands/case-batch-contract.js +608 -0
  8. package/dist/commands/case-batch.js +713 -0
  9. package/dist/commands/cloud-verifier-identity-census.js +5 -2
  10. package/dist/commands/combination-search-hook.js +139 -0
  11. package/dist/commands/design-verify.js +27 -1
  12. package/dist/commands/dismiss.js +2 -1
  13. package/dist/commands/hooks.js +66 -7
  14. package/dist/commands/install-session-hooks.js +131 -44
  15. package/dist/commands/policy.js +32 -45
  16. package/dist/commands/precompute-delivery.js +6 -1
  17. package/dist/commands/scaffold-provisional-universe.js +8 -10
  18. package/dist/commands/setup.js +32 -10
  19. package/dist/commands/submit.js +7 -2
  20. package/dist/commands/telemetry.js +17 -2
  21. package/dist/commands/triage.js +2 -1
  22. package/dist/commands/verify-explore.js +1 -0
  23. package/dist/commands/verify-history.js +152 -0
  24. package/dist/commands/verify-hosted-mcp.js +3 -12
  25. package/dist/commands/verify-hosted-reproducibility.js +49 -547
  26. package/dist/commands/verify-hosted.js +180 -163
  27. package/dist/commands/verify-precompute.js +57 -10
  28. package/dist/commands/verify.js +51 -139
  29. package/dist/index.js +264 -348
  30. package/dist/lazy.js +8 -0
  31. package/dist/schema.js +1 -0
  32. package/dist/tools/detect.js +3 -24
  33. package/dist/triage/prompts.js +46 -45
  34. package/dist/triage/runner.js +119 -33
  35. package/dist/types/verify-history.js +1 -0
  36. package/dist/types.js +3 -3
  37. package/dist/utils/auth.js +14 -2
  38. package/dist/utils/design-verifier-api.js +23 -0
  39. package/dist/utils/design-verifier-history.js +79 -0
  40. package/dist/utils/git.js +60 -29
  41. package/dist/utils/github-api.js +14 -1
  42. package/dist/utils/haystack-api.js +26 -7
  43. package/dist/utils/hooks.js +43 -6
  44. package/dist/utils/prompter.js +25 -10
  45. package/dist/utils/safe-write.js +31 -0
  46. package/dist/utils/secret-paths.js +115 -0
  47. package/dist/utils/secrets.js +0 -1
  48. package/dist/utils/telemetry.js +34 -14
  49. package/dist/utils/update-check.js +151 -0
  50. package/package.json +18 -14
  51. package/schemas/case-batch.v1.json +184 -0
  52. package/schemas/cloud-verifier.v1.json +55 -100
  53. package/dist/commands/ask.d.ts +0 -14
  54. package/dist/commands/cloud-verifier-behaviors.d.ts +0 -27
  55. package/dist/commands/cloud-verifier-data-store-census.d.ts +0 -47
  56. package/dist/commands/cloud-verifier-data-store-drift.d.ts +0 -42
  57. package/dist/commands/cloud-verifier-identity-census.d.ts +0 -88
  58. package/dist/commands/cloud-verifier-materialization.d.ts +0 -16
  59. package/dist/commands/cloud-verifier-pascal-selector-census.d.ts +0 -29
  60. package/dist/commands/cloud-verifier-python-manifest-selector-census.d.ts +0 -27
  61. package/dist/commands/cloud-verifier-specialized-operational-census.d.ts +0 -51
  62. package/dist/commands/cloud-verifier-universe.d.ts +0 -31
  63. package/dist/commands/config.d.ts +0 -46
  64. package/dist/commands/design-verify.d.ts +0 -31
  65. package/dist/commands/dismiss.d.ts +0 -29
  66. package/dist/commands/hooks.d.ts +0 -13
  67. package/dist/commands/inbox.d.ts +0 -65
  68. package/dist/commands/init.d.ts +0 -10
  69. package/dist/commands/install-session-hooks.d.ts +0 -17
  70. package/dist/commands/login.d.ts +0 -8
  71. package/dist/commands/mcp.d.ts +0 -1
  72. package/dist/commands/policy.d.ts +0 -31
  73. package/dist/commands/pr-status.d.ts +0 -144
  74. package/dist/commands/pr.d.ts +0 -40
  75. package/dist/commands/precompute-delivery-contract.d.ts +0 -68
  76. package/dist/commands/precompute-delivery.d.ts +0 -20
  77. package/dist/commands/prepare-universe-review.d.ts +0 -115
  78. package/dist/commands/production-source-deny-policy.d.ts +0 -15
  79. package/dist/commands/request-review.d.ts +0 -26
  80. package/dist/commands/review.d.ts +0 -25
  81. package/dist/commands/rules.d.ts +0 -4
  82. package/dist/commands/scaffold-provisional-universe.d.ts +0 -468
  83. package/dist/commands/schema-cmd.d.ts +0 -2
  84. package/dist/commands/setup.d.ts +0 -28
  85. package/dist/commands/skills.d.ts +0 -8
  86. package/dist/commands/status.d.ts +0 -4
  87. package/dist/commands/submit.d.ts +0 -30
  88. package/dist/commands/system-map.d.ts +0 -42
  89. package/dist/commands/telemetry.d.ts +0 -53
  90. package/dist/commands/tokens.d.ts +0 -14
  91. package/dist/commands/triage.d.ts +0 -35
  92. package/dist/commands/verify-core.d.ts +0 -449
  93. package/dist/commands/verify-core.js +0 -789
  94. package/dist/commands/verify-explore.d.ts +0 -14
  95. package/dist/commands/verify-hosted-mcp.d.ts +0 -12
  96. package/dist/commands/verify-hosted-reproducibility.d.ts +0 -90
  97. package/dist/commands/verify-hosted.d.ts +0 -92
  98. package/dist/commands/verify-mcp.d.ts +0 -8
  99. package/dist/commands/verify-mcp.js +0 -517
  100. package/dist/commands/verify-ops.d.ts +0 -158
  101. package/dist/commands/verify-ops.js +0 -1148
  102. package/dist/commands/verify-precompute.d.ts +0 -30
  103. package/dist/commands/verify-reproducibility.d.ts +0 -85
  104. package/dist/commands/verify-reproducibility.js +0 -494
  105. package/dist/commands/verify-reseal.d.ts +0 -9
  106. package/dist/commands/verify-reseal.js +0 -148
  107. package/dist/commands/verify-sandboxes.d.ts +0 -95
  108. package/dist/commands/verify-sandboxes.js +0 -352
  109. package/dist/commands/verify.d.ts +0 -28
  110. package/dist/commands/webhooks.d.ts +0 -30
  111. package/dist/index.d.ts +0 -22
  112. package/dist/schema.d.ts +0 -28
  113. package/dist/states.d.ts +0 -29
  114. package/dist/tools/detect.d.ts +0 -50
  115. package/dist/triage/prompts.d.ts +0 -24
  116. package/dist/triage/runner.d.ts +0 -34
  117. package/dist/triage/types.d.ts +0 -42
  118. package/dist/types.d.ts +0 -1684
  119. package/dist/utils/action-output.d.ts +0 -24
  120. package/dist/utils/analysis-api.d.ts +0 -187
  121. package/dist/utils/auth.d.ts +0 -79
  122. package/dist/utils/config.d.ts +0 -24
  123. package/dist/utils/design-verifier-api.d.ts +0 -200
  124. package/dist/utils/design-verifier-result.d.ts +0 -74
  125. package/dist/utils/detect.d.ts +0 -43
  126. package/dist/utils/git.d.ts +0 -135
  127. package/dist/utils/github-api.d.ts +0 -104
  128. package/dist/utils/haystack-api.d.ts +0 -37
  129. package/dist/utils/hooks.d.ts +0 -12
  130. package/dist/utils/pending-state.d.ts +0 -40
  131. package/dist/utils/pr-ref.d.ts +0 -27
  132. package/dist/utils/prompter.d.ts +0 -85
  133. package/dist/utils/secrets.d.ts +0 -47
  134. package/dist/utils/telemetry.d.ts +0 -19
  135. /package/dist/commands/{precompute-delivery-worker.d.ts → combination-search-hook-contract.js} +0 -0
@@ -0,0 +1,608 @@
1
+ /** FIXED CONTRACT — mirror of CASE-BATCH-V1 for the published CLI, whose build
2
+ * cannot import repository files outside `src`. The authority is
3
+ * `infra/lambda/haystack-design-verifier-shared/case_batch_contracts.ts`
4
+ * (frozen at feec1004cab4a2344d3730ccddd94d8eaaedbaec), the admission rules in
5
+ * `infra/lambda/haystack-design-verifier-shared/case_batch_validation.ts`
6
+ * (whose primitives come from `combination_search_validation.ts`), and
7
+ * `docs/case-batch-coordinator.md`; this file restates only what the CLI
8
+ * submits, reads back, and verifies. Where it restates an admission rule it
9
+ * restates it exactly — a submission the CLI accepts and the gateway refuses
10
+ * is a round trip the caller pays for and loses. It never restates the runner-side or
11
+ * queue-side rows: Lane D submits and polls, nothing else.
12
+ *
13
+ * The CLI carries no fleet credential and no trusted host path. The only
14
+ * credential any request here can carry is the caller's Haystack login bearer.
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ /** Bounds, copied verbatim from the frozen contract. */
18
+ export const CASE_BATCH_VERSION = 'case-batch-v1';
19
+ export const CASE_BATCH_MAX_CONCURRENT_CASES = 500;
20
+ export const CASE_BATCH_MAX_CASES = 5_000;
21
+ export const CASE_BATCH_MAX_INPUT_BYTES = 8 * 1024 * 1024;
22
+ export const CASE_BATCH_MIN_CASE_WALL_MS = 1_000;
23
+ export const CASE_BATCH_MAX_CASE_WALL_MS = 600_000;
24
+ export const CASE_BATCH_MIN_TOTAL_BUDGET_MS = 10_000;
25
+ export const CASE_BATCH_MAX_TOTAL_BUDGET_MS = 30 * 60_000;
26
+ export const CASE_BATCH_VM_SLOTS_PER_CASE = 2;
27
+ export const CASE_BATCH_ID_PATTERN = /^[A-Za-z0-9_-]{1,160}$/u;
28
+ /** `CombinationSearchStatus`, reused by the batch without change. */
29
+ export const CASE_BATCH_STATUSES = [
30
+ 'queued', 'preparing', 'searching', 'draining', 'completed', 'incomplete', 'cancelled',
31
+ ];
32
+ export const CASE_BATCH_TERMINAL_STATUSES = ['completed', 'incomplete', 'cancelled'];
33
+ export function isTerminalCaseBatchStatus(status) {
34
+ return CASE_BATCH_TERMINAL_STATUSES.includes(status);
35
+ }
36
+ /** Ruling 1 in docs/case-batch-coordinator.md: adding an unknown reason is
37
+ * additive, so a consumer that does not know a reason still reads the outcome
38
+ * as UNKNOWN. This CLI therefore never rejects an unrecognised reason; the
39
+ * list exists only to render the ones it can explain. */
40
+ export const KNOWN_CASE_UNKNOWN_REASONS = [
41
+ 'missing-state', 'missing-action', 'missing-harness', 'arrival-failed',
42
+ 'comparison-unavailable', 'infrastructure', 'wall-budget', 'wall-watchdog', 'worker-exited',
43
+ ];
44
+ export class CaseBatchRequestValidationError extends TypeError {
45
+ name = 'CaseBatchRequestValidationError';
46
+ }
47
+ export class CaseBatchRequestSizeError extends RangeError {
48
+ name = 'CaseBatchRequestSizeError';
49
+ }
50
+ export class CaseBatchResponseError extends Error {
51
+ name = 'CaseBatchResponseError';
52
+ }
53
+ function fail(what) {
54
+ throw new CaseBatchRequestValidationError(`Invalid case batch ${what}.`);
55
+ }
56
+ function bad(what) {
57
+ throw new CaseBatchResponseError(`Case batch service returned an invalid ${what}.`);
58
+ }
59
+ const ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
60
+ export function isJsonObject(value) {
61
+ return !!value && typeof value === 'object' && !Array.isArray(value);
62
+ }
63
+ function object(value, what) {
64
+ if (!isJsonObject(value))
65
+ fail(what);
66
+ return value;
67
+ }
68
+ function keys(value, required, optional = []) {
69
+ for (const key of required)
70
+ if (!Object.hasOwn(value, key))
71
+ fail(`fields (missing ${key})`);
72
+ for (const key of Object.keys(value)) {
73
+ if (!required.includes(key) && !optional.includes(key))
74
+ fail(`fields (unexpected ${key})`);
75
+ }
76
+ }
77
+ function text(value, what, maximum = 4_096) {
78
+ if (typeof value !== 'string' || value.length === 0 || value.length > maximum
79
+ || value.includes('\0'))
80
+ fail(what);
81
+ return value;
82
+ }
83
+ /** At most the first 64 characters of an offending value, JSON-quoted so the
84
+ * refusal stays on one line whatever the value contains. */
85
+ const EXCERPT_LIMIT = 64;
86
+ function excerpt(value) {
87
+ return value.length > EXCERPT_LIMIT
88
+ ? `${JSON.stringify(value.slice(0, EXCERPT_LIMIT))} (truncated)`
89
+ : JSON.stringify(value);
90
+ }
91
+ /** MIRRORED RULE — `referenceIdentity` in
92
+ * `infra/lambda/haystack-design-verifier-shared/case_batch_validation.ts`,
93
+ * which is `token(value, REFERENCE_IDENTITY, 1, 256, field)` over the shared
94
+ * `token` helper in `combination_search_validation.ts`:
95
+ *
96
+ * REFERENCE_IDENTITY = A-Z a-z 0-9 . _ : / -
97
+ * a non-empty string, 1 to 256 characters, every character in that set.
98
+ *
99
+ * The gateway applies it to `conditionId`, `variantOf`, `surfaceId`,
100
+ * `finalControlId`, every `staticReach` entry, and every `start` axis and
101
+ * value. This file cannot import the gateway module, so the rule is copied.
102
+ * It is copied exactly on purpose: anything the CLI accepts here and the
103
+ * gateway refuses is a submission that only dies at admission, after the
104
+ * caller has already paid to build it. */
105
+ const REFERENCE_IDENTITY_CHARACTERS = `${ALPHANUMERIC}._:/-`;
106
+ const REFERENCE_IDENTITY_MAXIMUM = 256;
107
+ /** Mirrors the gateway's `start()`: at most 1000 axes on one case. */
108
+ const CASE_START_MAX_AXES = 1_000;
109
+ /** Mirrors the gateway's per-value JSON bound on `actions`, `arrivalCheck`,
110
+ * `observations` and `provenance` (262_144 bytes each). */
111
+ const CASE_VALUE_MAX_BYTES = 262_144;
112
+ /** Mirrors the gateway's `array(..., maximum)` counts inside one case. */
113
+ const CASE_MAX_ACTIONS = 10_000;
114
+ const CASE_MAX_OBSERVATIONS = 1_000;
115
+ const CASE_MAX_STATIC_REACH = 10_000;
116
+ /** Mirrors the gateway's `text(row.label, 'case label', 16384)`. */
117
+ const CASE_LABEL_MAX = 16_384;
118
+ function referenceIdentity(value, what) {
119
+ if (typeof value !== 'string')
120
+ fail(`${what} (expected a string identifier)`);
121
+ if (value.length === 0)
122
+ fail(`${what} (an identifier may not be empty)`);
123
+ if (value.length > REFERENCE_IDENTITY_MAXIMUM) {
124
+ fail(`${what} is ${value.length} characters; the gateway accepts at most `
125
+ + `${REFERENCE_IDENTITY_MAXIMUM}: ${excerpt(value)}`);
126
+ }
127
+ for (let position = 0; position < value.length; position += 1) {
128
+ const character = value[position];
129
+ if (!REFERENCE_IDENTITY_CHARACTERS.includes(character)) {
130
+ fail(`${what} has ${JSON.stringify(character)} at position ${position}, outside the `
131
+ + `gateway identifier charset [A-Za-z0-9._:/-]: ${excerpt(value)}`);
132
+ }
133
+ }
134
+ return value;
135
+ }
136
+ function hex(value, length, what, prefix = '') {
137
+ if (typeof value !== 'string' || value.length !== prefix.length + length || !value.startsWith(prefix))
138
+ fail(what);
139
+ for (const character of value.slice(prefix.length)) {
140
+ if (!'0123456789abcdef'.includes(character))
141
+ fail(what);
142
+ }
143
+ return value;
144
+ }
145
+ export function integer(value, what, minimum, maximum) {
146
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
147
+ fail(`${what} (expected an integer from ${minimum} to ${maximum})`);
148
+ }
149
+ return value;
150
+ }
151
+ /** Bounded JSON, as the combination-search hook already bounds its inputs:
152
+ * finite numbers, no prototype-polluting keys, depth 64, 8 MiB serialized. */
153
+ export function boundedCaseBatchJson(value, what, maximumBytes = CASE_BATCH_MAX_INPUT_BYTES) {
154
+ const visit = (item, depth) => {
155
+ if (depth > 64)
156
+ fail(`${what} (nested deeper than 64 levels)`);
157
+ if (item === null || typeof item === 'string' || typeof item === 'boolean')
158
+ return;
159
+ if (typeof item === 'number') {
160
+ if (!Number.isFinite(item))
161
+ fail(`${what} (non-finite number)`);
162
+ return;
163
+ }
164
+ if (Array.isArray(item)) {
165
+ for (const entry of item)
166
+ visit(entry, depth + 1);
167
+ return;
168
+ }
169
+ for (const [key, entry] of Object.entries(object(item, what))) {
170
+ if (['__proto__', 'constructor', 'prototype'].includes(key))
171
+ fail(`${what} (unsafe key)`);
172
+ visit(entry, depth + 1);
173
+ }
174
+ };
175
+ visit(value, 0);
176
+ const bytes = Buffer.byteLength(JSON.stringify(value) ?? '');
177
+ if (bytes > maximumBytes) {
178
+ throw new CaseBatchRequestSizeError(`${what} is ${bytes} bytes; the case-batch bound is ${maximumBytes} bytes.`);
179
+ }
180
+ return value;
181
+ }
182
+ function jsonObject(value, what, maximumBytes = CASE_BATCH_MAX_INPUT_BYTES) {
183
+ object(value, what);
184
+ return boundedCaseBatchJson(value, what, maximumBytes);
185
+ }
186
+ export function parseProductCase(value, index) {
187
+ const row = object(value, `case ${index}`);
188
+ keys(row, ['id', 'contentDigest', 'conditionId', 'start', 'actions', 'arrivalCheck',
189
+ 'observations', 'variantOf', 'provenance', 'surfaceId', 'finalControlId', 'staticReach', 'label'], ['arrivalActionIndex']);
190
+ const id = text(row.id, `case ${index} id`, 160);
191
+ if (!CASE_BATCH_ID_PATTERN.test(id))
192
+ fail(`case ${index} id (queue identity charset is [A-Za-z0-9_-], 1-160)`);
193
+ const startRow = object(row.start, `case ${index} start`);
194
+ const axes = Object.keys(startRow);
195
+ if (axes.length > CASE_START_MAX_AXES) {
196
+ fail(`case ${index} start has ${axes.length} axes; the gateway accepts at most ${CASE_START_MAX_AXES}`);
197
+ }
198
+ const start = {};
199
+ for (const [position, axis] of axes.entries()) {
200
+ if (['__proto__', 'constructor', 'prototype'].includes(axis))
201
+ 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)})`);
204
+ }
205
+ if (!Array.isArray(row.actions))
206
+ fail(`case ${index} actions`);
207
+ if (row.actions.length > CASE_MAX_ACTIONS) {
208
+ fail(`case ${index} actions has ${row.actions.length} entries; the gateway accepts at most ${CASE_MAX_ACTIONS}`);
209
+ }
210
+ if (!Array.isArray(row.observations))
211
+ fail(`case ${index} observations`);
212
+ if (row.observations.length > CASE_MAX_OBSERVATIONS) {
213
+ fail(`case ${index} observations has ${row.observations.length} entries; `
214
+ + `the gateway accepts at most ${CASE_MAX_OBSERVATIONS}`);
215
+ }
216
+ if (!Array.isArray(row.staticReach))
217
+ fail(`case ${index} staticReach`);
218
+ if (row.staticReach.length > CASE_MAX_STATIC_REACH) {
219
+ fail(`case ${index} staticReach has ${row.staticReach.length} entries; `
220
+ + `the gateway accepts at most ${CASE_MAX_STATIC_REACH}`);
221
+ }
222
+ const nullableIdentity = (candidate, what) => candidate === null ? null : referenceIdentity(candidate, what);
223
+ const staticReach = row.staticReach.map((reach, position) => referenceIdentity(reach, `case ${index} staticReach[${position}]`));
224
+ const seenReach = new Set();
225
+ for (const [position, reach] of staticReach.entries()) {
226
+ if (seenReach.has(reach)) {
227
+ fail(`case ${index} staticReach[${position}] repeats an earlier entry: ${excerpt(reach)}`);
228
+ }
229
+ seenReach.add(reach);
230
+ }
231
+ return {
232
+ id,
233
+ // `hashCanonical` is sha256Hex over canonical JSON (verifier/contracts/src/canonical.ts).
234
+ contentDigest: hex(row.contentDigest, 64, `case ${index} contentDigest`),
235
+ conditionId: referenceIdentity(row.conditionId, `case ${index} conditionId`),
236
+ start,
237
+ actions: row.actions.map((action, position) => boundedCaseBatchJson(action, `case ${index} action ${position}`, CASE_VALUE_MAX_BYTES)),
238
+ // Optional and additive: refused here when it does not name an action this
239
+ // case carries, rather than at execution where it costs two VMs to find.
240
+ ...(row.arrivalActionIndex === undefined ? {}
241
+ : { arrivalActionIndex: integer(row.arrivalActionIndex, `case ${index} arrivalActionIndex`, 0, row.actions.length - 1) }),
242
+ 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)),
244
+ variantOf: nullableIdentity(row.variantOf, `case ${index} variantOf`),
245
+ provenance: jsonObject(row.provenance, `case ${index} provenance`, CASE_VALUE_MAX_BYTES),
246
+ surfaceId: nullableIdentity(row.surfaceId, `case ${index} surfaceId`),
247
+ finalControlId: nullableIdentity(row.finalControlId, `case ${index} finalControlId`),
248
+ staticReach,
249
+ label: text(row.label, `case ${index} label`, CASE_LABEL_MAX),
250
+ };
251
+ }
252
+ /** The submitter's ordered cases. Order is its scheduling decision and is
253
+ * preserved end to end, so the CLI never sorts or deduplicates silently:
254
+ * a repeated id or contentDigest is refused here, as the coordinator refuses
255
+ * it server-side. */
256
+ export function parseProductCases(value) {
257
+ const rows = Array.isArray(value)
258
+ ? value
259
+ : (isJsonObject(value) && Array.isArray(value.cases) ? value.cases : null);
260
+ if (!rows)
261
+ fail('cases file (expected a JSON array of cases, or an object with a "cases" array)');
262
+ if (rows.length === 0)
263
+ fail('cases file (a batch needs at least one case)');
264
+ if (rows.length > CASE_BATCH_MAX_CASES) {
265
+ throw new CaseBatchRequestSizeError(`The cases file has ${rows.length} cases; one submission carries at most ${CASE_BATCH_MAX_CASES}.`);
266
+ }
267
+ // The gateway bounds the whole cases document before it reads a single case
268
+ // (`json(value)` at the top of `parseCaseBatchCases`): finite numbers, no
269
+ // prototype-polluting keys, depth 64, 8 MiB serialized.
270
+ boundedCaseBatchJson(rows, 'cases file');
271
+ const cases = rows.map((row, index) => parseProductCase(row, index));
272
+ const ids = new Set();
273
+ const digests = new Set();
274
+ for (const item of cases) {
275
+ if (ids.has(item.id))
276
+ fail(`cases file (case id ${item.id} appears twice)`);
277
+ ids.add(item.id);
278
+ if (digests.has(item.contentDigest)) {
279
+ fail(`cases file (contentDigest ${item.contentDigest} appears twice; two cases with the same digest are the same experiment)`);
280
+ }
281
+ digests.add(item.contentDigest);
282
+ }
283
+ return cases;
284
+ }
285
+ export function parseCaseBatchLimits(value) {
286
+ return {
287
+ 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),
289
+ totalBudgetMs: integer(value.totalBudgetMs, 'totalBudgetMs', CASE_BATCH_MIN_TOTAL_BUDGET_MS, CASE_BATCH_MAX_TOTAL_BUDGET_MS),
290
+ };
291
+ }
292
+ export function parseCaseBatchRepository(value) {
293
+ const parts = (value ?? '').split('/');
294
+ if (parts.length !== 2 || parts.some(part => !/^[A-Za-z0-9_.-]{1,100}$/.test(part) || part === '.' || part === '..')) {
295
+ fail(`repository "${value}" (use an exact GitHub owner/repository name)`);
296
+ }
297
+ return { owner: parts[0], name: parts[1], fullName: value };
298
+ }
299
+ export function parseCaseBatchCommit(value, flag) {
300
+ if (typeof value !== 'string')
301
+ fail(`${flag} (required, exact 40-character lowercase commit SHA)`);
302
+ return hex(value, 40, `${flag} commit`);
303
+ }
304
+ export function parseCaseBatchWorld(value, flag) {
305
+ return hex(value, 16, `${flag} frozen world id`, 'frz_');
306
+ }
307
+ export function parseCaseBatchIdempotencyKey(value) {
308
+ if (typeof value !== 'string' || value.length < 8 || value.length > 128
309
+ || !ALPHANUMERIC.includes(value[0] ?? '')
310
+ || [...value].some(character => !ALPHANUMERIC.includes(character) && !'._:-'.includes(character))) {
311
+ fail('idempotency key (8-128 characters of letters, numbers, ".", "_", ":" or "-", starting alphanumeric)');
312
+ }
313
+ return value;
314
+ }
315
+ /** The precompute-captured source record, checked locally with exactly the
316
+ * rules the gateway applies before custody: canonical base64, gzip magic,
317
+ * sha256 over the COMPRESSED bytes, and a cache key that names these commits. */
318
+ export function parseCaseBatchSource(value, baseCommit, headCommit) {
319
+ const row = object(value, 'source patch file');
320
+ keys(row, ['cacheKey', 'patchSha256', 'patchGzBase64']);
321
+ const patchGzBase64 = text(row.patchGzBase64, 'source patch bytes', CASE_BATCH_MAX_INPUT_BYTES);
322
+ let patch;
323
+ try {
324
+ patch = Buffer.from(patchGzBase64, 'base64');
325
+ if (patch.toString('base64') !== patchGzBase64)
326
+ fail('source patch encoding');
327
+ }
328
+ catch {
329
+ fail('source patch encoding');
330
+ }
331
+ if (patch[0] !== 0x1f || patch[1] !== 0x8b)
332
+ fail('source patch (not gzip)');
333
+ const cacheKey = text(row.cacheKey, 'source cache key');
334
+ const parts = cacheKey.split('-');
335
+ if (parts.length !== 5 || parts[3] !== 'slim')
336
+ fail('source cache key');
337
+ hex(parts[0], 12, 'source cache base');
338
+ hex(parts[1], 12, 'source cache head');
339
+ hex(parts[2], 16, 'source cache identity');
340
+ hex(parts[4], 64, 'source cache digest');
341
+ if (!cacheKey.startsWith(`${baseCommit.slice(0, 12)}-${headCommit.slice(0, 12)}-`)) {
342
+ fail('source cache key (it does not name --base and --head)');
343
+ }
344
+ const patchSha256 = hex(row.patchSha256, 64, 'source patch digest');
345
+ // The declared digest is over the COMPRESSED bytes, and the gateway checks
346
+ // it before custody. Format-checking it here and sending it unverified would
347
+ // ship a record whose digest names bytes it does not describe, so compute it.
348
+ const computed = createHash('sha256').update(patch).digest('hex');
349
+ if (computed !== patchSha256) {
350
+ throw new CaseBatchRequestValidationError(`Invalid case batch source patch digest: the compressed bytes hash to ${computed}, but the record declares ${patchSha256}.`);
351
+ }
352
+ return { cacheKey, patchSha256, patchGzBase64 };
353
+ }
354
+ /** Assemble and bound the request exactly as it goes on the wire. */
355
+ export function buildCaseBatchRequest(input) {
356
+ const request = {
357
+ repository: input.repository,
358
+ baseCommit: input.baseCommit,
359
+ headCommit: input.headCommit,
360
+ idempotencyKey: input.idempotencyKey,
361
+ ...(input.baseWorld ? { baseWorld: input.baseWorld } : {}),
362
+ ...(input.headWorld ? { headWorld: input.headWorld } : {}),
363
+ combinationsRef: { inline: input.combinations },
364
+ casesRef: { inline: input.cases },
365
+ limits: input.limits,
366
+ ...(input.source ? { source: input.source } : {}),
367
+ };
368
+ boundedCaseBatchJson(request, 'request body');
369
+ return request;
370
+ }
371
+ /** MIRRORED BOUNDS — the gateway's serialisation bounds for everything a
372
+ * snapshot carries. A case record the API serves is exactly the record
373
+ * `caseRecord()` in
374
+ * `infra/lambda/haystack-design-verifier-shared/case_batch_validation.ts`
375
+ * accepted from a runner checkpoint (stored verbatim by
376
+ * `agent/cloudflare/src/case-batch-store.ts`), and the snapshot envelope is
377
+ * assembled by `caseBatchSnapshot()` in that store file. A read-side bound
378
+ * tighter than the gateway's turns a batch both sides accepted into a
379
+ * snapshot every poll refuses, so each bound here equals the gateway's.
380
+ *
381
+ * Shared helper defaults (`combination_search_validation.ts`): `text()`
382
+ * defaults to 2000 characters and `date()` to 30. */
383
+ /** `text(row.label, 'case label', 16384)` in `caseRecord()`; also the
384
+ * submit-side `CASE_LABEL_MAX`. */
385
+ const RECORD_LABEL_MAX = CASE_LABEL_MAX;
386
+ /** `referenceIdentity(row.conditionId, ...)`: `token(..., 1, 256, ...)`. */
387
+ const RECORD_CONDITION_ID_MAX = REFERENCE_IDENTITY_MAXIMUM;
388
+ /** `text(row.detail, 'outcome detail', 16384)` in `outcome()`. */
389
+ const RECORD_DETAIL_MAX = 16_384;
390
+ /** `bundlePath()`: `MAX_BUNDLE_PATH = 320`, for `comparisonPath` and
391
+ * `caseBundlePath`. */
392
+ const RECORD_BUNDLE_PATH_MAX = 320;
393
+ /** `vmId()`: `hex(value, [16, 32], field, 'sbx_')`, so at most 4 + 32. */
394
+ const RECORD_VM_ID_MAX = 36;
395
+ /** `leaseId()`: `token(..., 1, 128, ...)` plus `CASE_BATCH_LEASE_ID_PATTERN`. */
396
+ const RECORD_LEASE_ID_MAX = 128;
397
+ /** `text(entry, 'differing outcome id', 256)` in `outcome()`. */
398
+ const RECORD_DIFFERING_OUTCOME_MAX = 256;
399
+ /** `array(..., 10000)` for a record's and an outcome's `vmIds`, `leaseIds`
400
+ * and `differingOutcomes`. */
401
+ const RECORD_ARRAY_MAX = 10_000;
402
+ /** The gateway accepts only its fixed `unknownReasons` set (longest 22
403
+ * characters) and has no length bound of its own. The CLI deliberately reads
404
+ * an unrecognised reason as UNKNOWN (additive by ruling), so it bounds the
405
+ * string at 128 rather than refusing a reason added later. Looser, never
406
+ * tighter, than the gateway. */
407
+ const RECORD_UNKNOWN_REASON_MAX = 128;
408
+ /** `validSearchRunId`: `cv_` plus 48 hex characters. */
409
+ const SNAPSHOT_RUN_ID_MAX = 51;
410
+ /** `hex(worlds.base, [16], 'base world', 'frz_')`: 4 + 16. */
411
+ const SNAPSHOT_WORLD_MAX = 20;
412
+ /** `date()` in the shared helpers: `text(value, field, 30)`. Also bounds
413
+ * `observedAt`, which is the row's `updated_at` ISO stamp. */
414
+ const SNAPSHOT_STAMP_MAX = 30;
415
+ /** `text(row.error, 'error')`: the shared `text()` default of 2000. */
416
+ const SNAPSHOT_ERROR_MAX = 2_000;
417
+ /** `nextCaseCursor` is a stored `case_id`: `CASE_BATCH_ID_PATTERN`, 1-160. */
418
+ const SNAPSHOT_CASE_CURSOR_MAX = 160;
419
+ function responseText(value, what, maximum = 4_096) {
420
+ if (typeof value !== 'string' || value.length === 0 || value.length > maximum)
421
+ bad(what);
422
+ return value;
423
+ }
424
+ function responseInteger(value, what, minimum, maximum) {
425
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum)
426
+ bad(what);
427
+ return value;
428
+ }
429
+ function responseNullableString(value, what, maximum = 4_096) {
430
+ return value === null ? null : responseText(value, what, maximum);
431
+ }
432
+ function responseStringArray(value, what, maximum) {
433
+ if (!Array.isArray(value) || value.length > RECORD_ARRAY_MAX)
434
+ bad(what);
435
+ return value.map(item => responseText(item, what, maximum));
436
+ }
437
+ function armPhases(value, what) {
438
+ const row = isJsonObject(value) ? value : bad(what);
439
+ const counter = (field) => responseInteger(row[field], `${what}.${field}`, 0, Number.MAX_SAFE_INTEGER);
440
+ return {
441
+ createMs: counter('createMs'),
442
+ readyMs: counter('readyMs'),
443
+ actionsMs: counter('actionsMs'),
444
+ collectMs: counter('collectMs'),
445
+ killMs: counter('killMs'),
446
+ bindingWaitMs: counter('bindingWaitMs'),
447
+ };
448
+ }
449
+ function caseOutcome(value, caseId) {
450
+ const row = isJsonObject(value) ? value : bad('case outcome');
451
+ if (row.caseId !== caseId)
452
+ bad('case outcome (it names a different case)');
453
+ const status = row.status;
454
+ if (status !== 'AGREE' && status !== 'DIFFER' && status !== 'UNKNOWN')
455
+ bad('case outcome status');
456
+ // Additive by ruling: an unrecognised reason still reads as UNKNOWN.
457
+ const unknownReason = responseNullableString(row.unknownReason, 'case outcome unknownReason', RECORD_UNKNOWN_REASON_MAX);
458
+ if ((status === 'UNKNOWN') !== (unknownReason !== null)) {
459
+ bad('case outcome (only an UNKNOWN outcome carries a reason)');
460
+ }
461
+ const phases = isJsonObject(row.phases) ? row.phases : bad('case outcome phases');
462
+ if (typeof row.cleanupProven !== 'boolean')
463
+ bad('case outcome cleanupProven');
464
+ return {
465
+ caseId,
466
+ status,
467
+ unknownReason,
468
+ comparisonPath: responseNullableString(row.comparisonPath, 'case outcome comparisonPath', RECORD_BUNDLE_PATH_MAX),
469
+ caseBundlePath: responseText(row.caseBundlePath, 'case outcome caseBundlePath', RECORD_BUNDLE_PATH_MAX),
470
+ arrivalEvidence: row.arrivalEvidence === null
471
+ ? null
472
+ : (isJsonObject(row.arrivalEvidence) ? row.arrivalEvidence : bad('case outcome arrivalEvidence')),
473
+ wallMs: responseInteger(row.wallMs, 'case outcome wallMs', 0, Number.MAX_SAFE_INTEGER),
474
+ phases: { base1: armPhases(phases.base1, 'phases.base1'), head: armPhases(phases.head, 'phases.head') },
475
+ vmIds: responseStringArray(row.vmIds, 'case outcome vmIds', RECORD_VM_ID_MAX),
476
+ leaseIds: responseStringArray(row.leaseIds, 'case outcome leaseIds', RECORD_LEASE_ID_MAX),
477
+ cleanupProven: row.cleanupProven,
478
+ differingOutcomes: responseStringArray(row.differingOutcomes, 'case outcome differingOutcomes', RECORD_DIFFERING_OUTCOME_MAX),
479
+ detail: responseNullableString(row.detail, 'case outcome detail', RECORD_DETAIL_MAX),
480
+ };
481
+ }
482
+ export function parseCaseBatchCaseRecord(value) {
483
+ const row = isJsonObject(value) ? value : bad('case row');
484
+ // `identity()` in `caseRecord()`: CASE_BATCH_ID_PATTERN, 1-160.
485
+ const id = responseText(row.id, 'case row id', 160);
486
+ if (!CASE_BATCH_ID_PATTERN.test(id))
487
+ bad('case row id');
488
+ const status = row.status;
489
+ if (status !== 'running' && status !== 'completed' && status !== 'incomplete')
490
+ bad('case row status');
491
+ const finishedAtMs = row.finishedAtMs === null
492
+ ? null
493
+ : responseInteger(row.finishedAtMs, 'case row finishedAtMs', 0, Number.MAX_SAFE_INTEGER);
494
+ if ((status === 'running') !== (finishedAtMs === null))
495
+ bad('case row (status disagrees with finishedAtMs)');
496
+ return {
497
+ id,
498
+ label: responseText(row.label, 'case row label', RECORD_LABEL_MAX),
499
+ conditionId: responseText(row.conditionId, 'case row conditionId', RECORD_CONDITION_ID_MAX),
500
+ position: responseInteger(row.position, 'case row position', 0, CASE_BATCH_MAX_CASES - 1),
501
+ launchedAtMs: responseInteger(row.launchedAtMs, 'case row launchedAtMs', 0, Number.MAX_SAFE_INTEGER),
502
+ finishedAtMs,
503
+ status,
504
+ outcome: row.outcome === null || row.outcome === undefined ? null : caseOutcome(row.outcome, id),
505
+ vmIds: responseStringArray(row.vmIds, 'case row vmIds', RECORD_VM_ID_MAX),
506
+ leaseIds: responseStringArray(row.leaseIds, 'case row leaseIds', RECORD_LEASE_ID_MAX),
507
+ };
508
+ }
509
+ function totals(value) {
510
+ const row = isJsonObject(value) ? value : bad('totals');
511
+ const counter = (field) => responseInteger(row[field], `totals.${field}`, 0, Number.MAX_SAFE_INTEGER);
512
+ return {
513
+ cases: counter('cases'),
514
+ launched: counter('launched'),
515
+ settled: counter('settled'),
516
+ agree: counter('agree'),
517
+ differ: counter('differ'),
518
+ unknown: counter('unknown'),
519
+ cleanupProven: counter('cleanupProven'),
520
+ cleanupUnproven: counter('cleanupUnproven'),
521
+ leasesReleased: counter('leasesReleased'),
522
+ };
523
+ }
524
+ function clocks(value) {
525
+ const row = isJsonObject(value) ? value : bad('clocks');
526
+ const stamp = (field) => {
527
+ const candidate = row[field];
528
+ if (candidate === null)
529
+ return null;
530
+ const stampText = responseText(candidate, `clocks.${field}`, SNAPSHOT_STAMP_MAX);
531
+ if (!Number.isFinite(Date.parse(stampText)))
532
+ bad(`clocks.${field}`);
533
+ return stampText;
534
+ };
535
+ const span = (field) => responseInteger(row[field], `clocks.${field}`, 0, Number.MAX_SAFE_INTEGER);
536
+ return {
537
+ preparationStartedAt: stamp('preparationStartedAt'),
538
+ searchStartedAt: stamp('searchStartedAt'),
539
+ launchesClosedAt: stamp('launchesClosedAt'),
540
+ finishedAt: stamp('finishedAt'),
541
+ searchBudgetMs: span('searchBudgetMs'),
542
+ searchWallMs: span('searchWallMs'),
543
+ drainWallMs: span('drainWallMs'),
544
+ };
545
+ }
546
+ function admission(value) {
547
+ const row = isJsonObject(value) ? value : bad('admission');
548
+ if (row.pool !== 'small' && row.pool !== 'big')
549
+ bad('admission pool');
550
+ const counter = (field) => responseInteger(row[field], `admission.${field}`, 0, Number.MAX_SAFE_INTEGER);
551
+ return {
552
+ requestedCases: counter('requestedCases'),
553
+ admittedCases: counter('admittedCases'),
554
+ pool: row.pool,
555
+ freeVmSlots: counter('freeVmSlots'),
556
+ reservedVmSlots: counter('reservedVmSlots'),
557
+ workerProcesses: counter('workerProcesses'),
558
+ casesPerProcess: counter('casesPerProcess'),
559
+ };
560
+ }
561
+ export function parseCaseBatchSnapshot(value, expectedRunId) {
562
+ const row = isJsonObject(value) ? value : bad('run snapshot');
563
+ const runId = responseText(row.runId, 'runId', SNAPSHOT_RUN_ID_MAX);
564
+ if (runId !== expectedRunId)
565
+ bad(`run snapshot (it answers for ${runId}, not ${expectedRunId})`);
566
+ const status = row.status;
567
+ if (typeof status !== 'string' || !CASE_BATCH_STATUSES.includes(status))
568
+ bad('status');
569
+ const worlds = isJsonObject(row.worlds) ? row.worlds : bad('worlds');
570
+ const world = (field) => worlds[field] === null ? null : responseText(worlds[field], `worlds.${field}`, SNAPSHOT_WORLD_MAX);
571
+ if (!Array.isArray(row.cases))
572
+ bad('cases page');
573
+ if (!Array.isArray(row.waves))
574
+ bad('waves page');
575
+ if (typeof row.cleanupComplete !== 'boolean')
576
+ bad('cleanupComplete');
577
+ const observedAt = responseText(row.observedAt, 'observedAt', SNAPSHOT_STAMP_MAX);
578
+ if (!Number.isFinite(Date.parse(observedAt)))
579
+ bad('observedAt');
580
+ return {
581
+ runId,
582
+ status: status,
583
+ generation: responseInteger(row.generation, 'generation', 0, Number.MAX_SAFE_INTEGER),
584
+ observedAt,
585
+ clocks: clocks(row.clocks),
586
+ totals: totals(row.totals),
587
+ worlds: { base: world('base'), head: world('head') },
588
+ ...(row.admission === undefined || row.admission === null ? {} : { admission: admission(row.admission) }),
589
+ ...(row.error === undefined || row.error === null ? {} : { error: responseText(row.error, 'error', SNAPSHOT_ERROR_MAX) }),
590
+ cases: row.cases.map(parseCaseBatchCaseRecord),
591
+ waves: row.waves.map(wave => {
592
+ const record = isJsonObject(wave) ? wave : bad('wave row');
593
+ return {
594
+ wave: responseInteger(record.wave, 'wave.wave', 0, Number.MAX_SAFE_INTEGER),
595
+ atMs: responseInteger(record.atMs, 'wave.atMs', 0, Number.MAX_SAFE_INTEGER),
596
+ launched: responseInteger(record.launched, 'wave.launched', 0, CASE_BATCH_MAX_CASES),
597
+ };
598
+ }),
599
+ vmIds: responseInteger(row.vmIds, 'vmIds', 0, Number.MAX_SAFE_INTEGER),
600
+ cleanupComplete: row.cleanupComplete,
601
+ nextCaseCursor: row.nextCaseCursor === undefined || row.nextCaseCursor === null
602
+ ? null
603
+ : responseText(row.nextCaseCursor, 'nextCaseCursor', SNAPSHOT_CASE_CURSOR_MAX),
604
+ nextWaveCursor: row.nextWaveCursor === undefined || row.nextWaveCursor === null
605
+ ? null
606
+ : responseInteger(row.nextWaveCursor, 'nextWaveCursor', 0, Number.MAX_SAFE_INTEGER),
607
+ };
608
+ }