@inflexa-ai/harness 0.19.0 → 0.19.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.
Files changed (64) hide show
  1. package/dist/agents/report-session-agent.d.ts +25 -9
  2. package/dist/agents/report-session-agent.js +21 -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/config/environment-stores.d.ts +12 -7
  7. package/dist/config/resource-limits.d.ts +6 -10
  8. package/dist/config/resource-limits.js +6 -44
  9. package/dist/contracts/report-reference.d.ts +4 -0
  10. package/dist/contracts/report-reference.js +3 -0
  11. package/dist/index.d.ts +2 -1
  12. package/dist/lib/chrome.d.ts +14 -0
  13. package/dist/lib/chrome.js +7 -0
  14. package/dist/lib/page-capture.d.ts +47 -0
  15. package/dist/lib/page-capture.js +83 -0
  16. package/dist/loop/run-agent.js +104 -15
  17. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  18. package/dist/memory/ai-sdk-message-storage.js +9 -4
  19. package/dist/memory/count-tokens.js +34 -5
  20. package/dist/prompts/report-session.d.ts +1 -1
  21. package/dist/prompts/report-session.js +21 -0
  22. package/dist/providers/ai-sdk.d.ts +81 -3
  23. package/dist/providers/ai-sdk.js +91 -10
  24. package/dist/providers/types.d.ts +17 -0
  25. package/dist/report-model/assert-rules.d.ts +44 -0
  26. package/dist/report-model/assert-rules.js +97 -0
  27. package/dist/report-model/draft-hash.d.ts +14 -0
  28. package/dist/report-model/draft-hash.js +36 -0
  29. package/dist/report-model/fixture-resolver.js +2 -71
  30. package/dist/report-model/production-resolver.d.ts +124 -0
  31. package/dist/report-model/production-resolver.js +801 -0
  32. package/dist/report-model/reference-resolver.d.ts +6 -0
  33. package/dist/report-model/validate.js +5 -0
  34. package/dist/report-render/page.d.ts +23 -0
  35. package/dist/report-render/page.js +36 -3
  36. package/dist/runtime/assemble.d.ts +28 -4
  37. package/dist/runtime/assemble.js +42 -3
  38. package/dist/sandbox/mount-plan.d.ts +7 -0
  39. package/dist/sandbox/mount-plan.js +7 -2
  40. package/dist/state/init.js +20 -7
  41. package/dist/state/report-session-state.d.ts +27 -0
  42. package/dist/state/report-session-state.js +29 -2
  43. package/dist/tasks/extract-values-script.d.ts +64 -0
  44. package/dist/tasks/extract-values-script.js +180 -0
  45. package/dist/tasks/extract-values.d.ts +124 -0
  46. package/dist/tasks/extract-values.js +193 -0
  47. package/dist/tools/define-tool.d.ts +29 -0
  48. package/dist/tools/define-tool.js +26 -0
  49. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  50. package/dist/tools/report/preview-snapshot.js +11 -50
  51. package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
  52. package/dist/tools/report-authoring/authoring-tools.js +1 -1
  53. package/dist/tools/report-session/examine-page.d.ts +90 -0
  54. package/dist/tools/report-session/examine-page.js +136 -0
  55. package/dist/tools/report-session/index.d.ts +2 -0
  56. package/dist/tools/report-session/index.js +2 -0
  57. package/dist/tools/report-session/preview-report.d.ts +20 -5
  58. package/dist/tools/report-session/preview-report.js +28 -4
  59. package/dist/tools/report-session/record-version.d.ts +102 -0
  60. package/dist/tools/report-session/record-version.js +139 -0
  61. package/dist/tools/sandbox/list-available-packages.js +2 -1
  62. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  63. package/dist/tools/sandbox/list-available-refs.js +13 -6
  64. package/package.json +10 -8
