@inflexa-ai/harness 0.20.0 → 0.21.1
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.
- package/dist/agents/conversation-agent.d.ts +26 -14
- package/dist/agents/conversation-agent.js +12 -23
- package/dist/agents/report-session-agent.d.ts +31 -5
- package/dist/agents/report-session-agent.js +35 -9
- package/dist/app/chat-turn.js +3 -1
- package/dist/app/message-assembly.d.ts +15 -1
- package/dist/app/message-assembly.js +22 -8
- package/dist/app/report-session-runtime.d.ts +21 -4
- package/dist/app/report-session-runtime.js +53 -6
- package/dist/app/spawn-report-session.d.ts +174 -17
- package/dist/app/spawn-report-session.js +233 -21
- package/dist/contracts/chat-events.d.ts +7 -2
- package/dist/contracts/message.d.ts +10 -0
- package/dist/contracts/report-blocks.d.ts +743 -19
- package/dist/contracts/report-blocks.js +186 -11
- package/dist/contracts/report-reference.d.ts +88 -0
- package/dist/contracts/report-reference.js +48 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -0
- package/dist/lib/chrome.d.ts +26 -6
- package/dist/lib/chrome.js +99 -32
- package/dist/lib/eyes.d.ts +57 -0
- package/dist/lib/eyes.js +45 -0
- package/dist/lib/page-capture.d.ts +14 -0
- package/dist/lib/page-capture.js +56 -2
- package/dist/loop/run-agent.js +58 -23
- package/dist/loop/tool-detail.d.ts +19 -2
- package/dist/loop/tool-detail.js +35 -2
- package/dist/memory/content-to-cortex.d.ts +3 -2
- package/dist/memory/content-to-cortex.js +21 -12
- package/dist/memory/conversation-display-replay.js +14 -7
- package/dist/memory/thread-history.d.ts +60 -3
- package/dist/memory/thread-history.js +55 -16
- package/dist/memory/thread-store.d.ts +11 -1
- package/dist/memory/thread-store.js +12 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +23 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +190 -9
- package/dist/report-model/authoring-grammar.d.ts +463 -0
- package/dist/report-model/authoring-grammar.js +77 -0
- package/dist/report-model/block-walk.d.ts +57 -14
- package/dist/report-model/block-walk.js +116 -11
- package/dist/report-model/draft-finish.d.ts +22 -4
- package/dist/report-model/draft-finish.js +39 -7
- package/dist/report-model/draft-operations.d.ts +3 -0
- package/dist/report-model/draft-operations.js +19 -5
- package/dist/report-model/draft-read.d.ts +8 -5
- package/dist/report-model/draft-read.js +21 -5
- package/dist/report-model/fixture-resolver.js +15 -4
- package/dist/report-model/pin-snapshot.d.ts +24 -6
- package/dist/report-model/pin-snapshot.js +204 -6
- package/dist/report-model/production-resolver.js +15 -4
- package/dist/report-model/reference-resolver.d.ts +50 -1
- package/dist/report-model/reference-resolver.js +94 -1
- package/dist/report-model/reference-stamp.d.ts +45 -0
- package/dist/report-model/reference-stamp.js +173 -0
- package/dist/report-model/structural-validation.d.ts +4 -1
- package/dist/report-model/structural-validation.js +48 -3
- package/dist/report-model/validate.d.ts +1 -1
- package/dist/report-model/validate.js +1 -1
- package/dist/report-render/asset-lookup.d.ts +19 -0
- package/dist/report-render/asset-lookup.js +37 -0
- package/dist/report-render/assets.d.ts +45 -1
- package/dist/report-render/assets.js +65 -6
- package/dist/report-render/chart-presets.d.ts +102 -0
- package/dist/report-render/chart-presets.js +205 -0
- package/dist/report-render/chart.d.ts +112 -10
- package/dist/report-render/chart.js +1164 -41
- package/dist/report-render/design.d.ts +99 -0
- package/dist/report-render/design.js +289 -107
- package/dist/report-render/number-format.d.ts +117 -0
- package/dist/report-render/number-format.js +479 -0
- package/dist/report-render/page.d.ts +121 -5
- package/dist/report-render/page.js +689 -7
- package/dist/report-render/references.d.ts +70 -16
- package/dist/report-render/references.js +54 -22
- package/dist/report-render/render.d.ts +23 -5
- package/dist/report-render/render.js +81 -22
- package/dist/report-render/table-data.d.ts +92 -0
- package/dist/report-render/table-data.js +129 -0
- package/dist/report-render/types.d.ts +15 -0
- package/dist/report-render/value-bridge.js +6 -2
- package/dist/report-render/views/chart-view.d.ts +12 -5
- package/dist/report-render/views/chart-view.js +14 -8
- package/dist/report-render/views/page-view.d.ts +36 -15
- package/dist/report-render/views/page-view.js +45 -23
- package/dist/report-render/views/prose.d.ts +12 -2
- package/dist/report-render/views/prose.js +29 -7
- package/dist/report-render/views/references-view.d.ts +22 -8
- package/dist/report-render/views/references-view.js +101 -13
- package/dist/report-render/views/values.d.ts +128 -13
- package/dist/report-render/views/values.js +199 -18
- package/dist/runtime/assemble.d.ts +39 -5
- package/dist/runtime/assemble.js +47 -6
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +9 -4
- package/dist/sandbox/create-sandbox.js +22 -16
- package/dist/sandbox/docker-client.js +10 -7
- package/dist/sandbox/mount-plan.d.ts +45 -7
- package/dist/sandbox/mount-plan.js +43 -5
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/state/init.js +38 -9
- package/dist/state/report-session-state.d.ts +42 -1
- package/dist/state/report-session-state.js +70 -2
- package/dist/state/report-versions.d.ts +22 -17
- package/dist/state/report-versions.js +48 -35
- package/dist/state/snapshot-parse.js +16 -1
- package/dist/tools/define-tool.d.ts +32 -0
- package/dist/tools/define-tool.js +4 -0
- package/dist/tools/detail-resolver.d.ts +7 -2
- package/dist/tools/detail-resolver.js +7 -2
- package/dist/tools/iterate-report.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
- package/dist/tools/report-authoring/authoring-tools.js +99 -18
- package/dist/tools/report-session/derive-table.d.ts +162 -0
- package/dist/tools/report-session/derive-table.js +459 -0
- package/dist/tools/report-session/examine-page.d.ts +48 -18
- package/dist/tools/report-session/examine-page.js +204 -24
- package/dist/tools/report-session/index.d.ts +2 -0
- package/dist/tools/report-session/index.js +2 -0
- package/dist/tools/report-session/list-artifacts.d.ts +97 -0
- package/dist/tools/report-session/list-artifacts.js +197 -0
- package/dist/tools/report-session/preview-report.d.ts +6 -0
- package/dist/tools/report-session/preview-report.js +180 -28
- package/dist/tools/report-session/record-version.d.ts +22 -10
- package/dist/tools/report-session/record-version.js +99 -14
- package/dist/tools/start-report-session.d.ts +115 -0
- package/dist/tools/start-report-session.js +179 -0
- package/dist/workspace/paths.d.ts +50 -0
- package/dist/workspace/paths.js +63 -0
- package/package.json +2 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned-artifact listing tool of a report session.
|
|
3
|
+
*
|
|
4
|
+
* The tool reads the frozen snapshot of the thread, and it gives the pinned set: the path, the content
|
|
5
|
+
* hash, the file type, and the header columns of a tabular artifact, plus the pinned citations with the
|
|
6
|
+
* short citation of each. Thus one call orients the agent, and a reference binds to a path or to a
|
|
7
|
+
* citation key of that set.
|
|
8
|
+
*
|
|
9
|
+
* The order is the code-unit order of the path. Two calls over one snapshot give one listing, thus the
|
|
10
|
+
* agent reads a stable set. A snapshot can pin many thousands of staged inputs, thus the listing stops at
|
|
11
|
+
* a cap and it carries the total count and a truncation marker.
|
|
12
|
+
*
|
|
13
|
+
* The columns come from a bounded header read of a listed entry. The path of a snapshot entry is
|
|
14
|
+
* untrusted, thus the read contains it with the one workspace-path resolver first. The extension gates
|
|
15
|
+
* the read, because a file type states a role and not a data format.
|
|
16
|
+
*
|
|
17
|
+
* An absent file, an unreadable file, and an unresolvable workspace root each give no columns and no
|
|
18
|
+
* error. Absence is a normal condition here: the listing is orientation, and the hash is the evidence.
|
|
19
|
+
*/
|
|
20
|
+
import { ok } from "neverthrow";
|
|
21
|
+
import { open } from "node:fs/promises";
|
|
22
|
+
import { extname } from "node:path";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { resolveWorkspacePath } from "../../workspace/paths.js";
|
|
25
|
+
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
26
|
+
import { tryFs } from "../../lib/fs-result.js";
|
|
27
|
+
import { defaultErrorFields } from "../../lib/logger.js";
|
|
28
|
+
import { citationRecordOf, fileTypeHoldsNoCell } from "../../report-model/reference-resolver.js";
|
|
29
|
+
import { defineTool } from "../define-tool.js";
|
|
30
|
+
import { openReportThread } from "../report-authoring/authoring-tools.js";
|
|
31
|
+
/** The empty input. The tool lists the pinned set of the thread, thus it needs no field. */
|
|
32
|
+
const listPinnedArtifactsInput = z.object({});
|
|
33
|
+
/**
|
|
34
|
+
* The cap of the header read. A header line of an analysis output sits far under it, and a file with no
|
|
35
|
+
* newline at all costs this many bytes and no more.
|
|
36
|
+
*/
|
|
37
|
+
const HEADER_CAP_BYTES = 16 * 1024;
|
|
38
|
+
/**
|
|
39
|
+
* The cap of the listing. A snapshot pins each staged input of the analysis, and such a set can hold
|
|
40
|
+
* many thousands of entries. One entry costs one file read, thus the cap bounds both the reply and the
|
|
41
|
+
* work of the call.
|
|
42
|
+
*/
|
|
43
|
+
const LISTING_CAP = 200;
|
|
44
|
+
/**
|
|
45
|
+
* The two extensions that carry a header line, each with its delimiter.
|
|
46
|
+
*
|
|
47
|
+
* The extension decides, because the bytes carry no declaration and a file type states a role alone. A
|
|
48
|
+
* minified JSON under `output/` splits into hundreds of names that address nothing, and a binary splits
|
|
49
|
+
* into unreadable text. Thus an entry with another extension reads no header.
|
|
50
|
+
*/
|
|
51
|
+
const HEADER_DELIMITERS = new Map([
|
|
52
|
+
[".csv", ","],
|
|
53
|
+
[".tsv", "\t"],
|
|
54
|
+
]);
|
|
55
|
+
/** The header delimiter of a path, or `undefined` when the extension carries no header. */
|
|
56
|
+
function delimiterOf(path) {
|
|
57
|
+
return HEADER_DELIMITERS.get(extname(path).toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read the first line of a file under the cap, or give `undefined` when the bytes do not come back.
|
|
61
|
+
*
|
|
62
|
+
* The read takes a bounded byte window, thus a file of any size costs the cap. An absent file and a
|
|
63
|
+
* genuine read fault both give `undefined`, because the listing reports no error for a header.
|
|
64
|
+
*/
|
|
65
|
+
async function readHeaderLine(absolute) {
|
|
66
|
+
const head = await tryFs("listPinnedArtifacts.readHeader", async () => {
|
|
67
|
+
const handle = await open(absolute, "r");
|
|
68
|
+
try {
|
|
69
|
+
const buffer = Buffer.alloc(HEADER_CAP_BYTES);
|
|
70
|
+
const { bytesRead } = await handle.read(buffer, 0, HEADER_CAP_BYTES, 0);
|
|
71
|
+
return { text: buffer.subarray(0, bytesRead).toString("utf8"), filled: bytesRead === HEADER_CAP_BYTES };
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await handle.close();
|
|
75
|
+
}
|
|
76
|
+
}, { path: absolute, onAbsent: () => undefined }).unwrapOr(undefined);
|
|
77
|
+
if (head === undefined) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const line = head.text.split(/\r?\n/, 1)[0];
|
|
81
|
+
if (line.length === 0) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return { line, cut: head.filled && !head.text.includes("\n") };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Split a header line into column names on the delimiter, or give `undefined` when no whole name comes
|
|
88
|
+
* out of it.
|
|
89
|
+
*
|
|
90
|
+
* A double quote makes a field that can hold the delimiter itself, thus the naive split gives wrong
|
|
91
|
+
* names. A cut line ends inside its last name, and an agent writes a column name into a locator. Both
|
|
92
|
+
* conditions give no columns, because a wrong name is worse than an absent one.
|
|
93
|
+
*/
|
|
94
|
+
function columnsOf(read, delimiter) {
|
|
95
|
+
if (read.line.includes('"')) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const names = read.line.split(delimiter).map((name) => name.trim());
|
|
99
|
+
const whole = read.cut ? names.slice(0, -1) : names;
|
|
100
|
+
return whole.length > 0 ? whole : undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The header columns of a file on disk, or `undefined` when the file gives none.
|
|
104
|
+
*
|
|
105
|
+
* `path` decides whether the file carries a header at all, and `absolute` is where the bytes sit. The
|
|
106
|
+
* derivation tool reads the columns of its own output the same way, thus the two surfaces describe a table
|
|
107
|
+
* with one rule.
|
|
108
|
+
*/
|
|
109
|
+
export async function readHeaderColumns(path, absolute) {
|
|
110
|
+
const delimiter = delimiterOf(path);
|
|
111
|
+
if (delimiter === undefined) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const read = await readHeaderLine(absolute);
|
|
115
|
+
return read === undefined ? undefined : columnsOf(read, delimiter);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Make the pinned-artifact listing tool over the session-state gateway.
|
|
119
|
+
*
|
|
120
|
+
* The tool reads the thread id from the scope of the call, and it loads the snapshot through the gateway.
|
|
121
|
+
* Thus one factory serves every thread, and the tool holds no per-session value.
|
|
122
|
+
*/
|
|
123
|
+
export function createListPinnedArtifactsTool(deps) {
|
|
124
|
+
const logger = (deps.logger ?? createNoopLogger()).named("list-pinned-artifacts");
|
|
125
|
+
return defineTool({
|
|
126
|
+
id: "list_pinned_artifacts",
|
|
127
|
+
description: "List the pinned evidence of this session. Each entry gives the path of an artifact, its content hash, its file type, " +
|
|
128
|
+
"and the header columns of a .csv or a .tsv file. Another extension, a header line that holds a double quote, and a " +
|
|
129
|
+
"file whose bytes do not read each give no columns. " +
|
|
130
|
+
`The listing gives a maximum of ${LISTING_CAP} entries: "total" gives the size of the pinned set, and "truncated" says that the ` +
|
|
131
|
+
"set holds more artifacts than this listing names. " +
|
|
132
|
+
'"citations" gives the pinned literature of this session. Each entry carries "key", the citation id in the "idKind:id" form, and ' +
|
|
133
|
+
'"citation", the short citation of the paper. A key that the pin recorded no citation for carries no "citation". A citation block ' +
|
|
134
|
+
"binds to a key of this list, thus take a key from here and never from a refusal. " +
|
|
135
|
+
"The pin freezes at the start of the session, thus a reference binds to an artifact of this pinned set. " +
|
|
136
|
+
"Read it to orient before you bind a block, and to choose the column that a locator names. " +
|
|
137
|
+
"A reference names the path alone: the session stamps the hash from this evidence.",
|
|
138
|
+
inputSchema: listPinnedArtifactsInput,
|
|
139
|
+
executionMode: "inline",
|
|
140
|
+
describeCall: "none",
|
|
141
|
+
// The size of the listing is what a watcher reads, and the cap makes that size differ from the
|
|
142
|
+
// pinned set. Thus a truncated listing names the total beside the count, and a whole listing names
|
|
143
|
+
// the count alone, because a total that equals the count says the same thing two times.
|
|
144
|
+
describeResult: (_input, result) => {
|
|
145
|
+
if (result.outcome !== "listed") {
|
|
146
|
+
return result.outcome;
|
|
147
|
+
}
|
|
148
|
+
const count = result.artifacts.length;
|
|
149
|
+
const listed = `${count} ${count === 1 ? "artifact" : "artifacts"}`;
|
|
150
|
+
return result.truncated ? `${listed} of ${result.total}` : listed;
|
|
151
|
+
},
|
|
152
|
+
execute: async (_input, ctx) => {
|
|
153
|
+
const opened = await openReportThread(deps.gateway, ctx.session.scope);
|
|
154
|
+
if (opened.isErr()) {
|
|
155
|
+
return ok({ outcome: "refused", refusal: opened.error });
|
|
156
|
+
}
|
|
157
|
+
const { threadId, analysisId, state } = opened.value;
|
|
158
|
+
const paths = Object.keys(state.snapshot.artifacts).sort();
|
|
159
|
+
// The cap cuts the tail of the same order, thus a truncated listing is a prefix of the whole
|
|
160
|
+
// listing and a second call over one snapshot names the same entries.
|
|
161
|
+
const listed = paths.slice(0, LISTING_CAP);
|
|
162
|
+
// The seam signals an unresolvable resource by a throw. The listing still serves the path and
|
|
163
|
+
// the hash of each entry, thus the fault costs the columns alone and never the whole call.
|
|
164
|
+
let root;
|
|
165
|
+
try {
|
|
166
|
+
root = deps.resolveWorkspaceRoot(analysisId);
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
|
|
170
|
+
}
|
|
171
|
+
const artifacts = [];
|
|
172
|
+
for (const path of listed) {
|
|
173
|
+
const entry = state.snapshot.artifacts[path];
|
|
174
|
+
const fileType = entry.fileType;
|
|
175
|
+
const artifact = { path, hash: entry.hash, ...(typeof fileType === "string" ? { fileType } : {}) };
|
|
176
|
+
if (root !== undefined && !fileTypeHoldsNoCell(fileType)) {
|
|
177
|
+
const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId, path });
|
|
178
|
+
if (resolved.kind === "ok") {
|
|
179
|
+
const columns = await readHeaderColumns(path, resolved.absolute);
|
|
180
|
+
if (columns !== undefined) {
|
|
181
|
+
artifact.columns = columns;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
artifacts.push(artifact);
|
|
186
|
+
}
|
|
187
|
+
// The pin stores the citation keys sorted, thus the listing keeps that order. A snapshot that
|
|
188
|
+
// pinned no citation gives an empty list, and an empty list is a complete answer. A key with no
|
|
189
|
+
// pinned record lists bare, thus a stored pin that holds no record map still orients the agent.
|
|
190
|
+
const citations = (state.snapshot.citations ?? []).map((key) => {
|
|
191
|
+
const record = citationRecordOf(state.snapshot.citationRecords, key);
|
|
192
|
+
return record === undefined ? { key } : { key, citation: record.citation };
|
|
193
|
+
});
|
|
194
|
+
return ok({ outcome: "listed", artifacts, total: paths.length, truncated: paths.length > listed.length, citations });
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -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.
|
|
@@ -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 {
|
|
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
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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,
|
|
152
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
249
|
+
reportFiles.set(name, resolved.absolute);
|
|
170
250
|
}
|
|
171
|
-
// Each
|
|
172
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
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.
|
|
22
|
-
*
|
|
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. `
|
|
45
|
-
*
|
|
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
|
|
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;
|