@inflexa-ai/harness 0.19.2 → 0.21.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 (139) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +31 -6
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/literature/sources/pubmed.js +2 -1
  27. package/dist/loop/run-agent.js +58 -23
  28. package/dist/loop/tool-detail.d.ts +19 -2
  29. package/dist/loop/tool-detail.js +35 -2
  30. package/dist/memory/content-to-cortex.d.ts +3 -2
  31. package/dist/memory/content-to-cortex.js +21 -12
  32. package/dist/memory/conversation-display-replay.js +14 -7
  33. package/dist/memory/thread-history.d.ts +60 -3
  34. package/dist/memory/thread-history.js +55 -16
  35. package/dist/memory/thread-store.d.ts +11 -1
  36. package/dist/memory/thread-store.js +12 -2
  37. package/dist/prompts/conversation.d.ts +1 -1
  38. package/dist/prompts/conversation.js +23 -6
  39. package/dist/prompts/report-session.d.ts +1 -1
  40. package/dist/prompts/report-session.js +183 -9
  41. package/dist/report-model/authoring-grammar.d.ts +463 -0
  42. package/dist/report-model/authoring-grammar.js +77 -0
  43. package/dist/report-model/block-walk.d.ts +57 -14
  44. package/dist/report-model/block-walk.js +116 -11
  45. package/dist/report-model/draft-finish.d.ts +22 -4
  46. package/dist/report-model/draft-finish.js +39 -7
  47. package/dist/report-model/draft-operations.d.ts +3 -0
  48. package/dist/report-model/draft-operations.js +19 -5
  49. package/dist/report-model/draft-read.d.ts +8 -5
  50. package/dist/report-model/draft-read.js +21 -5
  51. package/dist/report-model/fixture-resolver.js +15 -4
  52. package/dist/report-model/pin-snapshot.d.ts +24 -6
  53. package/dist/report-model/pin-snapshot.js +204 -6
  54. package/dist/report-model/production-resolver.js +15 -4
  55. package/dist/report-model/reference-resolver.d.ts +50 -1
  56. package/dist/report-model/reference-resolver.js +94 -1
  57. package/dist/report-model/reference-stamp.d.ts +45 -0
  58. package/dist/report-model/reference-stamp.js +173 -0
  59. package/dist/report-model/structural-validation.d.ts +4 -1
  60. package/dist/report-model/structural-validation.js +48 -3
  61. package/dist/report-model/validate.d.ts +1 -1
  62. package/dist/report-model/validate.js +1 -1
  63. package/dist/report-render/asset-lookup.d.ts +19 -0
  64. package/dist/report-render/asset-lookup.js +37 -0
  65. package/dist/report-render/assets.d.ts +45 -1
  66. package/dist/report-render/assets.js +65 -6
  67. package/dist/report-render/chart-presets.d.ts +102 -0
  68. package/dist/report-render/chart-presets.js +205 -0
  69. package/dist/report-render/chart.d.ts +112 -10
  70. package/dist/report-render/chart.js +1164 -41
  71. package/dist/report-render/design.d.ts +99 -0
  72. package/dist/report-render/design.js +289 -107
  73. package/dist/report-render/number-format.d.ts +117 -0
  74. package/dist/report-render/number-format.js +479 -0
  75. package/dist/report-render/page.d.ts +121 -5
  76. package/dist/report-render/page.js +689 -7
  77. package/dist/report-render/references.d.ts +70 -16
  78. package/dist/report-render/references.js +54 -22
  79. package/dist/report-render/render.d.ts +23 -5
  80. package/dist/report-render/render.js +81 -22
  81. package/dist/report-render/table-data.d.ts +92 -0
  82. package/dist/report-render/table-data.js +129 -0
  83. package/dist/report-render/types.d.ts +15 -0
  84. package/dist/report-render/value-bridge.js +6 -2
  85. package/dist/report-render/views/chart-view.d.ts +12 -5
  86. package/dist/report-render/views/chart-view.js +14 -8
  87. package/dist/report-render/views/page-view.d.ts +36 -15
  88. package/dist/report-render/views/page-view.js +45 -23
  89. package/dist/report-render/views/prose.d.ts +12 -2
  90. package/dist/report-render/views/prose.js +29 -7
  91. package/dist/report-render/views/references-view.d.ts +22 -8
  92. package/dist/report-render/views/references-view.js +101 -13
  93. package/dist/report-render/views/values.d.ts +128 -13
  94. package/dist/report-render/views/values.js +199 -18
  95. package/dist/runtime/assemble.d.ts +39 -5
  96. package/dist/runtime/assemble.js +47 -6
  97. package/dist/sandbox/client.d.ts +10 -1
  98. package/dist/sandbox/create-sandbox.d.ts +9 -4
  99. package/dist/sandbox/create-sandbox.js +22 -16
  100. package/dist/sandbox/docker-client.js +10 -7
  101. package/dist/sandbox/k8s-client.js +11 -18
  102. package/dist/sandbox/mount-plan.d.ts +45 -7
  103. package/dist/sandbox/mount-plan.js +43 -5
  104. package/dist/sandbox/types.d.ts +10 -6
  105. package/dist/state/init.js +38 -9
  106. package/dist/state/report-session-state.d.ts +42 -1
  107. package/dist/state/report-session-state.js +70 -2
  108. package/dist/state/report-versions.d.ts +22 -17
  109. package/dist/state/report-versions.js +48 -35
  110. package/dist/state/snapshot-parse.js +16 -1
  111. package/dist/tasks/data-profile.d.ts +7 -4
  112. package/dist/tasks/data-profile.js +9 -9
  113. package/dist/tools/define-tool.d.ts +32 -0
  114. package/dist/tools/define-tool.js +4 -0
  115. package/dist/tools/detail-resolver.d.ts +7 -2
  116. package/dist/tools/detail-resolver.js +7 -2
  117. package/dist/tools/iterate-report.d.ts +1 -1
  118. package/dist/tools/report/preview-snapshot.js +1 -0
  119. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  120. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  121. package/dist/tools/report-session/derive-table.d.ts +162 -0
  122. package/dist/tools/report-session/derive-table.js +459 -0
  123. package/dist/tools/report-session/examine-page.d.ts +48 -18
  124. package/dist/tools/report-session/examine-page.js +204 -24
  125. package/dist/tools/report-session/index.d.ts +2 -0
  126. package/dist/tools/report-session/index.js +2 -0
  127. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  128. package/dist/tools/report-session/list-artifacts.js +197 -0
  129. package/dist/tools/report-session/preview-report.d.ts +6 -0
  130. package/dist/tools/report-session/preview-report.js +180 -28
  131. package/dist/tools/report-session/record-version.d.ts +22 -10
  132. package/dist/tools/report-session/record-version.js +99 -14
  133. package/dist/tools/start-report-session.d.ts +115 -0
  134. package/dist/tools/start-report-session.js +179 -0
  135. package/dist/workflows/sandbox-step.d.ts +7 -4
  136. package/dist/workflows/sandbox-step.js +10 -12
  137. package/dist/workspace/paths.d.ts +50 -0
  138. package/dist/workspace/paths.js +63 -0
  139. package/package.json +2 -1