@@ -157,15 +157,20 @@ export interface ReportSessionState {
157
157
  */
158
158
  export type SessionStateToken = DraftDocument | null;
159
159
  /**
160
- * The outcome of a gateway load. `found` carries the state, the analysis that owns the thread, and the
161
- * concurrency token. `absent` and `wrong-type` are permanent conditions, and `failed` is a transient fault
162
- * that names its cause.
160
+ * The outcome of a gateway load. `found` carries the state, the analysis that owns the thread, the
161
+ * concurrency token, and the seen-document hash of the look-before-record rule. `absent` and `wrong-type`
162
+ * are permanent conditions, and `failed` is a transient fault that names its cause.
163
+ *
164
+ * `seenDocumentHash` is the hash that the last eyes capture saw, or `null` before the first look. The record
165
+ * tool compares it against the hash of the current draft, thus a never-seen page and a stale look each
166
+ * refuse.
163
167
  */
164
168
  export type SessionStateLoad = {
165
169
  outcome: "found";
166
170
  state: ReportSessionState;
167
171
  analysisId: string;
168
172
  token: SessionStateToken;
173
+ seenDocumentHash: string | null;
169
174
  } | {
170
175
  outcome: "absent";
171
176
  } | {
@@ -184,6 +189,35 @@ export type SessionStatePersist = {
184
189
  outcome: "failed";
185
190
  detail: string;
186
191
  };
192
+ /**
193
+ * The outcome of a gateway stamp. `stamped` wrote the marker. `absent` means that no row holds the thread.
194
+ * `failed` is a transient store fault that names its cause. Each arm is plain data, thus a stamp never
195
+ * throws for one of them.
196
+ */
197
+ export type StampResult = {
198
+ outcome: "stamped";
199
+ } | {
200
+ outcome: "absent";
201
+ } | {
202
+ outcome: "failed";
203
+ detail: string;
204
+ };
205
+ /**
206
+ * The outcome of the seen stamp through the gateway. `stamped` copied a rendered hash onto the seen hash.
207
+ * `no-rendered` means that the row holds no rendered hash to copy, thus no preview stamped one. `absent`
208
+ * means that no row holds the thread. `failed` is a transient store fault that names its cause. Each arm is
209
+ * plain data, thus a stamp never throws for one of them.
210
+ */
211
+ export type SeenStampResult = {
212
+ outcome: "stamped";
213
+ } | {
214
+ outcome: "no-rendered";
215
+ } | {
216
+ outcome: "absent";
217
+ } | {
218
+ outcome: "failed";
219
+ detail: string;
220
+ };
187
221
  /**
188
222
  * The session-state gateway that the tools read and write.
189
223
  *
@@ -195,13 +229,29 @@ export type SessionStatePersist = {
195
229
  export interface ReportSessionStateGateway {
196
230
  load(threadId: string): Promise<SessionStateLoad>;
197
231
  persist(threadId: string, document: DraftDocument, expected: SessionStateToken): Promise<SessionStatePersist>;
232
+ /**
233
+ * Stamp the hash of the rendered draft on the session state. The preview calls it when the page lands,
234
+ * thus the runtime knows which draft the page shows.
235
+ */
236
+ stampRendered(threadId: string, hash: string): Promise<StampResult>;
237
+ /**
238
+ * Copy the rendered hash onto the seen hash. The eyes call it after a capture, thus the seen hash holds
239
+ * the hash of the draft that the picture shows, and never the current one. The outcome names whether a
240
+ * rendered hash existed to copy, thus the eyes tell a real stamp from a missed one.
241
+ */
242
+ stampSeen(threadId: string): Promise<SeenStampResult>;
198
243
  }
199
- /** The thread of a call, its analysis, the loaded state, and the concurrency token that the persist compares against. */
244
+ /**
245
+ * The thread of a call, its analysis, the loaded state, the concurrency token that the persist compares
246
+ * against, and the seen-document hash of the look-before-record rule.
247
+ */
200
248
  export interface OpenedThread {
201
249
  readonly threadId: string;
202
250
  readonly analysisId: string;
203
251
  readonly state: ReportSessionState;
204
252
  readonly token: SessionStateToken;
253
+ /** The hash that the last eyes capture saw, or `null` before the first look. */
254
+ readonly seenDocumentHash: string | null;
205
255
  }
206
256
  /**
207
257
  * Resolve the report thread of a call, and load its state through the gateway.
@@ -119,7 +119,7 @@ export async function openReportThread(gateway, scope) {
119
119
  detail: `the scope names the analysis ${analysisId}, but the thread ${threadId} belongs to the analysis ${loaded.analysisId}`,
120
120
  });
121
121
  }
122
- return ok({ threadId, analysisId, state: loaded.state, token: loaded.token });
122
+ return ok({ threadId, analysisId, state: loaded.state, token: loaded.token, seenDocumentHash: loaded.seenDocumentHash });
123
123
  }
124
124
  /** Read an absentable field. An explicit `null` and an omitted key both mean that the model gave no value. */
125
125
  function given(value) {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The eyes tool of a report session.
3
+ *
4
+ * The tool opens the rendered page of a thread in headless Chrome, and it gives back what a reviewer would
5
+ * see: the screenshot, the console errors, and the failed requests. The agent reads the picture and the
6
+ * faults, decides, and repairs. The tool never judges, thus it never blocks the loop.
7
+ *
8
+ * The session tree has no URL space, thus the tool navigates to the page file through a `file://` URL. The
9
+ * page lives at `report-sessions/{threadId}/index.html` under the workspace root, which the preview writes.
10
+ * A missed page means that no preview ran, and it is a typed outcome, not a throw.
11
+ *
12
+ * The `file://` URL resolves on the filesystem of the Chrome sidecar, because the connection is out of
13
+ * process. Thus the sidecar must mount the workspace tree of the harness host at the same path. A sidecar
14
+ * with no such mount reports the page as an unreachable request, and the tool gives back that fault.
15
+ *
16
+ * A composition that names no browser and injects no capture seam has no eyes at all. The tool reports that
17
+ * condition once, up front, and it stamps nothing. A per-attempt capture failure would instead read as a
18
+ * transient fault and invite a repeat of a call that can never pass.
19
+ *
20
+ * On a capture the tool copies the rendered hash onto the seen hash through the gateway. Thus the look
21
+ * counts, and the record tool lets the current draft record. The copy takes the rendered hash and never the
22
+ * current one, thus a later edit makes the look stale and the record refuses.
23
+ *
24
+ * The gateway reports whether a rendered hash existed to copy. When the row holds none, no preview stamped
25
+ * one and the look cannot count. The tool then gives a missed-stamp outcome that directs a new preview,
26
+ * because a repeated look never stamps a marker that no preview wrote.
27
+ *
28
+ * The chrome navigation and the workspace-root seam each speak the throw protocol. The tool runs the capture
29
+ * inside a guard, thus a genuine fault becomes a typed outcome and a control-flow exception propagates.
30
+ */
31
+ import { z } from "zod";
32
+ import { type ChromeConfig } from "../../lib/chrome.js";
33
+ import { type Logger } from "../../lib/logger.js";
34
+ import { type CapturePage, type FailedRequest, type PageCapture } from "../../lib/page-capture.js";
35
+ import type { ResolveWorkspaceRoot } from "../../workspace/paths.js";
36
+ import { type Tool } from "../define-tool.js";
37
+ import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
38
+ export type { CapturePage, FailedRequest, PageCapture };
39
+ /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
40
+ declare const examinePageInput: z.ZodObject<{}, z.core.$strip>;
41
+ export type ExaminePageInput = z.infer<typeof examinePageInput>;
42
+ /**
43
+ * The typed outcome of the eyes tool. Each arm is ok-channel data, thus the tool never throws for a
44
+ * degraded condition. `examined` carries the console errors, the failed requests, and the page path. The
45
+ * screenshot does not ride the JSON. It rides the image path of the tool result, thus the model sees the
46
+ * picture and the JSON text holds no bytes. `missed-stamp` means that the row holds no rendered hash, thus
47
+ * no preview stamped one and the agent must run a new preview before the next look. `no-browser` means that
48
+ * the composition gives no browser, thus no look is possible at all and a repeat gives the same answer.
49
+ */
50
+ export type ExaminePageResult = {
51
+ outcome: "refused";
52
+ refusal: SessionRefusal;
53
+ } | {
54
+ outcome: "no-browser";
55
+ detail: string;
56
+ } | {
57
+ outcome: "no-page";
58
+ } | {
59
+ outcome: "missed-stamp";
60
+ } | {
61
+ outcome: "capture-failed";
62
+ detail: string;
63
+ } | {
64
+ outcome: "examined";
65
+ consoleErrors: string[];
66
+ failedRequests: FailedRequest[];
67
+ pagePath: string;
68
+ };
69
+ /**
70
+ * The construction deps of the eyes tool.
71
+ *
72
+ * `resolveWorkspaceRoot` maps the analysis of the call onto its workspace root, thus one singleton tool
73
+ * serves every analysis. `chrome` configures the headless browser, and the sidecar that it names must mount
74
+ * the workspace tree at the same path, because the tool navigates to a `file://` URL. `capture` is optional
75
+ * and defaults to the shared capture, thus a test injects a seam that reads no browser.
76
+ */
77
+ export interface ExaminePageToolDeps {
78
+ readonly gateway: ReportSessionStateGateway;
79
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
80
+ readonly chrome: ChromeConfig;
81
+ readonly capture?: CapturePage;
82
+ readonly logger?: Logger;
83
+ }
84
+ /**
85
+ * Make the eyes tool over the session-state gateway, the workspace-root seam, and the chrome config.
86
+ *
87
+ * The tool reads the thread id from the scope of the call, and it resolves the page path under the workspace
88
+ * root. Thus one factory serves every thread. The tool holds no per-session value.
89
+ */
90
+ export declare function createExaminePageTool(deps: ExaminePageToolDeps): Tool<ExaminePageInput, ExaminePageResult>;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The eyes tool of a report session.
3
+ *
4
+ * The tool opens the rendered page of a thread in headless Chrome, and it gives back what a reviewer would
5
+ * see: the screenshot, the console errors, and the failed requests. The agent reads the picture and the
6
+ * faults, decides, and repairs. The tool never judges, thus it never blocks the loop.
7
+ *
8
+ * The session tree has no URL space, thus the tool navigates to the page file through a `file://` URL. The
9
+ * page lives at `report-sessions/{threadId}/index.html` under the workspace root, which the preview writes.
10
+ * A missed page means that no preview ran, and it is a typed outcome, not a throw.
11
+ *
12
+ * The `file://` URL resolves on the filesystem of the Chrome sidecar, because the connection is out of
13
+ * process. Thus the sidecar must mount the workspace tree of the harness host at the same path. A sidecar
14
+ * with no such mount reports the page as an unreachable request, and the tool gives back that fault.
15
+ *
16
+ * A composition that names no browser and injects no capture seam has no eyes at all. The tool reports that
17
+ * condition once, up front, and it stamps nothing. A per-attempt capture failure would instead read as a
18
+ * transient fault and invite a repeat of a call that can never pass.
19
+ *
20
+ * On a capture the tool copies the rendered hash onto the seen hash through the gateway. Thus the look
21
+ * counts, and the record tool lets the current draft record. The copy takes the rendered hash and never the
22
+ * current one, thus a later edit makes the look stale and the record refuses.
23
+ *
24
+ * The gateway reports whether a rendered hash existed to copy. When the row holds none, no preview stamped
25
+ * one and the look cannot count. The tool then gives a missed-stamp outcome that directs a new preview,
26
+ * because a repeated look never stamps a marker that no preview wrote.
27
+ *
28
+ * The chrome navigation and the workspace-root seam each speak the throw protocol. The tool runs the capture
29
+ * inside a guard, thus a genuine fault becomes a typed outcome and a control-flow exception propagates.
30
+ */
31
+ import { ok } from "neverthrow";
32
+ import { access } from "node:fs/promises";
33
+ import { join } from "node:path";
34
+ import { pathToFileURL } from "node:url";
35
+ import { z } from "zod";
36
+ import { hasBrowserUrl } from "../../lib/chrome.js";
37
+ import { createNoopLogger } from "../../lib/console-logger.js";
38
+ import { defaultErrorFields } from "../../lib/logger.js";
39
+ import { capturePage } from "../../lib/page-capture.js";
40
+ import { defineTool, withToolResultImage } from "../define-tool.js";
41
+ import { openReportThread } from "../report-authoring/authoring-tools.js";
42
+ /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
43
+ const examinePageInput = z.object({});
44
+ /** The IANA media type of the screenshot. `page.screenshot` gives PNG bytes. */
45
+ const SCREENSHOT_MEDIA_TYPE = "image/png";
46
+ /** The line that the agent reads when the composition gives no browser. */
47
+ const NO_BROWSER_DETAIL = "the composition gives no browser, thus this session cannot look at its page";
48
+ /**
49
+ * Make the eyes tool over the session-state gateway, the workspace-root seam, and the chrome config.
50
+ *
51
+ * The tool reads the thread id from the scope of the call, and it resolves the page path under the workspace
52
+ * root. Thus one factory serves every thread. The tool holds no per-session value.
53
+ */
54
+ export function createExaminePageTool(deps) {
55
+ const logger = (deps.logger ?? createNoopLogger()).named("examine-page");
56
+ // An injected seam is the eyes of a test. A named browser is the eyes of a deployment. With neither, the
57
+ // composition has no eyes, and that answer is fixed at construction.
58
+ const eyesAvailable = deps.capture !== undefined || hasBrowserUrl(deps.chrome);
59
+ const capture = deps.capture ?? ((url) => capturePage(deps.chrome, url));
60
+ return defineTool({
61
+ id: "examine_page",
62
+ description: "Open the rendered report page in a real headless browser, and report what you see. " +
63
+ "Give back a screenshot, the console errors, and the failed requests. " +
64
+ "Run it after the preview to look at the current page, and to confirm that the layout and the charts read clean. " +
65
+ "The report tool records a version only after you look at the current page. " +
66
+ "If the page has no confirmed render, run the preview again first.",
67
+ inputSchema: examinePageInput,
68
+ executionMode: "inline",
69
+ describeCall: "none",
70
+ execute: async (_input, ctx) => {
71
+ // The check runs before every read, thus one clear signal replaces a capture failure for each
72
+ // page. The arm stamps no seen hash, because no eyes saw the page.
73
+ if (!eyesAvailable) {
74
+ logger.warn("the composition gives no browser, thus no look can run");
75
+ return ok({ outcome: "no-browser", detail: NO_BROWSER_DETAIL });
76
+ }
77
+ const opened = await openReportThread(deps.gateway, ctx.session.scope);
78
+ if (opened.isErr()) {
79
+ return ok({ outcome: "refused", refusal: opened.error });
80
+ }
81
+ const { threadId, analysisId } = opened.value;
82
+ let root;
83
+ try {
84
+ root = deps.resolveWorkspaceRoot(analysisId);
85
+ }
86
+ catch (cause) {
87
+ logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
88
+ return ok({ outcome: "capture-failed", detail: "the workspace root did not resolve" });
89
+ }
90
+ const relativePagePath = join("report-sessions", threadId, "index.html");
91
+ const pagePath = join(root, relativePagePath);
92
+ try {
93
+ await access(pagePath);
94
+ }
95
+ catch (cause) {
96
+ if (cause.code === "ENOENT") {
97
+ // No preview wrote the page yet. The agent runs the preview first.
98
+ return ok({ outcome: "no-page" });
99
+ }
100
+ logger.warn("the session page could not be read", { threadId, analysisId, ...defaultErrorFields(cause) });
101
+ return ok({ outcome: "capture-failed", detail: "the session page could not be read" });
102
+ }
103
+ let captured;
104
+ try {
105
+ captured = await capture(pathToFileURL(pagePath).href);
106
+ }
107
+ catch (cause) {
108
+ logger.warn("the page capture failed", { threadId, analysisId, ...defaultErrorFields(cause) });
109
+ return ok({ outcome: "capture-failed", detail: cause instanceof Error ? cause.message : String(cause) });
110
+ }
111
+ const stamped = await deps.gateway.stampSeen(threadId);
112
+ if (stamped.outcome === "no-rendered") {
113
+ // The row holds no rendered hash, thus no preview stamped one and the look cannot count. A
114
+ // repeated look never fixes this, because it copies a marker that no preview wrote. The agent
115
+ // runs a new preview, which stamps the rendered hash for the next look.
116
+ logger.warn("the seen stamp found no rendered hash", { threadId, analysisId });
117
+ return ok({ outcome: "missed-stamp" });
118
+ }
119
+ // A transient stamp fault and an absent row each leave the seen hash short, and the record gate
120
+ // then refuses a never-seen page. A later look re-stamps the marker, thus the picture stays valid
121
+ // and the tool gives it back.
122
+ if (stamped.outcome !== "stamped") {
123
+ const detail = stamped.outcome === "failed" ? stamped.detail : "the session state row is absent";
124
+ logger.warn("the seen hash did not stamp", { threadId, analysisId, detail });
125
+ }
126
+ // The screenshot rides the image path of the tool result, thus the model sees the picture. The
127
+ // JSON keeps the faults and the page path only, thus the JSON text holds no bytes.
128
+ return ok(withToolResultImage({
129
+ outcome: "examined",
130
+ consoleErrors: captured.consoleErrors,
131
+ failedRequests: captured.failedRequests,
132
+ pagePath: relativePagePath,
133
+ }, { base64: captured.screenshotBase64, mediaType: SCREENSHOT_MEDIA_TYPE }));
134
+ },
135
+ });
136
+ }
@@ -6,3 +6,5 @@
6
6
  * the factories and their public types for the composition root.
7
7
  */
8
8
  export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps } from "./preview-report.js";
9
+ export { createExaminePageTool, type CapturePage, type ExaminePageInput, type ExaminePageResult, type ExaminePageToolDeps, type FailedRequest, type PageCapture, } from "./examine-page.js";
10
+ export { createRecordVersionTool, type RecordVersionInput, type RecordVersionResult, type RecordVersionToolDeps } from "./record-version.js";
@@ -6,3 +6,5 @@
6
6
  * the factories and their public types for the composition root.
7
7
  */
8
8
  export { createPreviewReportTool } from "./preview-report.js";
9
+ export { createExaminePageTool, } from "./examine-page.js";
10
+ export { createRecordVersionTool } from "./record-version.js";
@@ -15,14 +15,18 @@
15
15
  * this tree. A hosted view of a session page is a later capability, with a URL space of its own.
16
16
  *
17
17
  * Each degraded condition is a typed outcome in the ok channel: a session refusal, a gap list, a resolver
18
- * absence, an unresolved reference, a bridge mismatch, a render problem, a figure that escapes the
19
- * workspace root, and a write failure. The tool never throws for one of them. The filesystem speaks the
18
+ * absence, an unresolvable root at resolver construction, an unresolved reference, a bridge mismatch, a
19
+ * render problem, a figure that escapes the workspace root, a write failure, and a stamp failure. The tool
20
+ * never throws for one of them. When the page
21
+ * lands, the tool stamps the hash of the rendered draft on the session state, thus the eyes and the record
22
+ * know which draft the page shows. The filesystem speaks the
20
23
  * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
21
24
  * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
22
25
  * control-flow exception propagate.
23
26
  */
24
27
  import { z } from "zod";
25
28
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
29
+ import type { AuthContext } from "../../auth/types.js";
26
30
  import { type Logger } from "../../lib/logger.js";
27
31
  import { type FinishGap } from "../../report-model/draft-finish.js";
28
32
  import type { ReferenceResolver } from "../../report-model/reference-resolver.js";
@@ -46,6 +50,9 @@ export type PreviewReportResult = {
46
50
  gaps: FinishGap[];
47
51
  } | {
48
52
  outcome: "resolver-unavailable";
53
+ } | {
54
+ outcome: "root-unresolvable";
55
+ detail: string;
49
56
  } | {
50
57
  outcome: "unresolved-references";
51
58
  unresolved: ResolutionFailure[];
@@ -62,6 +69,10 @@ export type PreviewReportResult = {
62
69
  } | {
63
70
  outcome: "write-failed";
64
71
  detail: string;
72
+ } | {
73
+ outcome: "stamp-failed";
74
+ pagePath: string;
75
+ detail: string;
65
76
  } | {
66
77
  outcome: "rendered";
67
78
  pagePath: string;
@@ -70,12 +81,16 @@ export type PreviewReportResult = {
70
81
  * The construction deps of the preview tool.
71
82
  *
72
83
  * `resolveWorkspaceRoot` maps the analysis of the call onto its workspace root, thus one singleton tool
73
- * serves every analysis and it resolves the root per call from the scope. `resolver` is optional, because a
74
- * resolver realization can be absent.
84
+ * serves every analysis and it resolves the root per call from the scope. `makeResolver` is optional, because
85
+ * a resolver realization can be absent. It binds one analysis, thus the tool makes the resolver over the
86
+ * scope of the call.
75
87
  */
76
88
  export interface PreviewReportToolDeps {
77
89
  readonly gateway: ReportSessionStateGateway;
78
- readonly resolver?: ReferenceResolver;
90
+ readonly makeResolver?: (scope: {
91
+ analysisId: string;
92
+ auth: AuthContext;
93
+ }) => ReferenceResolver;
79
94
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
80
95
  readonly logger?: Logger;
81
96
  }
@@ -15,8 +15,11 @@
15
15
  * this tree. A hosted view of a session page is a later capability, with a URL space of its own.
16
16
  *
17
17
  * Each degraded condition is a typed outcome in the ok channel: a session refusal, a gap list, a resolver
18
- * absence, an unresolved reference, a bridge mismatch, a render problem, a figure that escapes the
19
- * workspace root, and a write failure. The tool never throws for one of them. The filesystem speaks the
18
+ * absence, an unresolvable root at resolver construction, an unresolved reference, a bridge mismatch, a
19
+ * render problem, a figure that escapes the workspace root, a write failure, and a stamp failure. The tool
20
+ * never throws for one of them. When the page
21
+ * lands, the tool stamps the hash of the rendered draft on the session state, thus the eyes and the record
22
+ * know which draft the page shows. The filesystem speaks the
20
23
  * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
21
24
  * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
22
25
  * control-flow exception propagate.
@@ -29,6 +32,7 @@ import { resolveWorkspacePath } from "../../workspace/paths.js";
29
32
  import { createNoopLogger } from "../../lib/console-logger.js";
30
33
  import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
31
34
  import { defaultErrorFields } from "../../lib/logger.js";
35
+ import { computeDraftHash } from "../../report-model/draft-hash.js";
32
36
  import { finishDraft } from "../../report-model/draft-finish.js";
33
37
  import { resolveDocumentReferences } from "../../report-model/validate.js";
34
38
  import { bridgeValues } from "../../report-render/value-bridge.js";
@@ -203,10 +207,21 @@ export function createPreviewReportTool(deps) {
203
207
  return ok({ outcome: "gaps", gaps: finished.gaps });
204
208
  }
205
209
  const document = finished.document;
206
- if (deps.resolver === undefined) {
210
+ if (deps.makeResolver === undefined) {
207
211
  return ok({ outcome: "resolver-unavailable" });
208
212
  }
209
- const { resolutions, unresolved } = await resolveDocument(document, deps.resolver, snapshot);
213
+ // The resolver construction resolves the workspace root inside, and that seam throws on an
214
+ // unresolvable root. The guard turns the throw into a typed outcome that names the fault, thus a
215
+ // control-flow exception propagates and the unresolvable root does not.
216
+ let resolver;
217
+ try {
218
+ resolver = deps.makeResolver({ analysisId, auth: ctx.session.auth });
219
+ }
220
+ catch (cause) {
221
+ logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
222
+ return ok({ outcome: "root-unresolvable", detail: "the workspace root did not resolve" });
223
+ }
224
+ const { resolutions, unresolved } = await resolveDocument(document, resolver, snapshot);
210
225
  if (unresolved.length > 0) {
211
226
  return ok({ outcome: "unresolved-references", unresolved });
212
227
  }
@@ -239,6 +254,15 @@ export function createPreviewReportTool(deps) {
239
254
  logger.warn("the page did not land", { threadId, analysisId, ...defaultErrorFields(failure.error.cause) });
240
255
  return ok({ outcome: "write-failed", detail: describeFsError(failure.error) });
241
256
  }
257
+ // The page shows the draft that the finish read. The stamp records that draft, thus the eyes
258
+ // copy this hash and the record compares against it. A failed stamp is a transient store fault,
259
+ // and the page stays on disk. The agent runs the preview again to stamp the marker.
260
+ const stamped = await deps.gateway.stampRendered(threadId, computeDraftHash(draft));
261
+ if (stamped.outcome !== "stamped") {
262
+ const detail = stamped.outcome === "failed" ? stamped.detail : "the session state row is absent";
263
+ logger.warn("the rendered hash did not stamp", { threadId, analysisId, detail });
264
+ return ok({ outcome: "stamp-failed", pagePath: written.value, detail });
265
+ }
242
266
  return ok({ outcome: "rendered", pagePath: written.value });
243
267
  },
244
268
  });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The record tool of a report session.
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.
7
+ *
8
+ * The gate has three parts, in order of cost. The finish gates the schema, the unique ids, and the
9
+ * structural tier, and a gap list returns as data. The look-before-record rule then compares the seen hash
10
+ * against the hash of the current draft, on the state that the load already read. A never-seen page and a
11
+ * stale look each refuse with a distinct reason, and the rule reads no model judgment. The full validation
12
+ * resolves every reference through the injected resolver, matches each chart encoding, and matches each
13
+ * assert. A failure names the block that holds the failed part, thus the agent repairs one block and not the
14
+ * report at large.
15
+ *
16
+ * The look runs before the validation on purpose. The validation builds the resolver and its prepare pass
17
+ * can start a container, thus the cheap look guards the expensive pass. A never-seen or stale draft refuses
18
+ * before the resolver constructs.
19
+ *
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.
23
+ */
24
+ import { z } from "zod";
25
+ import type { AuthContext } from "../../auth/types.js";
26
+ import type { Logger } from "../../lib/logger.js";
27
+ import type { ThreadStore } from "../../memory/thread-store.js";
28
+ import { type FinishGap } from "../../report-model/draft-finish.js";
29
+ import type { ReferenceResolver } from "../../report-model/reference-resolver.js";
30
+ import { type ResolutionFailure, type SchemaIssue } from "../../report-model/validate.js";
31
+ import type { ReportVersionStore } from "../../state/report-versions.js";
32
+ import { type Tool } from "../define-tool.js";
33
+ import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
34
+ /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
35
+ declare const recordVersionInput: z.ZodObject<{}, z.core.$strip>;
36
+ export type RecordVersionInput = z.infer<typeof recordVersionInput>;
37
+ /**
38
+ * The typed outcome of the record tool. Each arm is ok-channel data, thus the tool never throws for a
39
+ * degraded condition.
40
+ *
41
+ * `gaps` names each completeness gap of the draft. `never-seen` means that no eyes ran on the current draft.
42
+ * `stale-look` means that the eyes ran, and the agent then changed the draft. `root-unresolvable` means that
43
+ * 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.
46
+ */
47
+ export type RecordVersionResult = {
48
+ outcome: "refused";
49
+ refusal: SessionRefusal;
50
+ } | {
51
+ outcome: "gaps";
52
+ gaps: FinishGap[];
53
+ } | {
54
+ outcome: "never-seen";
55
+ } | {
56
+ outcome: "stale-look";
57
+ } | {
58
+ outcome: "resolver-unavailable";
59
+ } | {
60
+ outcome: "root-unresolvable";
61
+ detail: string;
62
+ } | {
63
+ outcome: "invalid";
64
+ schemaIssues?: SchemaIssue[];
65
+ duplicateIds?: string[];
66
+ resolutionFailures?: ResolutionFailure[];
67
+ } | {
68
+ outcome: "already-recorded";
69
+ } | {
70
+ outcome: "record-failed";
71
+ detail: string;
72
+ } | {
73
+ outcome: "recorded";
74
+ versionId: string;
75
+ };
76
+ /**
77
+ * The construction deps of the record tool.
78
+ *
79
+ * `store` is the append-only version store. `threads` reads the anchor of the report thread, thus the
80
+ * version carries the parent conversation and the transcript position. `makeResolver` is optional, because a
81
+ * resolver realization can be absent, and the gate needs one. It binds one analysis, thus the tool makes the
82
+ * resolver over the scope of the call.
83
+ */
84
+ export interface RecordVersionToolDeps {
85
+ readonly gateway: ReportSessionStateGateway;
86
+ readonly store: ReportVersionStore;
87
+ readonly threads: Pick<ThreadStore, "getThread">;
88
+ readonly makeResolver?: (scope: {
89
+ analysisId: string;
90
+ auth: AuthContext;
91
+ }) => ReferenceResolver;
92
+ readonly logger?: Logger;
93
+ }
94
+ /**
95
+ * Make the record tool over the session-state gateway, the version store, the thread store, and the
96
+ * resolver.
97
+ *
98
+ * The tool reads the thread id from the scope of the call, and it loads the state through the gateway. Thus
99
+ * one factory serves every thread. The tool holds no per-session value.
100
+ */
101
+ export declare function createRecordVersionTool(deps: RecordVersionToolDeps): Tool<RecordVersionInput, RecordVersionResult>;
102
+ export {};