@inflexa-ai/harness 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/agents/report-session-agent.d.ts +32 -9
  2. package/dist/agents/report-session-agent.js +22 -6
  3. package/dist/app/report-session-runtime.js +26 -0
  4. package/dist/app/spawn-report-session.d.ts +32 -5
  5. package/dist/app/spawn-report-session.js +19 -0
  6. package/dist/auth/local-run-authorizer.d.ts +3 -2
  7. package/dist/auth/local-run-authorizer.js +4 -2
  8. package/dist/config/environment-stores.d.ts +12 -7
  9. package/dist/config/resource-limits.d.ts +6 -10
  10. package/dist/config/resource-limits.js +6 -44
  11. package/dist/contracts/report-reference.d.ts +4 -0
  12. package/dist/contracts/report-reference.js +3 -0
  13. package/dist/execution/run-authorizer.d.ts +18 -0
  14. package/dist/execution/run-canceler.d.ts +67 -0
  15. package/dist/execution/run-canceler.js +130 -0
  16. package/dist/index.d.ts +7 -3
  17. package/dist/index.js +6 -1
  18. package/dist/lib/chrome.d.ts +14 -0
  19. package/dist/lib/chrome.js +7 -0
  20. package/dist/lib/page-capture.d.ts +47 -0
  21. package/dist/lib/page-capture.js +83 -0
  22. package/dist/loop/run-agent.js +104 -15
  23. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  24. package/dist/memory/ai-sdk-message-storage.js +9 -4
  25. package/dist/memory/count-tokens.js +34 -5
  26. package/dist/prompts/report-session.d.ts +1 -1
  27. package/dist/prompts/report-session.js +21 -0
  28. package/dist/providers/ai-sdk.d.ts +81 -3
  29. package/dist/providers/ai-sdk.js +91 -10
  30. package/dist/providers/types.d.ts +17 -0
  31. package/dist/report-model/assert-rules.d.ts +44 -0
  32. package/dist/report-model/assert-rules.js +97 -0
  33. package/dist/report-model/draft-hash.d.ts +14 -0
  34. package/dist/report-model/draft-hash.js +36 -0
  35. package/dist/report-model/fixture-resolver.js +2 -71
  36. package/dist/report-model/production-resolver.d.ts +124 -0
  37. package/dist/report-model/production-resolver.js +801 -0
  38. package/dist/report-model/reference-resolver.d.ts +6 -0
  39. package/dist/report-model/validate.js +5 -0
  40. package/dist/report-render/assets.d.ts +42 -0
  41. package/dist/report-render/assets.js +61 -0
  42. package/dist/report-render/chart.js +31 -10
  43. package/dist/report-render/design.d.ts +120 -0
  44. package/dist/report-render/design.js +808 -0
  45. package/dist/report-render/page.d.ts +51 -111
  46. package/dist/report-render/page.js +158 -268
  47. package/dist/report-render/render.js +34 -11
  48. package/dist/report-render/views/chart-view.d.ts +6 -3
  49. package/dist/report-render/views/chart-view.js +10 -5
  50. package/dist/report-render/views/page-view.d.ts +19 -8
  51. package/dist/report-render/views/page-view.js +31 -11
  52. package/dist/report-render/views/prose.d.ts +1 -0
  53. package/dist/report-render/views/prose.js +7 -13
  54. package/dist/report-render/views/references-view.js +5 -5
  55. package/dist/report-render/views/values.d.ts +15 -6
  56. package/dist/report-render/views/values.js +29 -10
  57. package/dist/runtime/assemble.d.ts +38 -4
  58. package/dist/runtime/assemble.js +43 -3
  59. package/dist/sandbox/k8s-client.js +18 -0
  60. package/dist/sandbox/mount-plan.d.ts +7 -0
  61. package/dist/sandbox/mount-plan.js +7 -2
  62. package/dist/sandbox/types.d.ts +6 -0
  63. package/dist/state/analyses.d.ts +3 -26
  64. package/dist/state/analyses.js +6 -64
  65. package/dist/state/index.d.ts +2 -2
  66. package/dist/state/index.js +2 -2
  67. package/dist/state/init.js +23 -10
  68. package/dist/state/report-session-state.d.ts +27 -0
  69. package/dist/state/report-session-state.js +29 -2
  70. package/dist/state/runs.d.ts +10 -0
  71. package/dist/state/runs.js +21 -0
  72. package/dist/tasks/data-profile.d.ts +4 -0
  73. package/dist/tasks/data-profile.js +14 -0
  74. package/dist/tasks/extract-values-script.d.ts +64 -0
  75. package/dist/tasks/extract-values-script.js +180 -0
  76. package/dist/tasks/extract-values.d.ts +124 -0
  77. package/dist/tasks/extract-values.js +193 -0
  78. package/dist/tools/approval/gateway.d.ts +8 -1
  79. package/dist/tools/approval/gateway.js +9 -2
  80. package/dist/tools/approval/queries.d.ts +9 -2
  81. package/dist/tools/approval/queries.js +12 -4
  82. package/dist/tools/define-tool.d.ts +29 -0
  83. package/dist/tools/define-tool.js +26 -0
  84. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  85. package/dist/tools/report/preview-snapshot.js +11 -50
  86. package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
  87. package/dist/tools/report-authoring/authoring-tools.js +1 -1
  88. package/dist/tools/report-session/examine-page.d.ts +90 -0
  89. package/dist/tools/report-session/examine-page.js +136 -0
  90. package/dist/tools/report-session/index.d.ts +3 -1
  91. package/dist/tools/report-session/index.js +3 -1
  92. package/dist/tools/report-session/preview-report.d.ts +31 -6
  93. package/dist/tools/report-session/preview-report.js +63 -16
  94. package/dist/tools/report-session/record-version.d.ts +102 -0
  95. package/dist/tools/report-session/record-version.js +139 -0
  96. package/dist/tools/sandbox/list-available-packages.js +2 -1
  97. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  98. package/dist/tools/sandbox/list-available-refs.js +13 -6
  99. package/dist/workflows/sandbox-step.d.ts +4 -0
  100. package/dist/workflows/sandbox-step.js +21 -2
  101. package/package.json +14 -8
