@inflexa-ai/harness 0.19.0 → 0.19.2

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 (101) hide show
  1. package/dist/agents/report-session-agent.d.ts +32 -9
  2. package/dist/agents/report-session-agent.js +22 -6
  3. package/dist/app/report-session-runtime.js +26 -0
  4. package/dist/app/spawn-report-session.d.ts +32 -5
  5. package/dist/app/spawn-report-session.js +19 -0
  6. package/dist/auth/local-run-authorizer.d.ts +3 -2
  7. package/dist/auth/local-run-authorizer.js +4 -2
  8. package/dist/config/environment-stores.d.ts +12 -7
  9. package/dist/config/resource-limits.d.ts +6 -10
  10. package/dist/config/resource-limits.js +6 -44
  11. package/dist/contracts/report-reference.d.ts +4 -0
  12. package/dist/contracts/report-reference.js +3 -0
  13. package/dist/execution/run-authorizer.d.ts +18 -0
  14. package/dist/execution/run-canceler.d.ts +67 -0
  15. package/dist/execution/run-canceler.js +130 -0
  16. package/dist/index.d.ts +7 -3
  17. package/dist/index.js +6 -1
  18. package/dist/lib/chrome.d.ts +14 -0
  19. package/dist/lib/chrome.js +7 -0
  20. package/dist/lib/page-capture.d.ts +47 -0
  21. package/dist/lib/page-capture.js +83 -0
  22. package/dist/loop/run-agent.js +104 -15
  23. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  24. package/dist/memory/ai-sdk-message-storage.js +9 -4
  25. package/dist/memory/count-tokens.js +34 -5
  26. package/dist/prompts/report-session.d.ts +1 -1
  27. package/dist/prompts/report-session.js +21 -0
  28. package/dist/providers/ai-sdk.d.ts +81 -3
  29. package/dist/providers/ai-sdk.js +91 -10
  30. package/dist/providers/types.d.ts +17 -0
  31. package/dist/report-model/assert-rules.d.ts +44 -0
  32. package/dist/report-model/assert-rules.js +97 -0
  33. package/dist/report-model/draft-hash.d.ts +14 -0
  34. package/dist/report-model/draft-hash.js +36 -0
  35. package/dist/report-model/fixture-resolver.js +2 -71
  36. package/dist/report-model/production-resolver.d.ts +124 -0
  37. package/dist/report-model/production-resolver.js +801 -0
  38. package/dist/report-model/reference-resolver.d.ts +6 -0
  39. package/dist/report-model/validate.js +5 -0
  40. package/dist/report-render/assets.d.ts +42 -0
  41. package/dist/report-render/assets.js +61 -0
  42. package/dist/report-render/chart.js +31 -10
  43. package/dist/report-render/design.d.ts +120 -0
  44. package/dist/report-render/design.js +808 -0
  45. package/dist/report-render/page.d.ts +51 -111
  46. package/dist/report-render/page.js +158 -268
  47. package/dist/report-render/render.js +34 -11
  48. package/dist/report-render/views/chart-view.d.ts +6 -3
  49. package/dist/report-render/views/chart-view.js +10 -5
  50. package/dist/report-render/views/page-view.d.ts +19 -8
  51. package/dist/report-render/views/page-view.js +31 -11
  52. package/dist/report-render/views/prose.d.ts +1 -0
  53. package/dist/report-render/views/prose.js +7 -13
  54. package/dist/report-render/views/references-view.js +5 -5
  55. package/dist/report-render/views/values.d.ts +15 -6
  56. package/dist/report-render/views/values.js +29 -10
  57. package/dist/runtime/assemble.d.ts +38 -4
  58. package/dist/runtime/assemble.js +43 -3
  59. package/dist/sandbox/k8s-client.js +18 -0
  60. package/dist/sandbox/mount-plan.d.ts +7 -0
  61. package/dist/sandbox/mount-plan.js +7 -2
  62. package/dist/sandbox/types.d.ts +6 -0
  63. package/dist/state/analyses.d.ts +3 -26
  64. package/dist/state/analyses.js +6 -64
  65. package/dist/state/index.d.ts +2 -2
  66. package/dist/state/index.js +2 -2
  67. package/dist/state/init.js +23 -10
  68. package/dist/state/report-session-state.d.ts +27 -0
  69. package/dist/state/report-session-state.js +29 -2
  70. package/dist/state/runs.d.ts +10 -0
  71. package/dist/state/runs.js +21 -0
  72. package/dist/tasks/data-profile.d.ts +4 -0
  73. package/dist/tasks/data-profile.js +14 -0
  74. package/dist/tasks/extract-values-script.d.ts +64 -0
  75. package/dist/tasks/extract-values-script.js +180 -0
  76. package/dist/tasks/extract-values.d.ts +124 -0
  77. package/dist/tasks/extract-values.js +193 -0
  78. package/dist/tools/approval/gateway.d.ts +8 -1
  79. package/dist/tools/approval/gateway.js +9 -2
  80. package/dist/tools/approval/queries.d.ts +9 -2
  81. package/dist/tools/approval/queries.js +12 -4
  82. package/dist/tools/define-tool.d.ts +29 -0
  83. package/dist/tools/define-tool.js +26 -0
  84. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  85. package/dist/tools/report/preview-snapshot.js +11 -50
  86. package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
  87. package/dist/tools/report-authoring/authoring-tools.js +1 -1
  88. package/dist/tools/report-session/examine-page.d.ts +90 -0
  89. package/dist/tools/report-session/examine-page.js +136 -0
  90. package/dist/tools/report-session/index.d.ts +3 -1
  91. package/dist/tools/report-session/index.js +3 -1
  92. package/dist/tools/report-session/preview-report.d.ts +31 -6
  93. package/dist/tools/report-session/preview-report.js +63 -16
  94. package/dist/tools/report-session/record-version.d.ts +102 -0
  95. package/dist/tools/report-session/record-version.js +139 -0
  96. package/dist/tools/sandbox/list-available-packages.js +2 -1
  97. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  98. package/dist/tools/sandbox/list-available-refs.js +13 -6
  99. package/dist/workflows/sandbox-step.d.ts +4 -0
  100. package/dist/workflows/sandbox-step.js +21 -2
  101. package/package.json +14 -8
