@inflexa-ai/harness 0.18.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 (83) hide show
  1. package/dist/agents/report-session-agent.d.ts +72 -0
  2. package/dist/agents/report-session-agent.js +87 -0
  3. package/dist/app/report-session-runtime.d.ts +72 -0
  4. package/dist/app/report-session-runtime.js +183 -0
  5. package/dist/app/spawn-report-session.d.ts +32 -5
  6. package/dist/app/spawn-report-session.js +19 -0
  7. package/dist/config/environment-stores.d.ts +12 -7
  8. package/dist/config/resource-limits.d.ts +6 -10
  9. package/dist/config/resource-limits.js +6 -44
  10. package/dist/contracts/report-reference.d.ts +4 -0
  11. package/dist/contracts/report-reference.js +3 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/lib/chrome.d.ts +14 -0
  14. package/dist/lib/chrome.js +7 -0
  15. package/dist/lib/page-capture.d.ts +47 -0
  16. package/dist/lib/page-capture.js +83 -0
  17. package/dist/loop/run-agent.js +104 -15
  18. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  19. package/dist/memory/ai-sdk-message-storage.js +9 -4
  20. package/dist/memory/count-tokens.js +34 -5
  21. package/dist/memory/thread-store.d.ts +4 -4
  22. package/dist/memory/thread-store.js +18 -9
  23. package/dist/prompts/report-session.d.ts +1 -0
  24. package/dist/prompts/report-session.js +110 -0
  25. package/dist/providers/ai-sdk.d.ts +158 -3
  26. package/dist/providers/ai-sdk.js +234 -23
  27. package/dist/providers/errors.d.ts +14 -0
  28. package/dist/providers/errors.js +103 -18
  29. package/dist/providers/types.d.ts +33 -0
  30. package/dist/providers/types.js +10 -1
  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/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
  37. package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
  38. package/dist/report-model/production-resolver.d.ts +124 -0
  39. package/dist/report-model/production-resolver.js +801 -0
  40. package/dist/report-model/reference-resolver.d.ts +7 -1
  41. package/dist/report-model/validate.d.ts +34 -2
  42. package/dist/report-model/validate.js +37 -17
  43. package/dist/report-render/page.d.ts +23 -0
  44. package/dist/report-render/page.js +36 -3
  45. package/dist/report-render/value-bridge.d.ts +62 -0
  46. package/dist/report-render/value-bridge.js +82 -0
  47. package/dist/runtime/assemble.d.ts +39 -0
  48. package/dist/runtime/assemble.js +67 -6
  49. package/dist/sandbox/mount-plan.d.ts +7 -0
  50. package/dist/sandbox/mount-plan.js +7 -2
  51. package/dist/state/init.js +36 -0
  52. package/dist/state/purge-analysis.js +1 -0
  53. package/dist/state/report-session-state.d.ts +139 -0
  54. package/dist/state/report-session-state.js +177 -0
  55. package/dist/state/report-versions.d.ts +3 -6
  56. package/dist/state/report-versions.js +11 -38
  57. package/dist/state/snapshot-parse.d.ts +34 -0
  58. package/dist/state/snapshot-parse.js +82 -0
  59. package/dist/tasks/extract-values-script.d.ts +64 -0
  60. package/dist/tasks/extract-values-script.js +180 -0
  61. package/dist/tasks/extract-values.d.ts +124 -0
  62. package/dist/tasks/extract-values.js +193 -0
  63. package/dist/tools/ad-hoc-router.d.ts +7 -2
  64. package/dist/tools/ad-hoc-router.js +2 -1
  65. package/dist/tools/define-tool.d.ts +29 -0
  66. package/dist/tools/define-tool.js +26 -0
  67. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  68. package/dist/tools/report/preview-snapshot.js +11 -50
  69. package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
  70. package/dist/tools/report-authoring/authoring-tools.js +152 -44
  71. package/dist/tools/report-session/examine-page.d.ts +90 -0
  72. package/dist/tools/report-session/examine-page.js +136 -0
  73. package/dist/tools/report-session/index.d.ts +10 -0
  74. package/dist/tools/report-session/index.js +10 -0
  75. package/dist/tools/report-session/preview-report.d.ts +105 -0
  76. package/dist/tools/report-session/preview-report.js +269 -0
  77. package/dist/tools/report-session/record-version.d.ts +102 -0
  78. package/dist/tools/report-session/record-version.js +139 -0
  79. package/dist/tools/research/generate-plan.js +4 -2
  80. package/dist/tools/sandbox/list-available-packages.js +2 -1
  81. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  82. package/dist/tools/sandbox/list-available-refs.js +13 -6
  83. package/package.json +10 -8
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The Report Builder agent -- the agent that talks to the user on a report thread.
3
+ *
4
+ * `createReportSessionAgent` is the composition root of the report path. It gets
5
+ * the shared deps one time, and it hands each tool exactly what it needs. The
6
+ * authoring tools, the preview tool, the eyes tool, and the record tool bind to the
7
+ * session-state gateway, thus one assembled definition serves every thread, and the
8
+ * per-session state stays behind the tool boundary.
9
+ *
10
+ * The roster is read-only toward the analysis. It holds the workspace read tools,
11
+ * the workspace search, the run inspection, and the data-profile inspection. It
12
+ * holds no planner, no run launcher, no working-memory write, and no sandbox
13
+ * mutate surface. Thus no tool starts a run, and no tool changes an analysis. The
14
+ * roster omits those tools, and no runtime guard blocks them.
15
+ *
16
+ * The definition carries no per-session value. The system prompt composes through
17
+ * `composeSystemPrompt` with the identity part and the conversational part, the
18
+ * same composition as the conversation agent. Thus the prompt prefix stays constant
19
+ * across threads, and the provider cache reuses it.
20
+ */
21
+ import type { Pool } from "pg";
22
+ import type { AuthContext } from "../auth/types.js";
23
+ import type { AgentDefinition } from "../loop/types.js";
24
+ import type { EmbeddingProvider } from "../providers/types.js";
25
+ import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
26
+ import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
27
+ import type { ChromeConfig } from "../lib/chrome.js";
28
+ import type { Logger } from "../lib/logger.js";
29
+ import type { ThreadStore } from "../memory/thread-store.js";
30
+ import type { ReferenceResolver } from "../report-model/reference-resolver.js";
31
+ import type { ReportVersionStore } from "../state/report-versions.js";
32
+ import type { ReportSessionStateGateway } from "../tools/report-authoring/authoring-tools.js";
33
+ /** Canonical agent id -- distinct from the id of the old templating agent. */
34
+ export declare const REPORT_SESSION_AGENT_ID: "report-session";
35
+ /**
36
+ * The shared dependencies of the report agent. The gateway binds the per-session
37
+ * state to the thread, thus every tool that touches the state gets the same
38
+ * gateway. The resolver factory is optional, because a resolver realization can be
39
+ * absent, and the preview tool and the record tool degrade as data when it is.
40
+ */
41
+ export interface ReportSessionAgentDeps {
42
+ /** Model id -- the provenance and metric label. The provider owns the wire model. */
43
+ readonly model: string;
44
+ /** Postgres pool -- workspace index, run inspection, and data-profile inspection. */
45
+ readonly pool: Pool;
46
+ /** Embedding seam -- workspace semantic search. */
47
+ readonly embedding: EmbeddingProvider;
48
+ /** Workspace filesystem read seam -- the four read tools. */
49
+ readonly workspaceFs: WorkspaceFilesystem;
50
+ /** Session-state gateway -- the authoring tools, the preview tool, the eyes tool, and the record tool bind to it. */
51
+ readonly gateway: ReportSessionStateGateway;
52
+ /** Workspace-root resolution seam -- the preview tool and the eyes tool resolve the page root per call. */
53
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
54
+ /** Append-only version store -- the record tool gates the whole document first, then records one version. */
55
+ readonly store: ReportVersionStore;
56
+ /** Thread reader -- the record tool reads the anchor of the report thread. */
57
+ readonly threads: Pick<ThreadStore, "getThread">;
58
+ /** Headless-Chrome config -- the eyes tool opens the rendered page. */
59
+ readonly chrome: ChromeConfig;
60
+ /**
61
+ * Reference-resolution factory -- absent until a realization lands; the preview tool and the record tool
62
+ * degrade as data. The factory binds one analysis, thus a tool makes the resolver over the scope of the call.
63
+ */
64
+ readonly makeResolver?: (scope: {
65
+ analysisId: string;
66
+ auth: AuthContext;
67
+ }) => ReferenceResolver;
68
+ /** Operational logging seam; omitted falls back to no-op. */
69
+ readonly logger?: Logger;
70
+ }
71
+ /** Build the report `AgentDefinition` with every tool bound to its deps. */
72
+ export declare function createReportSessionAgent(deps: ReportSessionAgentDeps): AgentDefinition;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The Report Builder agent -- the agent that talks to the user on a report thread.
3
+ *
4
+ * `createReportSessionAgent` is the composition root of the report path. It gets
5
+ * the shared deps one time, and it hands each tool exactly what it needs. The
6
+ * authoring tools, the preview tool, the eyes tool, and the record tool bind to the
7
+ * session-state gateway, thus one assembled definition serves every thread, and the
8
+ * per-session state stays behind the tool boundary.
9
+ *
10
+ * The roster is read-only toward the analysis. It holds the workspace read tools,
11
+ * the workspace search, the run inspection, and the data-profile inspection. It
12
+ * holds no planner, no run launcher, no working-memory write, and no sandbox
13
+ * mutate surface. Thus no tool starts a run, and no tool changes an analysis. The
14
+ * roster omits those tools, and no runtime guard blocks them.
15
+ *
16
+ * The definition carries no per-session value. The system prompt composes through
17
+ * `composeSystemPrompt` with the identity part and the conversational part, the
18
+ * same composition as the conversation agent. Thus the prompt prefix stays constant
19
+ * across threads, and the provider cache reuses it.
20
+ */
21
+ import { createReportAuthoringTools } from "../tools/report-authoring/authoring-tools.js";
22
+ import { createExaminePageTool, createPreviewReportTool, createRecordVersionTool } from "../tools/report-session/index.js";
23
+ import { createFileStatTool, createGrepTool, createListFilesTool, createReadFileTool, createWorkspaceSearchTool } from "../tools/workspace/index.js";
24
+ import { createInspectDataProfileTool, createInspectRunTool } from "../tools/research/index.js";
25
+ import { reportSessionPrompt } from "../prompts/report-session.js";
26
+ import { composeSystemPrompt } from "./system-prompt.js";
27
+ /** Canonical agent id -- distinct from the id of the old templating agent. */
28
+ export const REPORT_SESSION_AGENT_ID = "report-session";
29
+ /**
30
+ * Runaway guard. A report turn drives many small tool calls, thus it needs the
31
+ * same headroom as the conversation agent.
32
+ */
33
+ const REPORT_SESSION_MAX_ITERATIONS = 50;
34
+ /** Build the report `AgentDefinition` with every tool bound to its deps. */
35
+ export function createReportSessionAgent(deps) {
36
+ const { model, pool, embedding, workspaceFs, gateway, resolveWorkspaceRoot, store, threads, chrome, makeResolver, logger } = deps;
37
+ const authoring = createReportAuthoringTools(gateway);
38
+ const tools = [
39
+ // The read surface toward the analysis. It roams the tree read-only.
40
+ createReadFileTool(workspaceFs),
41
+ createListFilesTool(workspaceFs),
42
+ createFileStatTool(workspaceFs),
43
+ createGrepTool(workspaceFs),
44
+ createWorkspaceSearchTool(pool, embedding),
45
+ createInspectRunTool(pool),
46
+ createInspectDataProfileTool(pool),
47
+ // The composition surface. Each authoring tool binds to the gateway, thus
48
+ // two threads never share one draft.
49
+ authoring.add_block,
50
+ authoring.change_block,
51
+ authoring.remove_block,
52
+ authoring.move_block,
53
+ authoring.set_title,
54
+ authoring.read_outline,
55
+ authoring.read_block,
56
+ authoring.finish_draft,
57
+ // The render-and-preview tool. It resolves the page root per call.
58
+ createPreviewReportTool({
59
+ gateway,
60
+ resolveWorkspaceRoot,
61
+ ...(makeResolver ? { makeResolver } : {}),
62
+ ...(logger ? { logger } : {}),
63
+ }),
64
+ // The eyes tool. It opens the rendered page in headless Chrome.
65
+ createExaminePageTool({
66
+ gateway,
67
+ resolveWorkspaceRoot,
68
+ chrome,
69
+ ...(logger ? { logger } : {}),
70
+ }),
71
+ // The record tool. It gates the whole document, then records one version.
72
+ createRecordVersionTool({
73
+ gateway,
74
+ store,
75
+ threads,
76
+ ...(makeResolver ? { makeResolver } : {}),
77
+ ...(logger ? { logger } : {}),
78
+ }),
79
+ ];
80
+ return {
81
+ id: REPORT_SESSION_AGENT_ID,
82
+ systemPrompt: composeSystemPrompt(reportSessionPrompt, { identity: true, conversational: true }),
83
+ model,
84
+ tools,
85
+ maxIterations: REPORT_SESSION_MAX_ITERATIONS,
86
+ };
87
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The report session-state runtime -- the durable realization of the session-state
3
+ * gateway, and the idempotent operation that anchors one report session.
4
+ *
5
+ * The runtime binds three parts: the session-state store, the thread store, and
6
+ * the pin. The tool layer speaks the gateway, and the gateway speaks a plain
7
+ * discriminated value. Thus the neverthrow of the store stays inside this module,
8
+ * and it never crosses the tool boundary.
9
+ *
10
+ * `ensureSessionState` makes sure that the row of a thread exists. The first run
11
+ * resolves the analysis of the thread, pins the snapshot, and writes the row. A
12
+ * later run reads the row and pins nothing. The store insert-if-absent keeps two
13
+ * concurrent first runs to one row. A pin failure writes no row and returns as
14
+ * typed data, thus a later run pins again.
15
+ *
16
+ * The gateway `load` runs the same operation, thus a tool call never arrives
17
+ * before the state exists. A fresh row holds the snapshot and no document yet.
18
+ * Thus `load` gives the empty draft with the stored snapshot, because the pin
19
+ * writes the snapshot first and the document lands later.
20
+ *
21
+ * A store fault, an absent thread, and a pin failure each ride the failed arm
22
+ * with a short detail. The diagnostic goes to the injected `Logger`, never to the
23
+ * detail alone.
24
+ *
25
+ * The gateway load separates a permanent condition from a transient one. An absent
26
+ * thread and a wrong thread type are permanent, and a store or pin fault is
27
+ * transient. The persist is a compare-and-swap against the prior document that the
28
+ * load read, thus two concurrent turns cannot both land.
29
+ */
30
+ import type { Pool } from "pg";
31
+ import type { Logger } from "../lib/logger.js";
32
+ import { type ReportSessionState as StoredSessionState } from "../state/report-session-state.js";
33
+ import type { ReportSessionStateGateway } from "../tools/report-authoring/authoring-tools.js";
34
+ /**
35
+ * How a failed anchor ends. `absent-thread` and `wrong-thread-type` are permanent
36
+ * conditions, and `unavailable` is a transient store or pin fault. The gateway load
37
+ * carries the distinction, thus the tool tells a permanent refusal from a transient one.
38
+ */
39
+ export type EnsureFailureKind = "absent-thread" | "wrong-thread-type" | "unavailable";
40
+ /**
41
+ * The outcome of the anchor operation. `ready` carries the durable row, whose
42
+ * document is `null` until the first document lands. `failed` names the cause as a
43
+ * short detail and a kind, and the runtime logs the diagnostic beside it.
44
+ */
45
+ export type EnsureSessionStateResult = {
46
+ outcome: "ready";
47
+ state: StoredSessionState;
48
+ } | {
49
+ outcome: "failed";
50
+ kind: EnsureFailureKind;
51
+ detail: string;
52
+ };
53
+ export interface ReportSessionRuntimeDeps {
54
+ readonly pool: Pool;
55
+ readonly logger?: Logger;
56
+ }
57
+ /**
58
+ * The report session-state runtime. The gateway serves the authoring tools, and
59
+ * `ensureSessionState` anchors the session. The serving path of a report turn runs
60
+ * `ensureSessionState` at the turn start, and the gateway `load` runs it too, thus
61
+ * a tool call cannot arrive before the state exists.
62
+ */
63
+ export interface ReportSessionRuntime {
64
+ readonly gateway: ReportSessionStateGateway;
65
+ ensureSessionState(threadId: string): Promise<EnsureSessionStateResult>;
66
+ }
67
+ /**
68
+ * Build the report session-state runtime bound to a Postgres pool. The factory
69
+ * closure captures the pool and constructs the session-state store and the thread
70
+ * store from it, the same way the chat-turn preparation does.
71
+ */
72
+ export declare function createReportSessionRuntime(deps: ReportSessionRuntimeDeps): ReportSessionRuntime;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The report session-state runtime -- the durable realization of the session-state
3
+ * gateway, and the idempotent operation that anchors one report session.
4
+ *
5
+ * The runtime binds three parts: the session-state store, the thread store, and
6
+ * the pin. The tool layer speaks the gateway, and the gateway speaks a plain
7
+ * discriminated value. Thus the neverthrow of the store stays inside this module,
8
+ * and it never crosses the tool boundary.
9
+ *
10
+ * `ensureSessionState` makes sure that the row of a thread exists. The first run
11
+ * resolves the analysis of the thread, pins the snapshot, and writes the row. A
12
+ * later run reads the row and pins nothing. The store insert-if-absent keeps two
13
+ * concurrent first runs to one row. A pin failure writes no row and returns as
14
+ * typed data, thus a later run pins again.
15
+ *
16
+ * The gateway `load` runs the same operation, thus a tool call never arrives
17
+ * before the state exists. A fresh row holds the snapshot and no document yet.
18
+ * Thus `load` gives the empty draft with the stored snapshot, because the pin
19
+ * writes the snapshot first and the document lands later.
20
+ *
21
+ * A store fault, an absent thread, and a pin failure each ride the failed arm
22
+ * with a short detail. The diagnostic goes to the injected `Logger`, never to the
23
+ * detail alone.
24
+ *
25
+ * The gateway load separates a permanent condition from a transient one. An absent
26
+ * thread and a wrong thread type are permanent, and a store or pin fault is
27
+ * transient. The persist is a compare-and-swap against the prior document that the
28
+ * load read, thus two concurrent turns cannot both land.
29
+ */
30
+ import { createNoopLogger } from "../lib/console-logger.js";
31
+ import { describeDbError } from "../lib/db-result.js";
32
+ import { createThreadStore } from "../memory/thread-store.js";
33
+ import { pinReportSnapshot } from "../report-model/pin-snapshot.js";
34
+ import { createReportSessionStateStore } from "../state/report-session-state.js";
35
+ /** The empty draft of a fresh session. The pin writes the snapshot first, thus the document is empty here. */
36
+ const EMPTY_DRAFT = { title: "", sections: [] };
37
+ /**
38
+ * Build the report session-state runtime bound to a Postgres pool. The factory
39
+ * closure captures the pool and constructs the session-state store and the thread
40
+ * store from it, the same way the chat-turn preparation does.
41
+ */
42
+ export function createReportSessionRuntime(deps) {
43
+ const { pool } = deps;
44
+ const log = (deps.logger ?? createNoopLogger()).named("report-session-runtime");
45
+ const store = createReportSessionStateStore({ pool, logger: deps.logger });
46
+ const threads = createThreadStore(pool);
47
+ /**
48
+ * A store read or write fault, as a failed outcome. `corrupt_session_state`
49
+ * names the part that cannot parse. A `DbError` carries its cause to the log.
50
+ * The identifiers ride as structured fields, never in the message.
51
+ */
52
+ function storeFault(threadId, error) {
53
+ if (error.type === "corrupt_session_state") {
54
+ log.error("the stored session state cannot parse", { threadId, part: error.part });
55
+ return { outcome: "failed", kind: "unavailable", detail: `the stored ${error.part} of the session state cannot parse` };
56
+ }
57
+ log.error("the session-state store failed", { threadId, ...log.errorFields(error.cause) });
58
+ return { outcome: "failed", kind: "unavailable", detail: describeDbError(error) };
59
+ }
60
+ /**
61
+ * Pin the snapshot and write the row. A pin failure writes no row, thus a
62
+ * later run pins again. The write is insert-if-absent, thus two concurrent
63
+ * first runs make one row and both read the winner back.
64
+ */
65
+ async function pinAndWrite(threadId, analysisId) {
66
+ const pinned = await pinReportSnapshot(pool, analysisId);
67
+ if (pinned.isErr()) {
68
+ log.error("the snapshot pin failed", { threadId, analysisId, ...log.errorFields(pinned.error.cause) });
69
+ return { outcome: "failed", kind: "unavailable", detail: "the artifact ledger read failed" };
70
+ }
71
+ const written = await store.writeSnapshot({ threadId, analysisId, snapshot: pinned.value });
72
+ return written.match((row) => ({ outcome: "ready", state: row }), (error) => storeFault(threadId, error));
73
+ }
74
+ async function ensureSessionState(threadId) {
75
+ const read = await store.readState(threadId);
76
+ if (read.isErr()) {
77
+ return storeFault(threadId, read.error);
78
+ }
79
+ if (read.value !== null) {
80
+ // The row exists, thus the anchor holds and the pin stays single.
81
+ return { outcome: "ready", state: read.value };
82
+ }
83
+ const thread = await threads.getThread(threadId);
84
+ if (thread.isErr()) {
85
+ log.error("the thread read failed", { threadId, ...log.errorFields(thread.error.cause) });
86
+ return { outcome: "failed", kind: "unavailable", detail: describeDbError(thread.error) };
87
+ }
88
+ if (thread.value === null) {
89
+ // The pin needs the analysis of the thread. An absent thread names none, and
90
+ // the absence is a permanent condition.
91
+ log.error("the thread resolves to no analysis", { threadId });
92
+ return { outcome: "failed", kind: "absent-thread", detail: "the thread names no analysis" };
93
+ }
94
+ if (thread.value.threadType !== "report") {
95
+ // The session anchors a report thread only. A conversation thread carries no report session,
96
+ // thus a wrong type writes no row and the detail names the type. The wrong type is permanent.
97
+ log.error("the thread is not a report thread", { threadId, threadType: thread.value.threadType });
98
+ return { outcome: "failed", kind: "wrong-thread-type", detail: `the thread is a ${thread.value.threadType} thread, not a report thread` };
99
+ }
100
+ return pinAndWrite(threadId, thread.value.analysisId);
101
+ }
102
+ /**
103
+ * Map the durable row onto the gateway load. A fresh row holds the snapshot and
104
+ * no document, thus the load gives the empty draft. The found load carries the
105
+ * stored analysis and the prior document as the concurrency token. A row with no
106
+ * snapshot cannot serve a tool, because the pin writes the snapshot first.
107
+ */
108
+ function toLoad(state) {
109
+ if (state.snapshot === null) {
110
+ log.error("the session-state row holds no snapshot", { threadId: state.threadId });
111
+ return { outcome: "failed", detail: "the session state holds no snapshot" };
112
+ }
113
+ return {
114
+ outcome: "found",
115
+ state: { document: state.document ?? EMPTY_DRAFT, snapshot: state.snapshot },
116
+ analysisId: state.analysisId,
117
+ token: state.document,
118
+ seenDocumentHash: state.seenDocumentHash,
119
+ };
120
+ }
121
+ const gateway = {
122
+ async load(threadId) {
123
+ const ensured = await ensureSessionState(threadId);
124
+ if (ensured.outcome === "failed") {
125
+ switch (ensured.kind) {
126
+ case "absent-thread":
127
+ return { outcome: "absent" };
128
+ case "wrong-thread-type":
129
+ return { outcome: "wrong-type", detail: ensured.detail };
130
+ case "unavailable":
131
+ return { outcome: "failed", detail: ensured.detail };
132
+ }
133
+ }
134
+ return toLoad(ensured.state);
135
+ },
136
+ async persist(threadId, document, expected) {
137
+ const result = await store.persistDocument({ threadId, document, expected });
138
+ return result.match((outcome) => {
139
+ switch (outcome) {
140
+ case "persisted":
141
+ return { outcome: "persisted" };
142
+ case "conflict":
143
+ // A concurrent turn landed first. The turn refuses and reads the state again.
144
+ return { outcome: "conflict" };
145
+ case "absent":
146
+ // The pin writes the row first. A persist that finds no row means the
147
+ // pin-first invariant broke.
148
+ log.error("the session-state persist matched no row", { threadId });
149
+ return { outcome: "failed", detail: "no report session state row exists to persist the document" };
150
+ }
151
+ }, (error) => {
152
+ log.error("the session-state persist failed", { threadId, ...log.errorFields(error.cause) });
153
+ return { outcome: "failed", detail: describeDbError(error) };
154
+ });
155
+ },
156
+ async stampRendered(threadId, hash) {
157
+ const result = await store.stampRendered(threadId, hash);
158
+ return result.match((outcome) => (outcome === "stamped" ? { outcome: "stamped" } : { outcome: "absent" }), (error) => {
159
+ log.error("the rendered-hash stamp failed", { threadId, ...log.errorFields(error.cause) });
160
+ return { outcome: "failed", detail: describeDbError(error) };
161
+ });
162
+ },
163
+ async stampSeen(threadId) {
164
+ const result = await store.stampSeen(threadId);
165
+ return result.match((outcome) => {
166
+ switch (outcome) {
167
+ case "stamped":
168
+ return { outcome: "stamped" };
169
+ case "no-rendered":
170
+ // The row holds no rendered hash, thus no preview stamped one and the copy found
171
+ // none. The eyes direct a new preview from this arm.
172
+ return { outcome: "no-rendered" };
173
+ case "absent":
174
+ return { outcome: "absent" };
175
+ }
176
+ }, (error) => {
177
+ log.error("the seen-hash stamp failed", { threadId, ...log.errorFields(error.cause) });
178
+ return { outcome: "failed", detail: describeDbError(error) };
179
+ });
180
+ },
181
+ };
182
+ return { gateway, ensureSessionState };
183
+ }
@@ -18,10 +18,19 @@
18
18
  * A conversation thread id comes from the host UI. But the spawn is a harness
19
19
  * operation with no UI in front of it. Thus it mints a `randomUUID` and returns
20
20
  * the full row. A managed deployment gets the same behavior with no host code.
21
+ *
22
+ * The spawn also gates on the eyes of the composition. A report session records
23
+ * a version only after a look at the rendered page, and only a browser can look.
24
+ * Thus a composition with no browser and no injected capture seam can author a
25
+ * draft that no gate ever accepts. The spawn refuses that session before it
26
+ * writes a row, because a dead-end thread is worse than a refusal that names the
27
+ * absent capability.
21
28
  */
22
29
  import { type ResultAsync } from "neverthrow";
23
30
  import type { Pool } from "pg";
24
31
  import type { DbError } from "../lib/db-result.js";
32
+ import { type ChromeConfig } from "../lib/chrome.js";
33
+ import type { CapturePage } from "../lib/page-capture.js";
25
34
  import { type Thread, type ThreadInputError, type ThreadPage, type ThreadType } from "../memory/thread-store.js";
26
35
  /**
27
36
  * A spawn the operation refuses before it writes a row. Distinct from
@@ -34,9 +43,15 @@ import { type Thread, type ThreadInputError, type ThreadPage, type ThreadType }
34
43
  * state is not permitted. `parent_not_a_conversation` keeps the tree flat: a
35
44
  * report session cannot spawn another. `empty_parent_transcript` refuses a
36
45
  * report on a parent that holds no messages, because such a report reports
37
- * nothing.
46
+ * nothing. `no_browser` refuses every spawn under a composition that gives no
47
+ * eyes, and it carries the line that explains the absent capability.
38
48
  */
39
49
  export type SpawnRefusal = {
50
+ readonly type: "no_browser";
51
+ readonly op: string;
52
+ readonly parentThreadId: string;
53
+ readonly detail: string;
54
+ } | {
40
55
  readonly type: "parent_not_found";
41
56
  readonly op: string;
42
57
  readonly parentThreadId: string;
@@ -57,6 +72,18 @@ export interface ReportSessionPaging {
57
72
  }
58
73
  export interface ReportSessionSpawnDeps {
59
74
  readonly pool: Pool;
75
+ /**
76
+ * The chrome config that the same composition gives to the eyes tool. The
77
+ * spawn reads only whether it names a browser, thus the spawn and the tool
78
+ * decide the availability of the eyes from one value.
79
+ */
80
+ readonly chrome: ChromeConfig;
81
+ /**
82
+ * The capture seam that the same composition gives to the eyes tool. A
83
+ * present seam is a route to a look with no browser endpoint, thus it
84
+ * satisfies the gate on its own.
85
+ */
86
+ readonly capture?: CapturePage;
60
87
  }
61
88
  export interface ReportSessionSpawn {
62
89
  /**
@@ -64,10 +91,10 @@ export interface ReportSessionSpawn {
64
91
  * The child takes the analysis of the parent, the parent thread id, and the
65
92
  * anchor — the parent's latest `messages.seq` at this moment.
66
93
  *
67
- * Refused with a `SpawnRefusal`, no row written: an absent or archived
68
- * parent, a parent that is not a conversation, and a parent with no
69
- * messages. A store refusal (`DbError`, `ThreadInputError`) passes through
70
- * unchanged.
94
+ * Refused with a `SpawnRefusal`, no row written: a composition with no
95
+ * eyes, an absent or archived parent, a parent that is not a conversation,
96
+ * and a parent with no messages. A store refusal (`DbError`,
97
+ * `ThreadInputError`) passes through unchanged.
71
98
  */
72
99
  spawnReportSession(parentThreadId: string): ResultAsync<Thread, SpawnRefusal | DbError | ThreadInputError>;
73
100
  /**
@@ -18,13 +18,23 @@
18
18
  * A conversation thread id comes from the host UI. But the spawn is a harness
19
19
  * operation with no UI in front of it. Thus it mints a `randomUUID` and returns
20
20
  * the full row. A managed deployment gets the same behavior with no host code.
21
+ *
22
+ * The spawn also gates on the eyes of the composition. A report session records
23
+ * a version only after a look at the rendered page, and only a browser can look.
24
+ * Thus a composition with no browser and no injected capture seam can author a
25
+ * draft that no gate ever accepts. The spawn refuses that session before it
26
+ * writes a row, because a dead-end thread is worse than a refusal that names the
27
+ * absent capability.
21
28
  */
22
29
  import { randomUUID } from "node:crypto";
23
30
  import { errAsync } from "neverthrow";
31
+ import { hasBrowserUrl } from "../lib/chrome.js";
24
32
  import { createThreadHistory } from "../memory/thread-history.js";
25
33
  import { createThreadStore } from "../memory/thread-store.js";
26
34
  const _assertDomainError = true;
27
35
  const OP = "spawn-report-session";
36
+ /** The line that the caller reads when the composition gives no eyes. */
37
+ const NO_BROWSER_DETAIL = "the composition gives no browser, thus a report session can never record a version";
28
38
  /**
29
39
  * Compose the child title. N counts the existing report children of the parent
30
40
  * plus one. A parent with no title yields `Report N` alone, because the seed of
@@ -43,7 +53,16 @@ export function createReportSessionSpawn(deps) {
43
53
  const { pool } = deps;
44
54
  const store = createThreadStore(pool);
45
55
  const history = createThreadHistory(pool);
56
+ // The eyes of the composition are fixed at construction, thus the gate reads
57
+ // one boolean and never a live probe of the sidecar.
58
+ const eyesAvailable = deps.capture !== undefined || hasBrowserUrl(deps.chrome);
46
59
  function spawnReportSession(parentThreadId) {
60
+ // The gate runs before the parent read. A session with no route to a look
61
+ // reaches the record gate and refuses there forever, thus the spawn is the
62
+ // one honest place to say so.
63
+ if (!eyesAvailable) {
64
+ return errAsync({ type: "no_browser", op: OP, parentThreadId, detail: NO_BROWSER_DETAIL });
65
+ }
47
66
  return store.getThread(parentThreadId).andThen((parent) => {
48
67
  // An absent parent and an archived one arrive the same way — `getThread`
49
68
  // filters the tombstone — and both refuse as `parent_not_found`.
@@ -18,17 +18,22 @@
18
18
  * one. Neither store is ever written through these paths.
19
19
  *
20
20
  * Both are optional, and absence is a NORMAL state rather than an error: an
21
- * omitted path makes its tool report the store as unavailable or unknown, which
22
- * is a truthful answer an agent can act on. Absence must never be reported as a
23
- * failure, and never papered over with a guessed path.
21
+ * omitted path falls back to the store's container mountpoint — correct for a
22
+ * host whose own process sees the store the way a sandbox does, and harmless for
23
+ * one that does not, because every consumer stats the root before it reports
24
+ * anything. A host with nothing mounted there gets the same "unavailable" or
25
+ * "unknown" answer an omitted path has always produced, so the fallback can no
26
+ * more invent a store than an explicit path can. Set a path only to name a
27
+ * location the container mountpoint does not describe; absence must never be
28
+ * reported as a failure.
24
29
  */
25
30
  export interface EnvironmentStorePaths {
26
31
  /**
27
32
  * Host path of the reference store — the same bytes sandboxes mount at
28
- * `/mnt/refs`. Omit when no store is provisioned; reference discovery then
29
- * reports the store as unavailable. Note that an installed store reads as
30
- * absent if this is omitted, which is indistinguishable to an agent from
31
- * having no data — so omit it only when there is genuinely nothing staged.
33
+ * `/mnt/refs`. Omit when the host's own process sees the store at that same
34
+ * path, as a K8s pod holding the ref-store PVC does; set it when the host
35
+ * reads the store somewhere else, as a native process bind-mounting a host
36
+ * directory into Docker does.
32
37
  */
33
38
  readonly refStorePath?: string;
34
39
  /**
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Resource limits — ported from orchestrator/src/config/resource-limits.ts.
2
+ * Resource limits — validates sandbox resource requests against cluster
3
+ * maximums.
3
4
  *
4
- * Validates sandbox resource requests against cluster maximums.
5
- * Used by K8sSandbox.
5
+ * The ceilings themselves are the embedder's: they arrive as
6
+ * `SandboxClientConfig.resourceLimits` and as the `resourcePolicy` this module
7
+ * validates. Nothing here reads the environment, so a host keeps one validated
8
+ * view of its own configuration rather than two that can disagree.
6
9
  */
7
10
  import { z } from "zod";
8
11
  export declare const GpuSpecSchema: z.ZodObject<{
@@ -47,13 +50,6 @@ export declare const DEFAULT_SANDBOX_MAX_STEPS = 75;
47
50
  export declare class ResourceLimitsConfigError extends Error {
48
51
  constructor(message: string);
49
52
  }
50
- /**
51
- * Load resource limits from environment variables.
52
- *
53
- * Required: SANDBOX_MAX_CPU, SANDBOX_MAX_MEMORY_GB, SANDBOX_MAX_GPU_COUNT
54
- * @throws ResourceLimitsConfigError if configuration is invalid
55
- */
56
- export declare function loadResourceLimits(): ResourceLimits;
57
53
  /**
58
54
  * Validate an embedder-supplied resource policy.
59
55
  *
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Resource limits — ported from orchestrator/src/config/resource-limits.ts.
2
+ * Resource limits — validates sandbox resource requests against cluster
3
+ * maximums.
3
4
  *
4
- * Validates sandbox resource requests against cluster maximums.
5
- * Used by K8sSandbox.
5
+ * The ceilings themselves are the embedder's: they arrive as
6
+ * `SandboxClientConfig.resourceLimits` and as the `resourcePolicy` this module
7
+ * validates. Nothing here reads the environment, so a host keeps one validated
8
+ * view of its own configuration rather than two that can disagree.
6
9
  */
7
10
  import { z } from "zod";
8
11
  // ── Schemas ─────────────────────────────────────────────────────────
@@ -38,47 +41,6 @@ export class ResourceLimitsConfigError extends Error {
38
41
  this.name = "ResourceLimitsConfigError";
39
42
  }
40
43
  }
41
- // ── Parsing ─────────────────────────────────────────────────────────
42
- function parsePositiveNumber(name, value) {
43
- if (!value) {
44
- throw new ResourceLimitsConfigError(`${name} environment variable is required`);
45
- }
46
- const num = Number(value);
47
- if (isNaN(num) || num <= 0) {
48
- throw new ResourceLimitsConfigError(`${name} must be a positive number, got: ${value}`);
49
- }
50
- return num;
51
- }
52
- function parseNonNegativeInt(name, value) {
53
- if (!value) {
54
- throw new ResourceLimitsConfigError(`${name} environment variable is required`);
55
- }
56
- const num = Number(value);
57
- if (isNaN(num) || num < 0) {
58
- throw new ResourceLimitsConfigError(`${name} must be a non-negative number, got: ${value}`);
59
- }
60
- if (!Number.isInteger(num)) {
61
- throw new ResourceLimitsConfigError(`${name} must be an integer, got: ${value}`);
62
- }
63
- return num;
64
- }
65
- /**
66
- * Load resource limits from environment variables.
67
- *
68
- * Required: SANDBOX_MAX_CPU, SANDBOX_MAX_MEMORY_GB, SANDBOX_MAX_GPU_COUNT
69
- * @throws ResourceLimitsConfigError if configuration is invalid
70
- */
71
- export function loadResourceLimits() {
72
- const maxCpu = parsePositiveNumber("SANDBOX_MAX_CPU", process.env.SANDBOX_MAX_CPU);
73
- const maxMemoryGb = parsePositiveNumber("SANDBOX_MAX_MEMORY_GB", process.env.SANDBOX_MAX_MEMORY_GB);
74
- const maxGpuCount = parseNonNegativeInt("SANDBOX_MAX_GPU_COUNT", process.env.SANDBOX_MAX_GPU_COUNT);
75
- const limits = { maxCpu, maxMemoryGb, maxGpuCount };
76
- const result = ResourceLimitsSchema.safeParse(limits);
77
- if (!result.success) {
78
- throw new ResourceLimitsConfigError(`Invalid resource limits configuration: ${result.error.message}`);
79
- }
80
- return result.data;
81
- }
82
44
  /**
83
45
  * Validate an embedder-supplied resource policy.
84
46
  *