@@ -19,11 +19,9 @@
19
19
  import { ok } from "neverthrow";
20
20
  import { z } from "zod";
21
21
  import { defineTool } from "../define-tool.js";
22
- import { withPage } from "../../lib/chrome.js";
22
+ import { capturePage } from "../../lib/page-capture.js";
23
23
  import { createNoopLogger } from "../../lib/console-logger.js";
24
24
  import { describeMintFailure } from "./preview-publisher.js";
25
- const NAV_TIMEOUT_MS = 20_000;
26
- const READY_TIMEOUT_MS = 8_000;
27
25
  const WAIT_MS_CAP = 30_000;
28
26
  export function createPreviewSnapshotTool(state) {
29
27
  const logger = (state.logger ?? createNoopLogger()).named("preview-snapshot").with({ previewId: state.previewId });
@@ -75,53 +73,16 @@ export function createPreviewSnapshotTool(state) {
75
73
  state.urlCell.expiresAt = result.data.expiresAt;
76
74
  }
77
75
  try {
78
- return ok(await withPage(state.chrome, async (page) => {
79
- const consoleErrors = [];
80
- const failedRequests = [];
81
- page.on("console", (msg) => {
82
- if (msg.type() === "error")
83
- consoleErrors.push(msg.text());
84
- });
85
- page.on("pageerror", (err) => consoleErrors.push(`pageerror: ${err.message}`));
86
- page.on("requestfailed", (req) => {
87
- failedRequests.push({
88
- url: req.url(),
89
- reason: req.failure()?.errorText ?? "unknown",
90
- });
91
- });
92
- await page.goto(url, {
93
- waitUntil: "networkidle2",
94
- timeout: NAV_TIMEOUT_MS,
95
- });
96
- // Wait for the inflexa-theme-ready event (fired after echarts theme
97
- // registers). Check the `__inflexaThemeReady` sentinel first because
98
- // the page already deferred its dispatch — by the time we get here
99
- // via networkidle2, the event has likely already fired and a plain
100
- // addEventListener would block forever. The callback runs in the
101
- // browser context.
102
- await page
103
- .evaluate(new Function("timeout", "return new Promise(function(resolve){if(window.__inflexaThemeReady){resolve();return;}var t=setTimeout(resolve,timeout);document.addEventListener('inflexa-theme-ready',function(){clearTimeout(t);resolve();},{once:true});});"), READY_TIMEOUT_MS)
104
- .catch(() => {
105
- /* fall through — capture state as-is */
106
- });
107
- if (input.waitForSelector) {
108
- await page.waitForSelector(input.waitForSelector, { timeout: 5_000 }).catch(() => {
109
- /* not fatal */
110
- });
111
- }
112
- if (input.waitMs)
113
- await new Promise((r) => setTimeout(r, input.waitMs));
114
- const screenshot = await page.screenshot({
115
- encoding: "base64",
116
- fullPage: false,
117
- });
118
- return {
119
- ok: true,
120
- screenshotBase64: typeof screenshot === "string" ? screenshot : Buffer.from(screenshot).toString("base64"),
121
- consoleErrors,
122
- failedRequests,
123
- };
124
- }));
76
+ const captured = await capturePage(state.chrome, url, {
77
+ ...(input.waitForSelector !== undefined ? { waitForSelector: input.waitForSelector } : {}),
78
+ ...(input.waitMs !== undefined ? { waitMs: input.waitMs } : {}),
79
+ });
80
+ return ok({
81
+ ok: true,
82
+ screenshotBase64: captured.screenshotBase64,
83
+ consoleErrors: captured.consoleErrors,
84
+ failedRequests: captured.failedRequests,
85
+ });
125
86
  }
126
87
  catch (err) {
127
88
  return ok({
@@ -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
+ }
@@ -5,4 +5,6 @@
5
5
  * render seams, thus each tool binds the per-session state to the thread of the call. The barrel exports
6
6
  * the factories and their public types for the composition root.
7
7
  */
8
- export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps } from "./preview-report.js";
8
+ export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps, type ResolvePageAsset, } 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";
@@ -5,4 +5,6 @@
5
5
  * render seams, thus each tool binds the per-session state to the thread of the call. The barrel exports
6
6
  * the factories and their public types for the composition root.
7
7
  */
8
- export { createPreviewReportTool } from "./preview-report.js";
8
+ export { createPreviewReportTool, } from "./preview-report.js";
9
+ export { createExaminePageTool, } from "./examine-page.js";
10
+ export { createRecordVersionTool } from "./record-version.js";
@@ -6,7 +6,7 @@
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 page and its staged image assets land in `report-sessions/{threadId}/` under the workspace root. That
9
+ * The page and its staged assets land in `report-sessions/{threadId}/` under the workspace root. That
10
10
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
11
11
  * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
12
12
  *
@@ -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,21 +69,39 @@ 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;
68
79
  };