@@ -0,0 +1,801 @@
1
+ /**
2
+ * The production realization of the reference resolver. It reads the pinned artifact on disk, and it
3
+ * gives the value that the belief points at.
4
+ *
5
+ * The realization holds three layers. Layer one is identity: membership through `snapshotEntry`, and a
6
+ * streamed hash compare with `computeSha256File`. A hash mismatch fails before any parse. Layer two is
7
+ * the host fast path: an in-process parse of a CSV, a TSV, a JSON, or a parquet file at or under the cap.
8
+ * Layer three is the fall-through: an over-cap file or a host parse fault goes to the extraction arm. On any
9
+ * doubt the read falls through, thus correctness never depends on the host parser. An extension that names
10
+ * no tabular format refuses here, because the host decides the format for both arms.
11
+ *
12
+ * A read of the bytes is the expensive part, thus an optional `ArtifactReadCache` outlives one resolver. It
13
+ * holds the streamed hash and the extracted rows against a stat signature. Two tool calls of one analysis
14
+ * share one cache, and each call still binds its own extraction arm with its own auth.
15
+ *
16
+ * The realization calls the shared assert functions in `assert-rules.ts`, and it mirrors the locator walk
17
+ * and the reason choices of `fixture-resolver.ts`. Thus one semantics exists, and the fixture stays the
18
+ * executable specification of the value tier. The two realizations are substitutable behind the seam.
19
+ */
20
+ import { err, ok } from "neverthrow";
21
+ import { readFile, stat } from "node:fs/promises";
22
+ import { extname } from "node:path";
23
+ import { parquetReadObjects } from "hyparquet";
24
+ import { allWithConcurrency } from "../lib/async-utils.js";
25
+ import { computeSha256File } from "../lib/fs-helpers.js";
26
+ import { resolveWorkspacePath } from "../workspace/paths.js";
27
+ import { asFiniteNumber, cellMatchesFilterValue, checkCitationAssertion, checkValueAssertion } from "./assert-rules.js";
28
+ import { columnsHeldByNoRow, fileTypeHoldsNoCell, snapshotEntry, } from "./reference-resolver.js";
29
+ /**
30
+ * The default cap on the file size that the host fast path reads. A larger file takes the fall-through.
31
+ * The cap gates the cell extraction only. The identity compare streams the hash at every size.
32
+ */
33
+ const DEFAULT_CAP_BYTES = 16 * 1024 * 1024;
34
+ /**
35
+ * The cap on how many artifacts the prepare batch reads at the same time. A large report holds hundreds
36
+ * of references, thus an unbounded fan-out would open one read for each artifact at the same moment.
37
+ */
38
+ const PREPARE_CONCURRENCY = 8;
39
+ /** The analyses that the read store holds at the same time. An older analysis drops out first. */
40
+ const ANALYSIS_CACHE_LIMIT = 4;
41
+ /** The artifacts that one analysis holds at the same time. The rows of a large file live here, thus the bound is small. */
42
+ const ARTIFACT_CACHE_LIMIT = 32;
43
+ /**
44
+ * A bounded map that drops the least recently used entry.
45
+ *
46
+ * A `Map` keeps its insertion order. Thus a read that deletes the key and sets it again moves the key to the
47
+ * end, and the first key of the map is always the oldest use.
48
+ */
49
+ function createBoundedMap(limit) {
50
+ const entries = new Map();
51
+ return {
52
+ get(key) {
53
+ const value = entries.get(key);
54
+ if (value === undefined) {
55
+ return undefined;
56
+ }
57
+ entries.delete(key);
58
+ entries.set(key, value);
59
+ return value;
60
+ },
61
+ set(key, value) {
62
+ entries.delete(key);
63
+ entries.set(key, value);
64
+ if (entries.size > limit) {
65
+ const oldest = entries.keys().next();
66
+ if (!oldest.done) {
67
+ entries.delete(oldest.value);
68
+ }
69
+ }
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * Make the read store of the report path. One host process serves many analyses, thus the store bounds both
75
+ * dimensions: how many analyses it holds, and how many artifacts one analysis holds. A dropped entry costs
76
+ * one read again, and it is never wrong.
77
+ */
78
+ export function createArtifactReadStore() {
79
+ const byAnalysis = createBoundedMap(ANALYSIS_CACHE_LIMIT);
80
+ return {
81
+ forAnalysis(analysisId) {
82
+ const held = byAnalysis.get(analysisId);
83
+ if (held !== undefined) {
84
+ return held;
85
+ }
86
+ const made = createBoundedMap(ARTIFACT_CACHE_LIMIT);
87
+ byAnalysis.set(analysisId, made);
88
+ return made;
89
+ },
90
+ };
91
+ }
92
+ /** Build an `Err` that carries the unresolved reference. The `detail` key is present only when there is a detail to carry. */
93
+ function fail(reference, reason, detail) {
94
+ const failure = detail !== undefined ? { reference, reason, detail } : { reference, reason };
95
+ return err(failure);
96
+ }
97
+ /** A short account of a derivation input, for a detail that names which input broke the arithmetic. */
98
+ function describeReference(reference) {
99
+ return `artifact value at ${reference.path} column ${reference.locator.column}`;
100
+ }
101
+ /**
102
+ * Map a file extension to the format that the host arm reads. An unknown extension gives back `undefined`.
103
+ *
104
+ * This is the one format decision of the report path. The extraction request carries the answer, thus the
105
+ * sandbox arm never repeats the mapping and the two arms hold no second table between them.
106
+ */
107
+ function detectFormat(path) {
108
+ switch (extname(path).toLowerCase()) {
109
+ case ".csv":
110
+ return "csv";
111
+ case ".tsv":
112
+ return "tsv";
113
+ case ".json":
114
+ return "json";
115
+ case ".parquet":
116
+ return "parquet";
117
+ default:
118
+ return undefined;
119
+ }
120
+ }
121
+ /**
122
+ * Read one value as a cell, or drop it.
123
+ *
124
+ * A parquet integer column decodes as a `bigint`, thus the coercion narrows it to a number. A `null` or an
125
+ * absent value is not a cell, thus it drops and the row omits the key. The fixture row omits an empty cell
126
+ * the same way. An object or an array is structural doubt, thus it drops too and the format falls through
127
+ * where the caller detects the loss.
128
+ *
129
+ * A non-finite number is not a cell either. A parquet float column holds a `NaN` or an `Infinity`, thus the
130
+ * coercion drops it and the row omits the key. The extraction script (`tasks/extract-values-script.ts`)
131
+ * drops the same value to `None`, thus the host arm and the sandbox arm read the same bytes the same way.
132
+ */
133
+ export function coerceCell(value) {
134
+ if (value === null || value === undefined) {
135
+ return undefined;
136
+ }
137
+ if (typeof value === "number") {
138
+ return Number.isFinite(value) ? value : undefined;
139
+ }
140
+ if (typeof value === "string") {
141
+ return value;
142
+ }
143
+ if (typeof value === "bigint") {
144
+ const narrowed = Number(value);
145
+ return Number.isFinite(narrowed) ? narrowed : undefined;
146
+ }
147
+ if (typeof value === "boolean") {
148
+ return String(value);
149
+ }
150
+ if (value instanceof Date) {
151
+ return value.toISOString();
152
+ }
153
+ return undefined;
154
+ }
155
+ /**
156
+ * Parse the records of a delimited file with one delimiter, per RFC 4180.
157
+ *
158
+ * A quoted field can hold the delimiter and a line break, and a pair of double quotes is one literal
159
+ * quote. Any structural doubt gives back `undefined`, thus a parse fault falls through and is never a
160
+ * guess. The doubt cases are a quote inside an unquoted field, text after a closing quote, and an
161
+ * unterminated quoted field.
162
+ */
163
+ function parseRecords(text, delimiter) {
164
+ const records = [];
165
+ const n = text.length;
166
+ if (n === 0) {
167
+ return records;
168
+ }
169
+ let i = 0;
170
+ while (i <= n) {
171
+ const record = [];
172
+ for (;;) {
173
+ let field = "";
174
+ if (i < n && text[i] === '"') {
175
+ i += 1;
176
+ let closed = false;
177
+ while (i < n) {
178
+ const ch = text[i];
179
+ if (ch === '"') {
180
+ if (text[i + 1] === '"') {
181
+ field += '"';
182
+ i += 2;
183
+ }
184
+ else {
185
+ i += 1;
186
+ closed = true;
187
+ break;
188
+ }
189
+ }
190
+ else {
191
+ field += ch;
192
+ i += 1;
193
+ }
194
+ }
195
+ if (!closed) {
196
+ return undefined;
197
+ }
198
+ if (i < n && text[i] !== delimiter && text[i] !== "\r" && text[i] !== "\n") {
199
+ return undefined;
200
+ }
201
+ }
202
+ else {
203
+ while (i < n) {
204
+ const ch = text[i];
205
+ if (ch === delimiter || ch === "\r" || ch === "\n") {
206
+ break;
207
+ }
208
+ if (ch === '"') {
209
+ return undefined;
210
+ }
211
+ field += ch;
212
+ i += 1;
213
+ }
214
+ }
215
+ record.push(field);
216
+ if (i < n && text[i] === delimiter) {
217
+ i += 1;
218
+ continue;
219
+ }
220
+ break;
221
+ }
222
+ records.push(record);
223
+ if (i < n && text[i] === "\r") {
224
+ i += 1;
225
+ }
226
+ if (i < n && text[i] === "\n") {
227
+ i += 1;
228
+ }
229
+ if (i >= n) {
230
+ break;
231
+ }
232
+ }
233
+ return records;
234
+ }
235
+ /**
236
+ * Parse a delimited file into rows. The first record is the header. A ragged record and a repeated header
237
+ * name are structural doubt, thus each gives back `undefined` and the file falls through.
238
+ */
239
+ function parseDelimited(text, delimiter) {
240
+ const records = parseRecords(text, delimiter);
241
+ if (records === undefined) {
242
+ return undefined;
243
+ }
244
+ if (records.length === 0) {
245
+ return [];
246
+ }
247
+ const header = records[0];
248
+ const seen = new Set();
249
+ for (const name of header) {
250
+ if (seen.has(name)) {
251
+ return undefined;
252
+ }
253
+ seen.add(name);
254
+ }
255
+ const rows = [];
256
+ for (let r = 1; r < records.length; r += 1) {
257
+ const fields = records[r];
258
+ // A blank line parses as one empty field, thus the walk drops it and reads no dialect into it. A
259
+ // record with more than one field, or one non-empty field, still obeys the count check, thus a
260
+ // genuinely ragged record falls through.
261
+ if (fields.length === 1 && fields[0] === "") {
262
+ continue;
263
+ }
264
+ if (fields.length !== header.length) {
265
+ return undefined;
266
+ }
267
+ const row = {};
268
+ for (let c = 0; c < header.length; c += 1) {
269
+ row[header[c]] = fields[c];
270
+ }
271
+ rows.push(row);
272
+ }
273
+ return rows;
274
+ }
275
+ /**
276
+ * Parse a JSON file into rows. The strict shape is an array of flat objects. A parse fault, a top-level
277
+ * value that is not an array, and a nested cell are structural doubt, thus each gives back `undefined` and
278
+ * the file falls through.
279
+ */
280
+ function parseJsonTable(text) {
281
+ let value;
282
+ try {
283
+ value = JSON.parse(text);
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
288
+ if (!Array.isArray(value)) {
289
+ return undefined;
290
+ }
291
+ const rows = [];
292
+ for (const item of value) {
293
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
294
+ return undefined;
295
+ }
296
+ const row = {};
297
+ for (const [key, cell] of Object.entries(item)) {
298
+ if (cell !== null && typeof cell === "object") {
299
+ return undefined;
300
+ }
301
+ const coerced = coerceCell(cell);
302
+ if (coerced !== undefined) {
303
+ row[key] = coerced;
304
+ }
305
+ }
306
+ rows.push(row);
307
+ }
308
+ return rows;
309
+ }
310
+ /** Wrap in-memory bytes as an `AsyncBuffer`, thus the parquet reader reads from memory and opens no file. */
311
+ function toAsyncBuffer(bytes) {
312
+ const buffer = new ArrayBuffer(bytes.byteLength);
313
+ new Uint8Array(buffer).set(bytes);
314
+ return { byteLength: buffer.byteLength, slice: (start, end) => buffer.slice(start, end) };
315
+ }
316
+ /**
317
+ * Read a parquet file into rows with the pure-JavaScript reader, thus no native library enters the
318
+ * package. A reader fault gives back `undefined`, thus the file falls through.
319
+ */
320
+ async function parseParquet(bytes) {
321
+ let records;
322
+ try {
323
+ records = await parquetReadObjects({ file: toAsyncBuffer(bytes), rowFormat: "object", utf8: true });
324
+ }
325
+ catch {
326
+ return undefined;
327
+ }
328
+ const rows = [];
329
+ for (const record of records) {
330
+ const row = {};
331
+ for (const [key, cell] of Object.entries(record)) {
332
+ const coerced = coerceCell(cell);
333
+ if (coerced !== undefined) {
334
+ row[key] = coerced;
335
+ }
336
+ }
337
+ rows.push(row);
338
+ }
339
+ return rows;
340
+ }
341
+ /**
342
+ * Read the cells of one artifact through the host fast path. An over-cap file and a parse fault each give
343
+ * back a fall-through, thus the arm reads them out of process.
344
+ *
345
+ * An extension that names no tabular format stops here. The arm reads a file by a decided format, and no
346
+ * format is decided, thus the arm has nothing to obey and the read is unavailable.
347
+ */
348
+ async function hostReadCells(cap, path, identity) {
349
+ const format = detectFormat(path);
350
+ if (format === undefined) {
351
+ return { kind: "unsupported-format", detail: `the file at ${path} has no supported tabular format, thus no reader is decided for it` };
352
+ }
353
+ if (identity.size > cap) {
354
+ return { kind: "fall-through", format, detail: `the file size of ${identity.size} bytes is over the ${cap}-byte cap` };
355
+ }
356
+ let bytes;
357
+ try {
358
+ bytes = await readFile(identity.absolute);
359
+ }
360
+ catch {
361
+ return { kind: "fall-through", format, detail: "the file could not be read" };
362
+ }
363
+ if (format === "parquet") {
364
+ const rows = await parseParquet(bytes);
365
+ return rows === undefined ? { kind: "fall-through", format, detail: "the parquet read did not succeed" } : { kind: "rows", rows };
366
+ }
367
+ const text = bytes.toString("utf8");
368
+ if (format === "json") {
369
+ const rows = parseJsonTable(text);
370
+ return rows === undefined ? { kind: "fall-through", format, detail: "the JSON parse did not succeed" } : { kind: "rows", rows };
371
+ }
372
+ const rows = parseDelimited(text, format === "tsv" ? "\t" : ",");
373
+ return rows === undefined ? { kind: "fall-through", format, detail: "the delimited parse did not succeed" } : { kind: "rows", rows };
374
+ }
375
+ /**
376
+ * Compute the identity of one artifact: membership, containment, and the on-disk hash.
377
+ *
378
+ * An agent authors a report reference, thus the path is untrusted. `resolveWorkspacePath` contains the
379
+ * path against the workspace root, the same containment as the preview tool. The hash streams at every
380
+ * size, thus a huge file never loads into memory here.
381
+ *
382
+ * The stat runs before the cache read, thus a deleted file is unreadable even when the cache holds its
383
+ * bytes. A cached hash serves only bytes whose stat signature did not move. `fileType` states a role, and
384
+ * the snapshot of this pass gives it, thus the cache never carries a role from an earlier snapshot.
385
+ */
386
+ async function computeIdentity(workspaceRoot, analysisId, path, snapshot, readCache) {
387
+ const entry = snapshotEntry(snapshot, path);
388
+ if (entry === undefined) {
389
+ return { kind: "missing" };
390
+ }
391
+ const resolved = resolveWorkspacePath({ workspaceRoot, analysisId, path });
392
+ if (resolved.kind !== "ok") {
393
+ return { kind: "unreadable", detail: `the artifact path ${path} escapes the workspace root` };
394
+ }
395
+ let size;
396
+ let signature;
397
+ try {
398
+ const stats = await stat(resolved.absolute);
399
+ size = stats.size;
400
+ // The size, the modification time, and the inode together move when the bytes move. A rewrite in
401
+ // place moves the time, and a replace by rename moves the inode.
402
+ signature = `${stats.size}:${stats.mtimeMs}:${stats.ino}`;
403
+ }
404
+ catch {
405
+ return { kind: "unreadable", detail: `the artifact at ${path} is not readable on disk` };
406
+ }
407
+ const shared = readCache?.get(resolved.absolute);
408
+ if (shared !== undefined && shared.signature === signature) {
409
+ return { kind: "ready", onDiskHash: shared.hash, fileType: entry.fileType, absolute: resolved.absolute, size, signature };
410
+ }
411
+ let onDiskHash;
412
+ try {
413
+ onDiskHash = await computeSha256File(resolved.absolute);
414
+ }
415
+ catch {
416
+ return { kind: "unreadable", detail: `the artifact at ${path} is not readable on disk` };
417
+ }
418
+ readCache?.set(resolved.absolute, { signature, hash: onDiskHash });
419
+ return { kind: "ready", onDiskHash, fileType: entry.fileType, absolute: resolved.absolute, size, signature };
420
+ }
421
+ /**
422
+ * Resolve each fall-through artifact through the extraction arm. One call covers the whole batch. An
423
+ * absent arm gives back `unavailable`, and the detail names the absent arm. An arm that gives no answer
424
+ * for a path gives back `unreadable`.
425
+ */
426
+ async function extractFallThrough(arm, requests) {
427
+ const out = new Map();
428
+ if (requests.length === 0) {
429
+ return out;
430
+ }
431
+ if (arm === undefined) {
432
+ for (const request of requests) {
433
+ out.set(request.path, {
434
+ kind: "unavailable",
435
+ detail: `the extraction arm is not wired, and the file at ${request.path} needs it because ${request.hostDetail}`,
436
+ });
437
+ }
438
+ return out;
439
+ }
440
+ let answers;
441
+ try {
442
+ answers = await arm.extract(requests.map((request) => ({ path: request.path, hash: request.hash, format: request.format })));
443
+ }
444
+ catch {
445
+ // The arm speaks the throw protocol for a genuine infrastructure fault. This boundary turns the
446
+ // throw into a value, thus each reference on the batch reads a failure as data.
447
+ for (const request of requests) {
448
+ out.set(request.path, { kind: "unreadable", detail: `the extraction arm did not read the file at ${request.path}` });
449
+ }
450
+ return out;
451
+ }
452
+ for (const request of requests) {
453
+ const answer = answers.get(request.path);
454
+ out.set(request.path, answer === undefined
455
+ ? { kind: "unreadable", detail: `the extraction arm did not read the file at ${request.path}` }
456
+ : { kind: "rows", rows: answer.rows });
457
+ }
458
+ return out;
459
+ }
460
+ /**
461
+ * Give back the rows that an earlier pass read for these exact bytes, or `undefined` when nothing shared
462
+ * them. The signature compare is the whole guard: a file that changed on disk holds a different signature,
463
+ * thus the earlier rows never answer for the new bytes.
464
+ */
465
+ function sharedRows(readCache, identity) {
466
+ const held = readCache?.get(identity.absolute);
467
+ return held !== undefined && held.signature === identity.signature && held.rows !== undefined ? held.rows : undefined;
468
+ }
469
+ /**
470
+ * Share the rows of one clean read, thus the next pass over the same bytes starts no second container and
471
+ * parses nothing again. A failed read is transient, thus only rows enter the cache.
472
+ */
473
+ function shareRows(readCache, identity, rows) {
474
+ readCache?.set(identity.absolute, { signature: identity.signature, hash: identity.onDiskHash, rows });
475
+ }
476
+ /** The reads that one reference makes. A citation reads no artifact, and a derivation reads its inputs. */
477
+ function accessesOf(reference) {
478
+ switch (reference.kind) {
479
+ case "artifact-value":
480
+ case "artifact-table":
481
+ return [{ path: reference.path, hash: reference.hash, needsCells: true }];
482
+ case "artifact-file":
483
+ return [{ path: reference.path, hash: reference.hash, needsCells: false }];
484
+ case "derivation":
485
+ return reference.inputs.map((input) => ({ path: input.path, hash: input.hash, needsCells: true }));
486
+ case "citation":
487
+ return [];
488
+ }
489
+ }
490
+ /**
491
+ * Make the production resolver over the workspace root and the optional extraction arm.
492
+ *
493
+ * The resolver holds a per-pass cache. `prepare` groups the references by artifact, reads each file one
494
+ * time, and fills the cache. `resolve` then answers from the cache. A `resolve` with no prior `prepare`
495
+ * still answers, and it writes what it read into the same cache. Thus the cost is one read for each
496
+ * artifact on both paths, and never one read for each reference.
497
+ */
498
+ export function createProductionResolver(deps) {
499
+ const { workspaceRoot, analysisId, extractionArm, readCache } = deps;
500
+ const cap = deps.cap ?? DEFAULT_CAP_BYTES;
501
+ // The per-pass caches. `prepare` clears and refills them, thus a later pass reads the artifacts again.
502
+ const idCache = new Map();
503
+ const cellsCache = new Map();
504
+ /**
505
+ * Give back the identity of one path, from the cache after a prepare, or by a fresh read. A fresh read
506
+ * enters the cache, thus a second reference at one path never streams the hash again.
507
+ */
508
+ async function getIdentity(path, snapshot) {
509
+ const cached = idCache.get(path);
510
+ if (cached !== undefined) {
511
+ return cached;
512
+ }
513
+ const identity = await computeIdentity(workspaceRoot, analysisId, path, snapshot, readCache);
514
+ idCache.set(path, identity);
515
+ return identity;
516
+ }
517
+ /**
518
+ * Give back the cells of one path, from the cache after a prepare, or by a fresh read. A fresh read
519
+ * runs the host fast path, and it resolves a fall-through through the arm as a batch of one. The
520
+ * outcome enters the cache, thus a second reference at one path opens no second batch.
521
+ */
522
+ async function getCells(path, identity) {
523
+ const cached = cellsCache.get(path);
524
+ if (cached !== undefined) {
525
+ return cached;
526
+ }
527
+ const shared = sharedRows(readCache, identity);
528
+ if (shared !== undefined) {
529
+ const held = { kind: "rows", rows: shared };
530
+ cellsCache.set(path, held);
531
+ return held;
532
+ }
533
+ const read = await hostReadCells(cap, path, identity);
534
+ if (read.kind === "rows") {
535
+ const parsed = { kind: "rows", rows: read.rows };
536
+ shareRows(readCache, identity, read.rows);
537
+ cellsCache.set(path, parsed);
538
+ return parsed;
539
+ }
540
+ if (read.kind === "unsupported-format") {
541
+ const unavailable = { kind: "unavailable", detail: read.detail };
542
+ cellsCache.set(path, unavailable);
543
+ return unavailable;
544
+ }
545
+ const resolved = await extractFallThrough(extractionArm, [{ path, hash: identity.onDiskHash, format: read.format, hostDetail: read.detail }]);
546
+ const cells = resolved.get(path) ?? { kind: "unreadable", detail: `the extraction arm did not read the file at ${path}` };
547
+ if (cells.kind === "rows") {
548
+ shareRows(readCache, identity, cells.rows);
549
+ }
550
+ cellsCache.set(path, cells);
551
+ return cells;
552
+ }
553
+ /**
554
+ * The identity gate that a cell-reading reference passes before a read of cells.
555
+ *
556
+ * The gate answers membership, the on-disk hash, and the file type. A hash mismatch fails before any
557
+ * parse, thus a drifted file never reaches the host parser.
558
+ */
559
+ async function gateCells(reference, snapshot) {
560
+ const identity = await getIdentity(reference.path, snapshot);
561
+ if (identity.kind === "missing") {
562
+ return err({ reference, reason: "artifact-missing", detail: `no artifact at ${reference.path}` });
563
+ }
564
+ if (identity.kind === "unreadable") {
565
+ return err({ reference, reason: "unreadable-artifact", detail: identity.detail });
566
+ }
567
+ if (reference.hash !== identity.onDiskHash) {
568
+ return err({ reference, reason: "hash-mismatch", detail: `expected ${reference.hash} but the artifact hash is ${identity.onDiskHash}` });
569
+ }
570
+ if (fileTypeHoldsNoCell(identity.fileType)) {
571
+ return err({ reference, reason: "unreadable-artifact", detail: `the ${identity.fileType} at ${reference.path} holds no cell to read` });
572
+ }
573
+ const cells = await getCells(reference.path, identity);
574
+ if (cells.kind === "unavailable") {
575
+ return err({ reference, reason: "extraction-unavailable", detail: cells.detail });
576
+ }
577
+ if (cells.kind === "unreadable") {
578
+ return err({ reference, reason: "unreadable-artifact", detail: cells.detail });
579
+ }
580
+ return ok(cells.rows);
581
+ }
582
+ /** Walk the locator over the rows, and match the resolved cell against the authored belief. */
583
+ function selectCell(reference, rows) {
584
+ const locator = reference.locator;
585
+ let selectedRow;
586
+ if (locator.row !== undefined) {
587
+ if (locator.row < 0 || locator.row >= rows.length) {
588
+ return fail(reference, "locator-out-of-range", `row ${locator.row} is outside the ${rows.length} rows`);
589
+ }
590
+ selectedRow = rows[locator.row];
591
+ }
592
+ else if (locator.rowFilter !== undefined) {
593
+ const filter = locator.rowFilter;
594
+ const matches = rows.filter((row) => cellMatchesFilterValue(row[filter.column], filter.value));
595
+ if (matches.length === 0) {
596
+ return fail(reference, "locator-out-of-range", `no row where ${filter.column} equals ${String(filter.value)}`);
597
+ }
598
+ if (matches.length > 1) {
599
+ return fail(reference, "ambiguous-match", `${matches.length} rows where ${filter.column} equals ${String(filter.value)}`);
600
+ }
601
+ selectedRow = matches[0];
602
+ }
603
+ else {
604
+ // The locator schema forbids a locator with neither selector. The resolver cannot depend on a
605
+ // prior parse, thus it treats a row that no selector addresses as out of range.
606
+ return fail(reference, "locator-out-of-range", "the locator selects no row");
607
+ }
608
+ // A real table holds an empty cell, and a parse gives it back as an absent key. An absent key is
609
+ // not a value that a scalar reference can bind. An empty string is a value, thus it stays valid.
610
+ const cell = selectedRow[locator.column];
611
+ if (cell === undefined || cell === null) {
612
+ return fail(reference, "locator-out-of-range", `column ${locator.column} holds no value in the selected row`);
613
+ }
614
+ const valueFailure = checkValueAssertion(reference, reference.assert?.value, reference.assert?.tolerance, cell);
615
+ return valueFailure !== undefined ? err(valueFailure) : ok({ type: "scalar", value: cell });
616
+ }
617
+ async function resolveArtifactValue(reference, snapshot) {
618
+ const gated = await gateCells(reference, snapshot);
619
+ return gated.isErr() ? err(gated.error) : selectCell(reference, gated.value);
620
+ }
621
+ async function resolveArtifactTable(reference, snapshot) {
622
+ const gated = await gateCells(reference, snapshot);
623
+ if (gated.isErr()) {
624
+ return err(gated.error);
625
+ }
626
+ const rows = gated.value;
627
+ const columns = reference.columns;
628
+ if (columns === undefined) {
629
+ return ok({ type: "table", rows });
630
+ }
631
+ // A name that no row holds addresses nothing. Without this check a projection onto an invented
632
+ // column gives an empty cell for each row and still resolves, which grounding exists to reject.
633
+ const absent = columnsHeldByNoRow(rows, columns);
634
+ if (absent.length > 0) {
635
+ return fail(reference, "locator-out-of-range", `the table at ${reference.path} holds no column ${absent.join(", ")}`);
636
+ }
637
+ // Project each row onto the requested columns. A table tolerates a ragged row, thus a column that
638
+ // a given row lacks is left out of that row and is not a failure.
639
+ const projectedRows = rows.map((row) => {
640
+ const projected = {};
641
+ for (const column of columns) {
642
+ if (column in row) {
643
+ projected[column] = row[column];
644
+ }
645
+ }
646
+ return projected;
647
+ });
648
+ return ok({ type: "table", rows: projectedRows, columns });
649
+ }
650
+ async function resolveArtifactFile(reference, snapshot) {
651
+ const identity = await getIdentity(reference.path, snapshot);
652
+ if (identity.kind === "missing") {
653
+ return fail(reference, "artifact-missing", `no artifact at ${reference.path}`);
654
+ }
655
+ if (identity.kind === "unreadable") {
656
+ return fail(reference, "unreadable-artifact", identity.detail);
657
+ }
658
+ if (reference.hash !== identity.onDiskHash) {
659
+ return fail(reference, "hash-mismatch", `expected ${reference.hash} but the artifact hash is ${identity.onDiskHash}`);
660
+ }
661
+ return ok({ type: "file", path: reference.path, hash: identity.onDiskHash });
662
+ }
663
+ async function resolveDerivation(reference, snapshot) {
664
+ const numbers = [];
665
+ for (const input of reference.inputs) {
666
+ const inputResult = await resolveArtifactValue(input, snapshot);
667
+ if (inputResult.isErr()) {
668
+ // Keep the inner reason so a reviewer sees the real cause, for example a missing artifact
669
+ // under the derivation. The derivation itself did not fail on its own terms.
670
+ return fail(reference, inputResult.error.reason, inputResult.error.detail);
671
+ }
672
+ const resolved = inputResult.value;
673
+ const numeric = resolved.type === "scalar" ? asFiniteNumber(resolved.value) : undefined;
674
+ if (numeric === undefined) {
675
+ // The arithmetic needs a finite number. A cell that holds text does not address a usable
676
+ // scalar, thus the closest reason is that the coordinate is out of range.
677
+ return fail(reference, "locator-out-of-range", `input ${describeReference(input)} did not resolve to a finite number`);
678
+ }
679
+ numbers.push(numeric);
680
+ }
681
+ const a = numbers[0];
682
+ const b = numbers[1];
683
+ let result;
684
+ switch (reference.op) {
685
+ case "ratio":
686
+ result = a / b;
687
+ break;
688
+ case "delta":
689
+ result = a - b;
690
+ break;
691
+ case "pctChange":
692
+ // A fraction, not a percent. A change of one half gives 0.5, thus an author asserts 0.5 and
693
+ // a `unit` of `%` only tells a renderer how to show it.
694
+ result = (a - b) / b;
695
+ break;
696
+ }
697
+ if (!Number.isFinite(result)) {
698
+ return fail(reference, "locator-out-of-range", `operation ${reference.op} does not yield a finite value, because the divisor is zero`);
699
+ }
700
+ const valueFailure = checkValueAssertion(reference, reference.assert?.value, reference.assert?.tolerance, result);
701
+ return valueFailure !== undefined ? err(valueFailure) : ok({ type: "scalar", value: result });
702
+ }
703
+ function resolveCitation(reference, snapshot) {
704
+ const key = `${reference.idKind}:${reference.id}`;
705
+ if (snapshot.citations === undefined || !snapshot.citations.includes(key)) {
706
+ return fail(reference, "artifact-missing", `the citation ${key} is not in the pinned evidence`);
707
+ }
708
+ const value = { type: "citation", id: key };
709
+ const citationFailure = checkCitationAssertion(reference, reference.assert?.value, key);
710
+ return citationFailure !== undefined ? err(citationFailure) : ok(value);
711
+ }
712
+ async function resolve(reference, snapshot) {
713
+ switch (reference.kind) {
714
+ case "artifact-value":
715
+ return resolveArtifactValue(reference, snapshot);
716
+ case "artifact-table":
717
+ return resolveArtifactTable(reference, snapshot);
718
+ case "artifact-file":
719
+ return resolveArtifactFile(reference, snapshot);
720
+ case "derivation":
721
+ return resolveDerivation(reference, snapshot);
722
+ case "citation":
723
+ return resolveCitation(reference, snapshot);
724
+ }
725
+ }
726
+ async function prepare(references, snapshot) {
727
+ idCache.clear();
728
+ cellsCache.clear();
729
+ // Group the read intent by path. Two references at one path share one file read.
730
+ const byPath = new Map();
731
+ for (const reference of references) {
732
+ for (const access of accessesOf(reference)) {
733
+ let intent = byPath.get(access.path);
734
+ if (intent === undefined) {
735
+ intent = { needsCells: false, cellHashes: new Set() };
736
+ byPath.set(access.path, intent);
737
+ }
738
+ if (access.needsCells) {
739
+ intent.needsCells = true;
740
+ intent.cellHashes.add(access.hash);
741
+ }
742
+ }
743
+ }
744
+ // Compute the identity of each path, one read each, under the fan-out bound.
745
+ const entries = [...byPath.entries()];
746
+ const identities = await allWithConcurrency(entries.map(([path]) => () => computeIdentity(workspaceRoot, analysisId, path, snapshot, readCache)), PREPARE_CONCURRENCY);
747
+ for (let e = 0; e < entries.length; e += 1) {
748
+ idCache.set(entries[e][0], identities[e]);
749
+ }
750
+ // Read cells for each cell-reading path whose on-disk hash matches an expected pin. A hash that no
751
+ // pin expects means every reference at the path mismatches, thus no parse runs on a drifted file.
752
+ const cellTargets = [];
753
+ for (const [path, intent] of entries) {
754
+ const identity = idCache.get(path);
755
+ if (intent.needsCells && identity !== undefined && identity.kind === "ready" && intent.cellHashes.has(identity.onDiskHash)) {
756
+ cellTargets.push({ path, identity });
757
+ }
758
+ }
759
+ // An earlier pass over these exact bytes already paid for the rows. Take them, thus the second pass
760
+ // of one analysis parses nothing again and starts no second container.
761
+ const pending = [];
762
+ for (const target of cellTargets) {
763
+ const shared = sharedRows(readCache, target.identity);
764
+ if (shared !== undefined) {
765
+ cellsCache.set(target.path, { kind: "rows", rows: shared });
766
+ }
767
+ else {
768
+ pending.push(target);
769
+ }
770
+ }
771
+ const reads = await allWithConcurrency(pending.map((target) => () => hostReadCells(cap, target.path, target.identity)), PREPARE_CONCURRENCY);
772
+ // Split the clean reads from the fall-throughs, then resolve every fall-through in one arm call. An
773
+ // unsupported format reaches no arm, because no reader is decided for it.
774
+ const fallThrough = [];
775
+ const identityByPath = new Map();
776
+ for (let t = 0; t < pending.length; t += 1) {
777
+ const target = pending[t];
778
+ const read = reads[t];
779
+ if (read.kind === "rows") {
780
+ cellsCache.set(target.path, { kind: "rows", rows: read.rows });
781
+ shareRows(readCache, target.identity, read.rows);
782
+ }
783
+ else if (read.kind === "unsupported-format") {
784
+ cellsCache.set(target.path, { kind: "unavailable", detail: read.detail });
785
+ }
786
+ else {
787
+ identityByPath.set(target.path, target.identity);
788
+ fallThrough.push({ path: target.path, hash: target.identity.onDiskHash, format: read.format, hostDetail: read.detail });
789
+ }
790
+ }
791
+ const resolved = await extractFallThrough(extractionArm, fallThrough);
792
+ for (const [path, cells] of resolved) {
793
+ cellsCache.set(path, cells);
794
+ const identity = identityByPath.get(path);
795
+ if (cells.kind === "rows" && identity !== undefined) {
796
+ shareRows(readCache, identity, cells.rows);
797
+ }
798
+ }
799
+ }
800
+ return { resolve, prepare };
801
+ }