@@ -6,6 +6,12 @@
6
6
  * resolves each reference through the injected resolver, bridges the resolved values into the render model,
7
7
  * and renders the page.
8
8
  *
9
+ * The renderer writes no file. It gives the page and the data asset of each table, and this tool stages
10
+ * them beside the figures. It also writes the script of each derivation that the document references, from
11
+ * the text of the durable record. The shipped libraries and fonts stage under `assets/deps/`, and each
12
+ * report-side file at the root of `assets/`. The stage is authoritative over that root: what this preview
13
+ * wrote stays, and each other file goes.
14
+ *
9
15
  * The page and its staged assets land in `report-sessions/{threadId}/` under the workspace root. That
10
16
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
11
17
  * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
@@ -25,19 +31,21 @@
25
31
  * control-flow exception propagate.
26
32
  */
27
33
  import { err, ok } from "neverthrow";
28
- import { copyFile, mkdir, writeFile } from "node:fs/promises";
29
- import { createRequire } from "node:module";
34
+ import { copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
30
35
  import { extname, join } from "node:path";
36
+ import { relative as relativePosix } from "node:path/posix";
31
37
  import { z } from "zod";
32
- import { resolveWorkspacePath } from "../../workspace/paths.js";
38
+ import { reportSessionDir, resolveWorkspacePath } from "../../workspace/paths.js";
33
39
  import { createNoopLogger } from "../../lib/console-logger.js";
34
40
  import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
35
41
  import { defaultErrorFields } from "../../lib/logger.js";
42
+ import { referencedPaths, walkBlocks } from "../../report-model/block-walk.js";
36
43
  import { computeDraftHash } from "../../report-model/draft-hash.js";
37
44
  import { finishDraft } from "../../report-model/draft-finish.js";
38
45
  import { resolveDocumentReferences } from "../../report-model/validate.js";
39
46
  import { bridgeValues } from "../../report-render/value-bridge.js";
40
- import { PAGE_ASSETS } from "../../report-render/assets.js";
47
+ import { resolvePageAssetFromInstallation } from "../../report-render/asset-lookup.js";
48
+ import { ASSETS_DIR, DEPS_DIR, derivationScriptName, PAGE_ASSETS, stagedSource, tableSidecarName } from "../../report-render/assets.js";
41
49
  import { renderReportPage } from "../../report-render/render.js";
42
50
  import { defineTool } from "../define-tool.js";
43
51
  import { openReportThread } from "../report-authoring/authoring-tools.js";
@@ -58,13 +66,6 @@ function assetFileName(file) {
58
66
  function figureSourcePolicy(file) {
59
67
  return `assets/${assetFileName(file)}`;
60
68
  }
61
- /**
62
- * The resolver of a package source. A manifest entry names a module specifier, and the resolution reads
63
- * the installation of the harness. Thus the staged bytes are the bytes of the pinned version.
64
- */
65
- const moduleResolver = createRequire(import.meta.url);
66
- /** The default asset lookup: the module resolution of the installation of the harness. */
67
- const resolvePageAssetFromInstallation = (specifier) => moduleResolver.resolve(specifier);
68
69
  /**
69
70
  * Pair each block with the resolved value of its binding, in document order.
70
71
  *
@@ -110,6 +111,77 @@ function collectResolutions(blocks, resolvedByBlock) {
110
111
  }
111
112
  return resolutions;
112
113
  }
114
+ /**
115
+ * The sidecar of each table block of the document, in document order.
116
+ *
117
+ * A table card offers the whole table as a download, and the download is the pinned file itself and never a
118
+ * re-serialization of it. The card spells the staged name through `tableSidecarName`, thus the stage reads
119
+ * that same function and the link and the file cannot disagree.
120
+ *
121
+ * A chart binds a table too, and it holds no card. Thus the walk reads the table blocks alone.
122
+ */
123
+ function collectTableSidecars(blocks) {
124
+ const sidecars = [];
125
+ const visit = (block) => {
126
+ if (block.kind === "section") {
127
+ for (const child of block.blocks) {
128
+ visit(child);
129
+ }
130
+ return;
131
+ }
132
+ if (block.kind === "table") {
133
+ sidecars.push({ blockId: block.id, name: tableSidecarName(block.binding.hash, block.binding.path), path: block.binding.path });
134
+ }
135
+ };
136
+ for (const block of blocks) {
137
+ visit(block);
138
+ }
139
+ return sidecars;
140
+ }
141
+ /**
142
+ * The derivation records that the document references, in the order that they landed.
143
+ *
144
+ * A binding names an output path, thus the used set is the intersection of the recorded paths and the paths
145
+ * of the document. The record of a dropped block stays in the durable state, and this walk leaves it out.
146
+ * Thus the page states the chain of what it shows, and the stage writes the script of what it states.
147
+ */
148
+ function referencedDerivations(document, derivations) {
149
+ if (derivations.length === 0) {
150
+ return [];
151
+ }
152
+ const named = referencedPaths(walkBlocks(document.sections).references);
153
+ return derivations.filter((record) => named.has(record.outputPath));
154
+ }
155
+ /**
156
+ * The chain of one record, with the two relative links that its appendix entry carries.
157
+ *
158
+ * The script link names the staged asset through the shared name function, thus the link and the file that
159
+ * the stage writes cannot disagree. The output link is the path of the derived file against the directory of
160
+ * the page. The derived table already sits inside the session directory, thus the link needs no copy.
161
+ */
162
+ function chainOf(record, sessionDir) {
163
+ return {
164
+ outputPath: record.outputPath,
165
+ sources: record.sources,
166
+ scriptHash: record.scriptHash,
167
+ scriptSource: stagedSource(derivationScriptName(record.scriptHash)),
168
+ outputSource: relativePosix(sessionDir, record.outputPath),
169
+ };
170
+ }
171
+ /**
172
+ * The script of each referenced derivation, keyed by its staged name.
173
+ *
174
+ * Two records of one script text carry one hash, thus they take one name and the map holds one entry. The
175
+ * name is content-addressed, thus an amended script stages beside no stale copy of itself and the sweep
176
+ * removes the name that the new page does not reference.
177
+ */
178
+ function scriptAssets(records) {
179
+ const scripts = new Map();
180
+ for (const record of records) {
181
+ scripts.set(derivationScriptName(record.scriptHash), record.script);
182
+ }
183
+ return scripts;
184
+ }
113
185
  /**
114
186
  * Resolve each reference of the document, and split the resolutions from the unresolved references.
115
187
  *
@@ -136,9 +208,14 @@ async function resolveDocument(document, resolver, snapshot) {
136
208
  * thus the containment test runs before any copy and reuses the one workspace-path resolver. A source
137
209
  * outside the root refuses and names the block, and no copy runs.
138
210
  *
139
- * The page references the chart runtime and the fonts under the same `assets/` directory, thus one copy
140
- * loop stages the figures and the manifest entries together. The manifest is never empty, thus the assets
141
- * directory exists beside every page.
211
+ * The page references the chart runtime and the fonts under `assets/deps/`, and each report-side file at
212
+ * the root of `assets/`. Each manifest entry names its own subpath, thus the copy loop reads the manifest
213
+ * and it spells no layout of its own. The manifest is never empty, thus both directories exist beside every
214
+ * page.
215
+ *
216
+ * The data assets and the derivation scripts are the parts that this preview produces rather than the disk.
217
+ * They write after the copies and before the page. The sweep then runs, and the root of the directory holds
218
+ * the closure of this page alone.
142
219
  */
143
220
  async function renderToWorkspace(args) {
144
221
  let root;
@@ -148,31 +225,47 @@ async function renderToWorkspace(args) {
148
225
  catch (cause) {
149
226
  return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveWorkspaceRoot", path: args.analysisId, cause } });
150
227
  }
151
- const sessionDir = join(root, "report-sessions", args.threadId);
152
- const assetsDir = join(sessionDir, "assets");
228
+ const sessionDir = join(root, reportSessionDir(args.threadId));
229
+ // The page addresses each staged file through `assetSource`, which spells the same segment. Thus the
230
+ // directory that receives the copies comes from that one constant, and never from a literal here.
231
+ const assetsDir = join(sessionDir, ASSETS_DIR);
232
+ const depsDir = join(assetsDir, DEPS_DIR);
153
233
  const pagePath = join(sessionDir, "index.html");
154
234
  // Each bound figure, contained and deduplicated by its staged name, mapped to its host source.
155
- const sources = new Map();
235
+ const reportFiles = new Map();
156
236
  for (const resolution of args.resolutions) {
157
237
  if (resolution.kind !== "figure" || resolution.resolved.type !== "file") {
158
238
  continue;
159
239
  }
160
240
  const file = resolution.resolved;
161
241
  const name = assetFileName(file);
162
- if (sources.has(name)) {
242
+ if (reportFiles.has(name)) {
163
243
  continue;
164
244
  }
165
245
  const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: file.path });
166
246
  if (resolved.kind !== "ok") {
167
247
  return err({ kind: "figure-out-of-scope", blockId: resolution.blockId, path: file.path });
168
248
  }
169
- sources.set(name, resolved.absolute);
249
+ reportFiles.set(name, resolved.absolute);
170
250
  }
171
- // Each manifest entry, mapped from its staged name to the file that the asset lookup gives. A specifier
172
- // that does not resolve is a fault of the installation, thus it rides the `fs` kind.
251
+ // Each table sidecar: the pinned bytes themselves, under the name that the download link of the card
252
+ // spells. The path of a snapshot entry is untrusted, thus the containment test runs here too.
253
+ for (const sidecar of args.sidecars) {
254
+ if (reportFiles.has(sidecar.name)) {
255
+ continue;
256
+ }
257
+ const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: sidecar.path });
258
+ if (resolved.kind !== "ok") {
259
+ return err({ kind: "figure-out-of-scope", blockId: sidecar.blockId, path: sidecar.path });
260
+ }
261
+ reportFiles.set(sidecar.name, resolved.absolute);
262
+ }
263
+ // Each manifest entry, mapped from its staged subpath to the file that the asset lookup gives. A
264
+ // specifier that does not resolve is a fault of the installation, thus it rides the `fs` kind.
265
+ const depsFiles = new Map();
173
266
  for (const asset of PAGE_ASSETS) {
174
267
  try {
175
- sources.set(asset.file, args.resolvePageAsset(asset.specifier));
268
+ depsFiles.set(asset.file, args.resolvePageAsset(asset.specifier));
176
269
  }
177
270
  catch (cause) {
178
271
  return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveAsset", path: asset.specifier, cause } });
@@ -182,22 +275,66 @@ async function renderToWorkspace(args) {
182
275
  if (madeSession.isErr()) {
183
276
  return err({ kind: "fs", error: madeSession.error });
184
277
  }
185
- const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(assetsDir, { recursive: true }), { path: assetsDir });
278
+ // The recursive make covers the assets directory itself, thus one call answers for both levels.
279
+ const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(depsDir, { recursive: true }), { path: depsDir });
186
280
  if (madeAssets.isErr()) {
187
281
  return err({ kind: "fs", error: madeAssets.error });
188
282
  }
189
- for (const [name, source] of sources) {
283
+ for (const [name, source] of [...reportFiles, ...depsFiles]) {
190
284
  const copied = await tryFsWrite("preview.copyFile", () => copyFile(source, join(assetsDir, name)), { path: source });
191
285
  if (copied.isErr()) {
192
286
  return err({ kind: "fs", error: copied.error });
193
287
  }
194
288
  }
289
+ // A data asset is source text that the renderer derived, and a script is the text of a durable record.
290
+ // Neither one sits on disk under its staged name, thus both write and neither one copies.
291
+ const texts = [...args.dataAssets.map((asset) => [asset.name, asset.bytes]), ...args.scripts];
292
+ for (const [name, text] of texts) {
293
+ const assetPath = join(assetsDir, name);
294
+ const wroteAsset = await tryFsWrite("preview.writeFile", () => writeFile(assetPath, text, "utf8"), { path: assetPath });
295
+ if (wroteAsset.isErr()) {
296
+ return err({ kind: "fs", error: wroteAsset.error });
297
+ }
298
+ }
195
299
  const wrote = await tryFsWrite("preview.writeFile", () => writeFile(pagePath, args.page, "utf8"), { path: pagePath });
196
300
  if (wrote.isErr()) {
197
301
  return err({ kind: "fs", error: wrote.error });
198
302
  }
303
+ const staged = new Set([...reportFiles.keys(), ...texts.map(([name]) => name)]);
304
+ await sweepAssets(assetsDir, staged, args.logger);
199
305
  return ok(pagePath);
200
306
  }
307
+ /**
308
+ * Remove each report-side file of the assets directory that this preview did not stage.
309
+ *
310
+ * The stage is authoritative: the root of the directory holds the closure of the page and nothing else. A
311
+ * block that goes leaves a stale data asset, a stale sidecar, a stale figure, and the script of a derivation
312
+ * that nothing binds. Each one costs disk and misleads a reader who opens the directory. The staged set is
313
+ * what this run wrote, thus the sweep needs no read of the page.
314
+ *
315
+ * The `deps/` directory holds the shipped libraries and fonts. The manifest governs that set, and the stage
316
+ * writes each entry of it on every run. Thus the sweep passes over the directory whole.
317
+ *
318
+ * A sweep fault costs the cleanup alone. The page and its assets are on disk and complete, thus a failed
319
+ * listing and a failed removal each log and the preview still reports the page.
320
+ */
321
+ async function sweepAssets(assetsDir, staged, logger) {
322
+ const listed = await tryFsWrite("preview.readdir", () => readdir(assetsDir), { path: assetsDir });
323
+ if (listed.isErr()) {
324
+ logger.warn("the assets directory did not list", { path: assetsDir, detail: describeFsError(listed.error) });
325
+ return;
326
+ }
327
+ for (const entry of listed.value) {
328
+ if (entry === DEPS_DIR || staged.has(entry)) {
329
+ continue;
330
+ }
331
+ const stale = join(assetsDir, entry);
332
+ const removed = await tryFsWrite("preview.rm", () => rm(stale, { force: true, recursive: true }), { path: stale });
333
+ if (removed.isErr()) {
334
+ logger.warn("a stale asset did not go", { path: stale, detail: describeFsError(removed.error) });
335
+ }
336
+ }
337
+ }
201
338
  /**
202
339
  * Make the render-and-preview tool over the session-state gateway and the render seams.
203
340
  *
@@ -217,14 +354,19 @@ export function createPreviewReportTool(deps) {
217
354
  inputSchema: previewReportInput,
218
355
  executionMode: "inline",
219
356
  describeCall: "none",
357
+ // The page path is the one fact of a preview that a watcher wants, and it exists only in the
358
+ // result. Each other arm is a degraded condition whose kind already names it, thus the kind
359
+ // stands as the line. A stamp failure is such an arm: the page landed, but the marker did not,
360
+ // and a line that named the page would read as a clean pass.
361
+ describeResult: (_input, result) => (result.outcome === "rendered" ? `page ${result.pagePath}` : result.outcome),
220
362
  execute: async (_input, ctx) => {
221
363
  const opened = await openReportThread(deps.gateway, ctx.session.scope);
222
364
  if (opened.isErr()) {
223
365
  return ok({ outcome: "refused", refusal: opened.error });
224
366
  }
225
- const { threadId, analysisId, state } = opened.value;
367
+ const { threadId, analysisId, state, derivations } = opened.value;
226
368
  const { document: draft, snapshot } = state;
227
- const finished = finishDraft(draft, snapshot);
369
+ const finished = finishDraft(draft, snapshot, derivations);
228
370
  if (!finished.valid) {
229
371
  return ok({ outcome: "gaps", gaps: finished.gaps });
230
372
  }
@@ -251,7 +393,13 @@ export function createPreviewReportTool(deps) {
251
393
  if (bridged.isErr()) {
252
394
  return ok({ outcome: "bridge-mismatch", mismatches: bridged.error });
253
395
  }
254
- const rendered = renderReportPage(document, bridged.value);
396
+ // The citation records are the bibliography of the pin, and the derivation records are the chain
397
+ // of each derived path. Both ride the render call and never the value map, because the appendix
398
+ // reads them beside the cards and the value map is keyed by block. The chain carries the two
399
+ // relative links of its entry, thus the renderer states them and it stages nothing.
400
+ const used = referencedDerivations(document, derivations);
401
+ const sessionDir = reportSessionDir(threadId);
402
+ const rendered = renderReportPage(document, bridged.value, snapshot.citationRecords, used.map((record) => chainOf(record, sessionDir)));
255
403
  if (rendered.isErr()) {
256
404
  return ok({ outcome: "render-problems", problems: rendered.error });
257
405
  }
@@ -261,7 +409,11 @@ export function createPreviewReportTool(deps) {
261
409
  analysisId,
262
410
  threadId,
263
411
  resolutions,
264
- page: rendered.value,
412
+ sidecars: collectTableSidecars(document.sections),
413
+ dataAssets: rendered.value.dataAssets,
414
+ scripts: scriptAssets(used),
415
+ page: rendered.value.html,
416
+ logger,
265
417
  });
266
418
  if (written.isErr()) {
267
419
  const failure = written.error;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The record tool of a report session.
3
3
  *
4
- * The tool records one version of the report. The store is append-only with no delete, thus a version that
5
- * the gate did not accept must never exist. The tool runs the whole gate first, and only a pass reaches the
6
- * store.
4
+ * The tool records one version of the report. A version that the gate did not accept must never stand, thus
5
+ * the tool runs the whole gate first, and only a pass reaches the store. The gate and the look rule run on
6
+ * every record, the first and each later one alike.
7
7
  *
8
8
  * The gate has three parts, in order of cost. The finish gates the schema, the unique ids, and the
9
9
  * structural tier, and a gap list returns as data. The look-before-record rule then compares the seen hash
@@ -18,8 +18,15 @@
18
18
  * before the resolver constructs.
19
19
  *
20
20
  * On a pass the store records the document that the gate validated, the pinned snapshot, and the anchor from
21
- * the thread row. The one-per-thread rule of the store bounds a concurrent race, and that refusal returns as
22
- * data. The outcome of a pass carries the version id.
21
+ * the thread row. A record on a thread that holds a version replaces that version whole, under the same
22
+ * version id. Thus the loop is unbounded: the agent amends, looks again, and records again, and the stored
23
+ * version equals the page that the last look accepted. A refused record leaves the stored version as it was.
24
+ * The outcome of a pass carries the version id, and it names a replacement.
25
+ *
26
+ * The prune runs after the version lands. It removes the output file of each derivation that the recorded
27
+ * document does not reference, under the `derived/` directory of the session alone. The records stay
28
+ * append-only, because the bytes are reproducible from the script and the sources. A failed removal logs and
29
+ * changes no outcome.
23
30
  */
24
31
  import { z } from "zod";
25
32
  import type { AuthContext } from "../../auth/types.js";
@@ -29,6 +36,7 @@ import { type FinishGap } from "../../report-model/draft-finish.js";
29
36
  import type { ReferenceResolver } from "../../report-model/reference-resolver.js";
30
37
  import { type ResolutionFailure, type SchemaIssue } from "../../report-model/validate.js";
31
38
  import type { ReportVersionStore } from "../../state/report-versions.js";
39
+ import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
32
40
  import { type Tool } from "../define-tool.js";
33
41
  import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
34
42
  /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
@@ -41,8 +49,8 @@ export type RecordVersionInput = z.infer<typeof recordVersionInput>;
41
49
  * `gaps` names each completeness gap of the draft. `never-seen` means that no eyes ran on the current draft.
42
50
  * `stale-look` means that the eyes ran, and the agent then changed the draft. `root-unresolvable` means that
43
51
  * the resolver construction cannot resolve the workspace root. `invalid` names each gate failure, and a
44
- * resolution failure carries the block that holds it. `already-recorded` means that the thread already holds
45
- * a version. `recorded` carries the version id.
52
+ * resolution failure carries the block that holds it. `recorded` carries the version id, and `replaced` is
53
+ * true when the record wrote over the earlier version of the thread.
46
54
  */
47
55
  export type RecordVersionResult = {
48
56
  outcome: "refused";
@@ -64,27 +72,31 @@ export type RecordVersionResult = {
64
72
  schemaIssues?: SchemaIssue[];
65
73
  duplicateIds?: string[];
66
74
  resolutionFailures?: ResolutionFailure[];
67
- } | {
68
- outcome: "already-recorded";
69
75
  } | {
70
76
  outcome: "record-failed";
71
77
  detail: string;
72
78
  } | {
73
79
  outcome: "recorded";
74
80
  versionId: string;
81
+ replaced: boolean;
75
82
  };
76
83
  /**
77
84
  * The construction deps of the record tool.
78
85
  *
79
- * `store` is the append-only version store. `threads` reads the anchor of the report thread, thus the
86
+ * `store` is the version store. `threads` reads the anchor of the report thread, thus the
80
87
  * version carries the parent conversation and the transcript position. `makeResolver` is optional, because a
81
88
  * resolver realization can be absent, and the gate needs one. It binds one analysis, thus the tool makes the
82
89
  * resolver over the scope of the call.
90
+ *
91
+ * `resolveWorkspaceRoot` maps the analysis of the call onto its workspace root. The prune reaches the
92
+ * derived directory of the session under that root, thus the seam is mandatory: a composition that bound
93
+ * none would record a version and leave every unused derivation on disk.
83
94
  */
84
95
  export interface RecordVersionToolDeps {
85
96
  readonly gateway: ReportSessionStateGateway;
86
97
  readonly store: ReportVersionStore;
87
98
  readonly threads: Pick<ThreadStore, "getThread">;
99
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
88
100
  readonly makeResolver?: (scope: {
89
101
  analysisId: string;
90
102
  auth: AuthContext;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The record tool of a report session.
3
3
  *
4
- * The tool records one version of the report. The store is append-only with no delete, thus a version that
5
- * the gate did not accept must never exist. The tool runs the whole gate first, and only a pass reaches the
6
- * store.
4
+ * The tool records one version of the report. A version that the gate did not accept must never stand, thus
5
+ * the tool runs the whole gate first, and only a pass reaches the store. The gate and the look rule run on
6
+ * every record, the first and each later one alike.
7
7
  *
8
8
  * The gate has three parts, in order of cost. The finish gates the schema, the unique ids, and the
9
9
  * structural tier, and a gap list returns as data. The look-before-record rule then compares the seen hash
@@ -18,16 +18,28 @@
18
18
  * before the resolver constructs.
19
19
  *
20
20
  * On a pass the store records the document that the gate validated, the pinned snapshot, and the anchor from
21
- * the thread row. The one-per-thread rule of the store bounds a concurrent race, and that refusal returns as
22
- * data. The outcome of a pass carries the version id.
21
+ * the thread row. A record on a thread that holds a version replaces that version whole, under the same
22
+ * version id. Thus the loop is unbounded: the agent amends, looks again, and records again, and the stored
23
+ * version equals the page that the last look accepted. A refused record leaves the stored version as it was.
24
+ * The outcome of a pass carries the version id, and it names a replacement.
25
+ *
26
+ * The prune runs after the version lands. It removes the output file of each derivation that the recorded
27
+ * document does not reference, under the `derived/` directory of the session alone. The records stay
28
+ * append-only, because the bytes are reproducible from the script and the sources. A failed removal logs and
29
+ * changes no outcome.
23
30
  */
24
31
  import { ok } from "neverthrow";
32
+ import { rm } from "node:fs/promises";
33
+ import { resolve as resolvePath, sep } from "node:path";
25
34
  import { z } from "zod";
26
35
  import { createNoopLogger } from "../../lib/console-logger.js";
27
36
  import { describeDbError } from "../../lib/db-result.js";
37
+ import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
38
+ import { referencedPaths, walkBlocks } from "../../report-model/block-walk.js";
28
39
  import { finishDraft } from "../../report-model/draft-finish.js";
29
40
  import { computeDraftHash } from "../../report-model/draft-hash.js";
30
41
  import { validateReport } from "../../report-model/validate.js";
42
+ import { reportSessionDerivedDir, resolveWorkspacePath } from "../../workspace/paths.js";
31
43
  import { defineTool } from "../define-tool.js";
32
44
  import { openReportThread } from "../report-authoring/authoring-tools.js";
33
45
  /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
@@ -41,12 +53,66 @@ function describeRecordFailure(error) {
41
53
  return "the pinned snapshot is malformed";
42
54
  case "parent_analysis_mismatch":
43
55
  return "the parent version belongs to a different analysis";
44
- case "thread_already_holds_version":
45
- return "the thread already holds a recorded version";
46
56
  default:
47
57
  return describeDbError(error);
48
58
  }
49
59
  }
60
+ /**
61
+ * Remove the output file of each derivation that the recorded document does not reference.
62
+ *
63
+ * The unused set is a set difference: a derivation is used when a binding of the recorded document names its
64
+ * output path. The document is the one that the gate validated, thus the prune reads what the version holds
65
+ * and never the draft.
66
+ *
67
+ * The prune reaches the `derived/` directory of the session alone. A record whose path resolves outside that
68
+ * directory is skipped, because this tool never removes a file that it does not own. A stale record from an
69
+ * earlier layout and a crafted path both land in that arm.
70
+ *
71
+ * The version already stands at this point. Thus each failure costs the cleanup alone: an unresolvable root,
72
+ * a path that escapes, and a failed removal each log and change no outcome. The records stay, and the bytes
73
+ * are reproducible from the script and the sources. The scope of the prune resolves inside the same guard as
74
+ * the root, because both builders throw and a throw here would read as a failure of a call that succeeded.
75
+ */
76
+ async function pruneUnusedDerivations(args) {
77
+ if (args.derivations.length === 0) {
78
+ return;
79
+ }
80
+ const named = referencedPaths(walkBlocks(args.document.sections).references);
81
+ const unused = args.derivations.filter((record) => !named.has(record.outputPath));
82
+ if (unused.length === 0) {
83
+ return;
84
+ }
85
+ let root;
86
+ let derivedDir;
87
+ try {
88
+ root = args.resolveWorkspaceRoot(args.analysisId);
89
+ derivedDir = resolvePath(root, reportSessionDerivedDir(args.threadId));
90
+ }
91
+ catch (cause) {
92
+ args.logger.warn("the unused derivations did not prune", {
93
+ threadId: args.threadId,
94
+ analysisId: args.analysisId,
95
+ ...args.logger.errorFields(cause),
96
+ });
97
+ return;
98
+ }
99
+ for (const record of unused) {
100
+ const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: record.outputPath });
101
+ if (resolved.kind !== "ok" || !resolved.absolute.startsWith(derivedDir + sep)) {
102
+ args.logger.warn("an unused derivation sits outside the derived directory of the session", {
103
+ threadId: args.threadId,
104
+ analysisId: args.analysisId,
105
+ path: record.outputPath,
106
+ });
107
+ continue;
108
+ }
109
+ // An absent file is the normal condition of a prune that ran before, thus `force` treats it as done.
110
+ const removed = await tryFsWrite("record.rm", () => rm(resolved.absolute, { force: true }), { path: resolved.absolute });
111
+ if (removed.isErr()) {
112
+ args.logger.warn("an unused derivation did not go", { path: resolved.absolute, detail: describeFsError(removed.error) });
113
+ }
114
+ }
115
+ }
50
116
  /**
51
117
  * Make the record tool over the session-state gateway, the version store, the thread store, and the
52
118
  * resolver.
@@ -61,18 +127,27 @@ export function createRecordVersionTool(deps) {
61
127
  description: "Record the current draft as one report version. The tool runs the whole gate first: it finishes the draft, " +
62
128
  "resolves each reference, matches each chart encoding, and matches each assert. An incomplete draft gives back the gap list, " +
63
129
  "and a failed reference gives back the block that broke. The tool records a version only after the eyes look at the current page. " +
64
- "A thread holds one version, thus a second record gives back that the thread already holds one.",
130
+ "A thread holds one version, thus a later record replaces it whole. Amend the draft, look at the page again, and record again. " +
131
+ "After the version lands, the tool removes the file of each derived table that no block of the recorded report binds.",
65
132
  inputSchema: recordVersionInput,
66
133
  executionMode: "inline",
67
134
  describeCall: "none",
135
+ // The version id is the one durable product of the first record, thus the created arm names it. A
136
+ // later record writes over that same id, thus the line names the update and never reads as a
137
+ // refusal. Each other arm is a gate that refused, and the kind of the arm is what a watcher reads.
138
+ describeResult: (_input, result) => {
139
+ if (result.outcome !== "recorded")
140
+ return result.outcome;
141
+ return result.replaced ? "version updated" : `version ${result.versionId}`;
142
+ },
68
143
  execute: async (_input, ctx) => {
69
144
  const opened = await openReportThread(deps.gateway, ctx.session.scope);
70
145
  if (opened.isErr()) {
71
146
  return ok({ outcome: "refused", refusal: opened.error });
72
147
  }
73
- const { threadId, analysisId, state, seenDocumentHash } = opened.value;
148
+ const { threadId, analysisId, state, seenDocumentHash, derivations } = opened.value;
74
149
  const { document: draft, snapshot } = state;
75
- const finished = finishDraft(draft, snapshot);
150
+ const finished = finishDraft(draft, snapshot, derivations);
76
151
  if (!finished.valid) {
77
152
  return ok({ outcome: "gaps", gaps: finished.gaps });
78
153
  }
@@ -127,10 +202,20 @@ export function createRecordVersionTool(deps) {
127
202
  parentThreadId: thread.value.parentThreadId,
128
203
  parentSeq: thread.value.parentSeq,
129
204
  });
130
- return recorded.match((ref) => ok({ outcome: "recorded", versionId: ref.versionId }), (error) => {
131
- if (error.type === "thread_already_holds_version") {
132
- return ok({ outcome: "already-recorded" });
133
- }
205
+ if (recorded.isOk()) {
206
+ // The version stands from here. The prune reclaims the bytes of each derivation that the
207
+ // recorded document ignores, and it decides nothing about the outcome. It runs on each
208
+ // record, thus an output that an amend unbound goes at the next record.
209
+ await pruneUnusedDerivations({
210
+ resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
211
+ analysisId,
212
+ threadId,
213
+ document: finished.document,
214
+ derivations,
215
+ logger,
216
+ });
217
+ }
218
+ return recorded.match((ref) => ok({ outcome: "recorded", versionId: ref.versionId, replaced: ref.outcome === "replaced" }), (error) => {
134
219
  logger.warn("the version did not record", { threadId, analysisId, reason: error.type });
135
220
  return ok({ outcome: "record-failed", detail: describeRecordFailure(error) });
136
221
  });