80
+ /**
81
+ * The lookup of the source file of one staged asset. It maps the module specifier of a manifest entry
82
+ * (`report-render/assets.ts`) onto an absolute path on disk.
83
+ */
84
+ export type ResolvePageAsset = (specifier: string) => string;
69
85
  /**
70
86
  * The construction deps of the preview tool.
71
87
  *
72
88
  * `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.
89
+ * serves every analysis and it resolves the root per call from the scope. `makeResolver` is optional, because
90
+ * a resolver realization can be absent. It binds one analysis, thus the tool makes the resolver over the
91
+ * scope of the call.
92
+ *
93
+ * `resolvePageAsset` is optional, and absent it resolves each specifier against the installation of the
94
+ * harness. An embedder that ships the asset bytes packed, for example a compiled single-file binary with no
95
+ * `node_modules` tree, materializes them to disk and binds its own lookup here.
75
96
  */
76
97
  export interface PreviewReportToolDeps {
77
98
  readonly gateway: ReportSessionStateGateway;
78
- readonly resolver?: ReferenceResolver;
99
+ readonly makeResolver?: (scope: {
100
+ analysisId: string;
101
+ auth: AuthContext;
102
+ }) => ReferenceResolver;
79
103
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
104
+ readonly resolvePageAsset?: ResolvePageAsset;
80
105
  readonly logger?: Logger;
81
106
  }
82
107
  /**