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