@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.
- package/dist/agents/report-session-agent.d.ts +32 -9
- package/dist/agents/report-session-agent.js +22 -6
- package/dist/app/report-session-runtime.js +26 -0
- package/dist/app/spawn-report-session.d.ts +32 -5
- package/dist/app/spawn-report-session.js +19 -0
- package/dist/auth/local-run-authorizer.d.ts +3 -2
- package/dist/auth/local-run-authorizer.js +4 -2
- package/dist/config/environment-stores.d.ts +12 -7
- package/dist/config/resource-limits.d.ts +6 -10
- package/dist/config/resource-limits.js +6 -44
- package/dist/contracts/report-reference.d.ts +4 -0
- package/dist/contracts/report-reference.js +3 -0
- package/dist/execution/run-authorizer.d.ts +18 -0
- package/dist/execution/run-canceler.d.ts +67 -0
- package/dist/execution/run-canceler.js +130 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -1
- package/dist/lib/chrome.d.ts +14 -0
- package/dist/lib/chrome.js +7 -0
- package/dist/lib/page-capture.d.ts +47 -0
- package/dist/lib/page-capture.js +83 -0
- package/dist/loop/run-agent.js +104 -15
- package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
- package/dist/memory/ai-sdk-message-storage.js +9 -4
- package/dist/memory/count-tokens.js +34 -5
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +21 -0
- package/dist/providers/ai-sdk.d.ts +81 -3
- package/dist/providers/ai-sdk.js +91 -10
- package/dist/providers/types.d.ts +17 -0
- package/dist/report-model/assert-rules.d.ts +44 -0
- package/dist/report-model/assert-rules.js +97 -0
- package/dist/report-model/draft-hash.d.ts +14 -0
- package/dist/report-model/draft-hash.js +36 -0
- package/dist/report-model/fixture-resolver.js +2 -71
- package/dist/report-model/production-resolver.d.ts +124 -0
- package/dist/report-model/production-resolver.js +801 -0
- package/dist/report-model/reference-resolver.d.ts +6 -0
- package/dist/report-model/validate.js +5 -0
- package/dist/report-render/assets.d.ts +42 -0
- package/dist/report-render/assets.js +61 -0
- package/dist/report-render/chart.js +31 -10
- package/dist/report-render/design.d.ts +120 -0
- package/dist/report-render/design.js +808 -0
- package/dist/report-render/page.d.ts +51 -111
- package/dist/report-render/page.js +158 -268
- package/dist/report-render/render.js +34 -11
- package/dist/report-render/views/chart-view.d.ts +6 -3
- package/dist/report-render/views/chart-view.js +10 -5
- package/dist/report-render/views/page-view.d.ts +19 -8
- package/dist/report-render/views/page-view.js +31 -11
- package/dist/report-render/views/prose.d.ts +1 -0
- package/dist/report-render/views/prose.js +7 -13
- package/dist/report-render/views/references-view.js +5 -5
- package/dist/report-render/views/values.d.ts +15 -6
- package/dist/report-render/views/values.js +29 -10
- package/dist/runtime/assemble.d.ts +38 -4
- package/dist/runtime/assemble.js +43 -3
- package/dist/sandbox/k8s-client.js +18 -0
- package/dist/sandbox/mount-plan.d.ts +7 -0
- package/dist/sandbox/mount-plan.js +7 -2
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/state/analyses.d.ts +3 -26
- package/dist/state/analyses.js +6 -64
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +2 -2
- package/dist/state/init.js +23 -10
- package/dist/state/report-session-state.d.ts +27 -0
- package/dist/state/report-session-state.js +29 -2
- package/dist/state/runs.d.ts +10 -0
- package/dist/state/runs.js +21 -0
- package/dist/tasks/data-profile.d.ts +4 -0
- package/dist/tasks/data-profile.js +14 -0
- package/dist/tasks/extract-values-script.d.ts +64 -0
- package/dist/tasks/extract-values-script.js +180 -0
- package/dist/tasks/extract-values.d.ts +124 -0
- package/dist/tasks/extract-values.js +193 -0
- package/dist/tools/approval/gateway.d.ts +8 -1
- package/dist/tools/approval/gateway.js +9 -2
- package/dist/tools/approval/queries.d.ts +9 -2
- package/dist/tools/approval/queries.js +12 -4
- package/dist/tools/define-tool.d.ts +29 -0
- package/dist/tools/define-tool.js +26 -0
- package/dist/tools/report/preview-snapshot.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +11 -50
- package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
- package/dist/tools/report-authoring/authoring-tools.js +1 -1
- package/dist/tools/report-session/examine-page.d.ts +90 -0
- package/dist/tools/report-session/examine-page.js +136 -0
- package/dist/tools/report-session/index.d.ts +3 -1
- package/dist/tools/report-session/index.js +3 -1
- package/dist/tools/report-session/preview-report.d.ts +31 -6
- package/dist/tools/report-session/preview-report.js +63 -16
- package/dist/tools/report-session/record-version.d.ts +102 -0
- package/dist/tools/report-session/record-version.js +139 -0
- package/dist/tools/sandbox/list-available-packages.js +2 -1
- package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
- package/dist/tools/sandbox/list-available-refs.js +13 -6
- package/dist/workflows/sandbox-step.d.ts +4 -0
- package/dist/workflows/sandbox-step.js +21 -2
- package/package.json +14 -8
|
@@ -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
|
|
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,23 +15,29 @@
|
|
|
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
|
|
19
|
-
* workspace root,
|
|
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 { err, ok } from "neverthrow";
|
|
25
28
|
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
|
29
|
+
import { createRequire } from "node:module";
|
|
26
30
|
import { extname, join } from "node:path";
|
|
27
31
|
import { z } from "zod";
|
|
28
32
|
import { resolveWorkspacePath } from "../../workspace/paths.js";
|
|
29
33
|
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
30
34
|
import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
|
|
31
35
|
import { defaultErrorFields } from "../../lib/logger.js";
|
|
36
|
+
import { computeDraftHash } from "../../report-model/draft-hash.js";
|
|
32
37
|
import { finishDraft } from "../../report-model/draft-finish.js";
|
|
33
38
|
import { resolveDocumentReferences } from "../../report-model/validate.js";
|
|
34
39
|
import { bridgeValues } from "../../report-render/value-bridge.js";
|
|
40
|
+
import { PAGE_ASSETS } from "../../report-render/assets.js";
|
|
35
41
|
import { renderReportPage } from "../../report-render/render.js";
|
|
36
42
|
import { defineTool } from "../define-tool.js";
|
|
37
43
|
import { openReportThread } from "../report-authoring/authoring-tools.js";
|
|
@@ -52,6 +58,13 @@ function assetFileName(file) {
|
|
|
52
58
|
function figureSourcePolicy(file) {
|
|
53
59
|
return `assets/${assetFileName(file)}`;
|
|
54
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The resolver of a package source. A manifest entry names a module specifier, and the resolution reads
|
|
63
|
+
* the installation of the harness. Thus the staged bytes are the bytes of the pinned version.
|
|
64
|
+
*/
|
|
65
|
+
const moduleResolver = createRequire(import.meta.url);
|
|
66
|
+
/** The default asset lookup: the module resolution of the installation of the harness. */
|
|
67
|
+
const resolvePageAssetFromInstallation = (specifier) => moduleResolver.resolve(specifier);
|
|
55
68
|
/**
|
|
56
69
|
* Pair each block with the resolved value of its binding, in document order.
|
|
57
70
|
*
|
|
@@ -112,7 +125,7 @@ async function resolveDocument(document, resolver, snapshot) {
|
|
|
112
125
|
return { resolutions: collectResolutions(document.sections, resolvedByBlock), unresolved: [] };
|
|
113
126
|
}
|
|
114
127
|
/**
|
|
115
|
-
* Resolve the page root, stage each bound image, and write the page.
|
|
128
|
+
* Resolve the page root, stage each bound image and each manifest entry, and write the page.
|
|
116
129
|
*
|
|
117
130
|
* The workspace-root seam signals an unresolvable resource by a throw, thus the resolution sits inside
|
|
118
131
|
* the protection and its throw becomes a value. Each `fs` call runs through `tryFsWrite`, the one
|
|
@@ -122,6 +135,10 @@ async function resolveDocument(document, resolver, snapshot) {
|
|
|
122
135
|
* A bound figure names a snapshot path, which is untrusted. A registered `../../` path escapes the root,
|
|
123
136
|
* thus the containment test runs before any copy and reuses the one workspace-path resolver. A source
|
|
124
137
|
* outside the root refuses and names the block, and no copy runs.
|
|
138
|
+
*
|
|
139
|
+
* The page references the chart runtime and the fonts under the same `assets/` directory, thus one copy
|
|
140
|
+
* loop stages the figures and the manifest entries together. The manifest is never empty, thus the assets
|
|
141
|
+
* directory exists beside every page.
|
|
125
142
|
*/
|
|
126
143
|
async function renderToWorkspace(args) {
|
|
127
144
|
let root;
|
|
@@ -151,20 +168,28 @@ async function renderToWorkspace(args) {
|
|
|
151
168
|
}
|
|
152
169
|
sources.set(name, resolved.absolute);
|
|
153
170
|
}
|
|
171
|
+
// Each manifest entry, mapped from its staged name to the file that the asset lookup gives. A specifier
|
|
172
|
+
// that does not resolve is a fault of the installation, thus it rides the `fs` kind.
|
|
173
|
+
for (const asset of PAGE_ASSETS) {
|
|
174
|
+
try {
|
|
175
|
+
sources.set(asset.file, args.resolvePageAsset(asset.specifier));
|
|
176
|
+
}
|
|
177
|
+
catch (cause) {
|
|
178
|
+
return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveAsset", path: asset.specifier, cause } });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
154
181
|
const madeSession = await tryFsWrite("preview.mkdir", () => mkdir(sessionDir, { recursive: true }), { path: sessionDir });
|
|
155
182
|
if (madeSession.isErr()) {
|
|
156
183
|
return err({ kind: "fs", error: madeSession.error });
|
|
157
184
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return err({ kind: "fs", error: copied.error });
|
|
167
|
-
}
|
|
185
|
+
const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(assetsDir, { recursive: true }), { path: assetsDir });
|
|
186
|
+
if (madeAssets.isErr()) {
|
|
187
|
+
return err({ kind: "fs", error: madeAssets.error });
|
|
188
|
+
}
|
|
189
|
+
for (const [name, source] of sources) {
|
|
190
|
+
const copied = await tryFsWrite("preview.copyFile", () => copyFile(source, join(assetsDir, name)), { path: source });
|
|
191
|
+
if (copied.isErr()) {
|
|
192
|
+
return err({ kind: "fs", error: copied.error });
|
|
168
193
|
}
|
|
169
194
|
}
|
|
170
195
|
const wrote = await tryFsWrite("preview.writeFile", () => writeFile(pagePath, args.page, "utf8"), { path: pagePath });
|
|
@@ -182,6 +207,7 @@ async function renderToWorkspace(args) {
|
|
|
182
207
|
*/
|
|
183
208
|
export function createPreviewReportTool(deps) {
|
|
184
209
|
const logger = (deps.logger ?? createNoopLogger()).named("preview-report");
|
|
210
|
+
const resolvePageAsset = deps.resolvePageAsset ?? resolvePageAssetFromInstallation;
|
|
185
211
|
return defineTool({
|
|
186
212
|
id: "preview_report",
|
|
187
213
|
description: "Render the current draft to a self-contained HTML page, and give back the page path. " +
|
|
@@ -203,10 +229,21 @@ export function createPreviewReportTool(deps) {
|
|
|
203
229
|
return ok({ outcome: "gaps", gaps: finished.gaps });
|
|
204
230
|
}
|
|
205
231
|
const document = finished.document;
|
|
206
|
-
if (deps.
|
|
232
|
+
if (deps.makeResolver === undefined) {
|
|
207
233
|
return ok({ outcome: "resolver-unavailable" });
|
|
208
234
|
}
|
|
209
|
-
|
|
235
|
+
// The resolver construction resolves the workspace root inside, and that seam throws on an
|
|
236
|
+
// unresolvable root. The guard turns the throw into a typed outcome that names the fault, thus a
|
|
237
|
+
// control-flow exception propagates and the unresolvable root does not.
|
|
238
|
+
let resolver;
|
|
239
|
+
try {
|
|
240
|
+
resolver = deps.makeResolver({ analysisId, auth: ctx.session.auth });
|
|
241
|
+
}
|
|
242
|
+
catch (cause) {
|
|
243
|
+
logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
|
|
244
|
+
return ok({ outcome: "root-unresolvable", detail: "the workspace root did not resolve" });
|
|
245
|
+
}
|
|
246
|
+
const { resolutions, unresolved } = await resolveDocument(document, resolver, snapshot);
|
|
210
247
|
if (unresolved.length > 0) {
|
|
211
248
|
return ok({ outcome: "unresolved-references", unresolved });
|
|
212
249
|
}
|
|
@@ -220,6 +257,7 @@ export function createPreviewReportTool(deps) {
|
|
|
220
257
|
}
|
|
221
258
|
const written = await renderToWorkspace({
|
|
222
259
|
resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
|
|
260
|
+
resolvePageAsset,
|
|
223
261
|
analysisId,
|
|
224
262
|
threadId,
|
|
225
263
|
resolutions,
|
|
@@ -239,6 +277,15 @@ export function createPreviewReportTool(deps) {
|
|
|
239
277
|
logger.warn("the page did not land", { threadId, analysisId, ...defaultErrorFields(failure.error.cause) });
|
|
240
278
|
return ok({ outcome: "write-failed", detail: describeFsError(failure.error) });
|
|
241
279
|
}
|
|
280
|
+
// The page shows the draft that the finish read. The stamp records that draft, thus the eyes
|
|
281
|
+
// copy this hash and the record compares against it. A failed stamp is a transient store fault,
|
|
282
|
+
// and the page stays on disk. The agent runs the preview again to stamp the marker.
|
|
283
|
+
const stamped = await deps.gateway.stampRendered(threadId, computeDraftHash(draft));
|
|
284
|
+
if (stamped.outcome !== "stamped") {
|
|
285
|
+
const detail = stamped.outcome === "failed" ? stamped.detail : "the session state row is absent";
|
|
286
|
+
logger.warn("the rendered hash did not stamp", { threadId, analysisId, detail });
|
|
287
|
+
return ok({ outcome: "stamp-failed", pagePath: written.value, detail });
|
|
288
|
+
}
|
|
242
289
|
return ok({ outcome: "rendered", pagePath: written.value });
|
|
243
290
|
},
|
|
244
291
|
});
|
|
@@ -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 {};
|
|
@@ -0,0 +1,139 @@
|
|
|
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 { ok } from "neverthrow";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
27
|
+
import { describeDbError } from "../../lib/db-result.js";
|
|
28
|
+
import { finishDraft } from "../../report-model/draft-finish.js";
|
|
29
|
+
import { computeDraftHash } from "../../report-model/draft-hash.js";
|
|
30
|
+
import { validateReport } from "../../report-model/validate.js";
|
|
31
|
+
import { defineTool } from "../define-tool.js";
|
|
32
|
+
import { openReportThread } from "../report-authoring/authoring-tools.js";
|
|
33
|
+
/** The empty input. The tool records the current draft of the thread, thus it needs no field. */
|
|
34
|
+
const recordVersionInput = z.object({});
|
|
35
|
+
/** A short account of a record refusal, for the detail of a transient failure. */
|
|
36
|
+
function describeRecordFailure(error) {
|
|
37
|
+
switch (error.type) {
|
|
38
|
+
case "malformed_document":
|
|
39
|
+
return "the gate accepted a document that the store read as malformed";
|
|
40
|
+
case "malformed_snapshot":
|
|
41
|
+
return "the pinned snapshot is malformed";
|
|
42
|
+
case "parent_analysis_mismatch":
|
|
43
|
+
return "the parent version belongs to a different analysis";
|
|
44
|
+
case "thread_already_holds_version":
|
|
45
|
+
return "the thread already holds a recorded version";
|
|
46
|
+
default:
|
|
47
|
+
return describeDbError(error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Make the record tool over the session-state gateway, the version store, the thread store, and the
|
|
52
|
+
* resolver.
|
|
53
|
+
*
|
|
54
|
+
* The tool reads the thread id from the scope of the call, and it loads the state through the gateway. Thus
|
|
55
|
+
* one factory serves every thread. The tool holds no per-session value.
|
|
56
|
+
*/
|
|
57
|
+
export function createRecordVersionTool(deps) {
|
|
58
|
+
const logger = (deps.logger ?? createNoopLogger()).named("record-report-version");
|
|
59
|
+
return defineTool({
|
|
60
|
+
id: "record_report_version",
|
|
61
|
+
description: "Record the current draft as one report version. The tool runs the whole gate first: it finishes the draft, " +
|
|
62
|
+
"resolves each reference, matches each chart encoding, and matches each assert. An incomplete draft gives back the gap list, " +
|
|
63
|
+
"and a failed reference gives back the block that broke. The tool records a version only after the eyes look at the current page. " +
|
|
64
|
+
"A thread holds one version, thus a second record gives back that the thread already holds one.",
|
|
65
|
+
inputSchema: recordVersionInput,
|
|
66
|
+
executionMode: "inline",
|
|
67
|
+
describeCall: "none",
|
|
68
|
+
execute: async (_input, ctx) => {
|
|
69
|
+
const opened = await openReportThread(deps.gateway, ctx.session.scope);
|
|
70
|
+
if (opened.isErr()) {
|
|
71
|
+
return ok({ outcome: "refused", refusal: opened.error });
|
|
72
|
+
}
|
|
73
|
+
const { threadId, analysisId, state, seenDocumentHash } = opened.value;
|
|
74
|
+
const { document: draft, snapshot } = state;
|
|
75
|
+
const finished = finishDraft(draft, snapshot);
|
|
76
|
+
if (!finished.valid) {
|
|
77
|
+
return ok({ outcome: "gaps", gaps: finished.gaps });
|
|
78
|
+
}
|
|
79
|
+
// The look-before-record rule runs before the resolver and the validation, on the state that the
|
|
80
|
+
// load already read. The preview stamps the rendered hash, and the eyes copy it onto the seen
|
|
81
|
+
// hash. A never-seen page carries no seen hash. A stale look carries the hash of an earlier draft,
|
|
82
|
+
// thus a mismatch against the current draft refuses. Each refusal skips the expensive validation.
|
|
83
|
+
const currentHash = computeDraftHash(draft);
|
|
84
|
+
if (seenDocumentHash === null) {
|
|
85
|
+
return ok({ outcome: "never-seen" });
|
|
86
|
+
}
|
|
87
|
+
if (seenDocumentHash !== currentHash) {
|
|
88
|
+
return ok({ outcome: "stale-look" });
|
|
89
|
+
}
|
|
90
|
+
if (deps.makeResolver === undefined) {
|
|
91
|
+
return ok({ outcome: "resolver-unavailable" });
|
|
92
|
+
}
|
|
93
|
+
// The resolver construction resolves the workspace root inside, and that seam throws on an
|
|
94
|
+
// unresolvable root. The guard turns the throw into a typed outcome that names the fault, thus a
|
|
95
|
+
// control-flow exception propagates and the unresolvable root does not.
|
|
96
|
+
let resolver;
|
|
97
|
+
try {
|
|
98
|
+
resolver = deps.makeResolver({ analysisId, auth: ctx.session.auth });
|
|
99
|
+
}
|
|
100
|
+
catch (cause) {
|
|
101
|
+
logger.warn("the workspace root did not resolve", { threadId, analysisId, ...logger.errorFields(cause) });
|
|
102
|
+
return ok({ outcome: "root-unresolvable", detail: "the workspace root did not resolve" });
|
|
103
|
+
}
|
|
104
|
+
const validation = await validateReport(finished.document, snapshot, resolver);
|
|
105
|
+
if (!validation.valid) {
|
|
106
|
+
return ok({
|
|
107
|
+
outcome: "invalid",
|
|
108
|
+
...(validation.schemaIssues ? { schemaIssues: validation.schemaIssues } : {}),
|
|
109
|
+
...(validation.duplicateIds ? { duplicateIds: validation.duplicateIds } : {}),
|
|
110
|
+
...(validation.resolutionFailures ? { resolutionFailures: validation.resolutionFailures } : {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const thread = await deps.threads.getThread(threadId);
|
|
114
|
+
if (thread.isErr()) {
|
|
115
|
+
logger.warn("the thread row read failed", { threadId, analysisId, ...logger.errorFields(thread.error.cause) });
|
|
116
|
+
return ok({ outcome: "record-failed", detail: describeDbError(thread.error) });
|
|
117
|
+
}
|
|
118
|
+
if (thread.value === null) {
|
|
119
|
+
logger.warn("the report thread row is absent at record", { threadId, analysisId });
|
|
120
|
+
return ok({ outcome: "record-failed", detail: "the report thread row is absent" });
|
|
121
|
+
}
|
|
122
|
+
const recorded = await deps.store.record({
|
|
123
|
+
document: finished.document,
|
|
124
|
+
snapshot,
|
|
125
|
+
analysisId,
|
|
126
|
+
threadId,
|
|
127
|
+
parentThreadId: thread.value.parentThreadId,
|
|
128
|
+
parentSeq: thread.value.parentSeq,
|
|
129
|
+
});
|
|
130
|
+
return recorded.match((ref) => ok({ outcome: "recorded", versionId: ref.versionId }), (error) => {
|
|
131
|
+
if (error.type === "thread_already_holds_version") {
|
|
132
|
+
return ok({ outcome: "already-recorded" });
|
|
133
|
+
}
|
|
134
|
+
logger.warn("the version did not record", { threadId, analysisId, reason: error.type });
|
|
135
|
+
return ok({ outcome: "record-failed", detail: describeRecordFailure(error) });
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -27,12 +27,13 @@ import { ok } from "neverthrow";
|
|
|
27
27
|
import { z } from "zod";
|
|
28
28
|
import { defineTool } from "../define-tool.js";
|
|
29
29
|
import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
|
|
30
|
+
import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
|
|
30
31
|
/**
|
|
31
32
|
* Where the list lives when the host mounts the library store at the same path
|
|
32
33
|
* the sandbox does. Hosts that do not — the store is baked into the image and
|
|
33
34
|
* never bind-mounted — inject their own path instead.
|
|
34
35
|
*/
|
|
35
|
-
const DEFAULT_PACKAGES_FILE =
|
|
36
|
+
const DEFAULT_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/current/packages.txt`;
|
|
36
37
|
/**
|
|
37
38
|
* Default cap on a listing — high enough that the real store is never truncated.
|
|
38
39
|
* The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* it at — read where it lives, render where the caller will open it. Reading it
|
|
15
15
|
* host-side is what lets a planner ask what reference data exists before any
|
|
16
16
|
* sandbox is created; a step agent asking mid-run sees the same store, because
|
|
17
|
-
* the mount and this path are the same bytes.
|
|
17
|
+
* the mount and this path are the same bytes. An omitted `refStorePath` falls
|
|
18
|
+
* back to that same container path — where a host that mounts the store into its
|
|
19
|
+
* own process (a K8s pod holding the ref-store PVC) already sees it. The fallback
|
|
20
|
+
* is stat-verified like any other root, so a host with nothing mounted there
|
|
21
|
+
* still reports the store as unavailable.
|
|
18
22
|
*
|
|
19
23
|
* The scan reports paths; meaning is joined on afterwards from the install
|
|
20
24
|
* receipt and the catalog it names (organism, format, and each file's internal
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* it at — read where it lives, render where the caller will open it. Reading it
|
|
15
15
|
* host-side is what lets a planner ask what reference data exists before any
|
|
16
16
|
* sandbox is created; a step agent asking mid-run sees the same store, because
|
|
17
|
-
* the mount and this path are the same bytes.
|
|
17
|
+
* the mount and this path are the same bytes. An omitted `refStorePath` falls
|
|
18
|
+
* back to that same container path — where a host that mounts the store into its
|
|
19
|
+
* own process (a K8s pod holding the ref-store PVC) already sees it. The fallback
|
|
20
|
+
* is stat-verified like any other root, so a host with nothing mounted there
|
|
21
|
+
* still reports the store as unavailable.
|
|
18
22
|
*
|
|
19
23
|
* The scan reports paths; meaning is joined on afterwards from the install
|
|
20
24
|
* receipt and the catalog it names (organism, format, and each file's internal
|
|
@@ -30,9 +34,13 @@ import { z } from "zod";
|
|
|
30
34
|
import { capCodePoints, DETAIL_MAX_LENGTH, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
|
|
31
35
|
import { REFERENCE_DATA_CATALOG } from "../../reference-data/catalog.js";
|
|
32
36
|
import { parseReferenceInstallReceipt } from "../../reference-data/receipt.js";
|
|
37
|
+
import { REFS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
|
|
33
38
|
import { defineTool } from "../define-tool.js";
|
|
34
|
-
/**
|
|
35
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Container path the ref store is mounted at: every reported path is rendered
|
|
41
|
+
* under it, and it is the read root when the embedder supplies none.
|
|
42
|
+
*/
|
|
43
|
+
const REFS_ROOT = REFS_CONTAINER_PATH;
|
|
36
44
|
const MAX_ENTRIES = 200;
|
|
37
45
|
const MAX_SCANNED_ENTRIES = 2_000;
|
|
38
46
|
const MAX_PATH_BYTES = 40_000;
|
|
@@ -283,8 +291,6 @@ async function collectFiles(directory, relative, budget) {
|
|
|
283
291
|
*/
|
|
284
292
|
async function scanStore(root, relative, recursive = false) {
|
|
285
293
|
const empty = () => ({ entries: [], scannedEntries: 0, truncated: false });
|
|
286
|
-
if (!root)
|
|
287
|
-
return { state: "unavailable", ...empty() };
|
|
288
294
|
try {
|
|
289
295
|
if (!(await lstat(root)).isDirectory())
|
|
290
296
|
return { state: "unavailable", ...empty() };
|
|
@@ -494,6 +500,7 @@ function renderContent(path, scan, entries) {
|
|
|
494
500
|
}
|
|
495
501
|
/** Create reference discovery over the host-visible reference store. */
|
|
496
502
|
export function createListAvailableRefsTool(deps) {
|
|
503
|
+
const storeRoot = deps.refStorePath ?? REFS_ROOT;
|
|
497
504
|
return defineTool({
|
|
498
505
|
id: "list_available_refs",
|
|
499
506
|
description: "Resolve reference data you need into a real path. Reports the reference data provisioned for this environment right now — the only reference data an analysis script can open. " +
|
|
@@ -570,7 +577,7 @@ export function createListAvailableRefsTool(deps) {
|
|
|
570
577
|
// recursive pass a search from the store root only ever sees directories,
|
|
571
578
|
// which carry no dataset labels, so "find me a regulon network" finds nothing.
|
|
572
579
|
const needleGiven = (query?.trim() ?? "") !== "";
|
|
573
|
-
const raw = await scanStore(
|
|
580
|
+
const raw = await scanStore(storeRoot, posixPath.relative(REFS_ROOT, requested.path) || ".", needleGiven);
|
|
574
581
|
const enriched = enrichEntries(raw.entries, raw);
|
|
575
582
|
const categories = collectCategories(enriched);
|
|
576
583
|
const needle = query?.trim().toLowerCase();
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import type { TokenUsageRollup } from "@inflexa-ai/harness/contracts/usage.js";
|
|
30
30
|
import type { Pool } from "pg";
|
|
31
31
|
import type { Logger } from "../lib/logger.js";
|
|
32
|
+
import type { ResolveBilling } from "../billing/resolver.js";
|
|
32
33
|
import type { UsageRecorder } from "../billing/usage-recorder.js";
|
|
33
34
|
import type { CitationResolver } from "../citations/types.js";
|
|
34
35
|
import type { AgentDefinition, LoopMessage } from "../loop/types.js";
|
|
@@ -232,6 +233,9 @@ export interface SandboxStepDeps {
|
|
|
232
233
|
/** Write-side embedder for the post-step vector index. */
|
|
233
234
|
readonly embedding: EmbeddingProvider;
|
|
234
235
|
readonly sandboxClient: SandboxClient;
|
|
236
|
+
/** Resolves the billing context stamped as the sandbox pod's OpenCost
|
|
237
|
+
* labels. Absent in upstream-less (dev/OSS) wiring — pods spawn unlabeled. */
|
|
238
|
+
readonly resolveBilling?: ResolveBilling;
|
|
235
239
|
/**
|
|
236
240
|
* External artifact registration + sync seam. The harness's post-step pipeline
|
|
237
241
|
* registers each step's outputs through it (filesystem index in the
|
|
@@ -46,6 +46,8 @@ import { createBlockerHolder } from "../tools/sandbox/report-blocker.js";
|
|
|
46
46
|
* declare it explicitly.
|
|
47
47
|
*/
|
|
48
48
|
export const DEFAULT_STEP_TIMEOUT_SECONDS = 3600;
|
|
49
|
+
/** Resolved-header key carrying the billing-context id (see `ResolveBilling`). */
|
|
50
|
+
const BILLING_CONTEXT_HEADER = "X-Inflexa-Billing-Context";
|
|
49
51
|
/**
|
|
50
52
|
* DBOS message topic the child uses to notify the parent that it is
|
|
51
53
|
* about to self-cancel because of a 402 budget-exceeded error. The
|
|
@@ -151,7 +153,9 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
151
153
|
const identity = await DBOS.runStep(() => Promise.resolve(mintSandboxIdentity(input.runId)), { name: "sandbox.mint" });
|
|
152
154
|
// (2b) sandbox.create — spawn (or adopt) the machine under the minted
|
|
153
155
|
// identity. The handle (secret included) is cached so recovery picks the
|
|
154
|
-
// same machine back up without re-provisioning.
|
|
156
|
+
// same machine back up without re-provisioning. Billing resolution lives
|
|
157
|
+
// INSIDE this step (not as its own step) so the child's step sequence is
|
|
158
|
+
// unchanged — in-flight workflows resumed across a deploy replay cleanly.
|
|
155
159
|
const sandboxMeta = {
|
|
156
160
|
runId: input.runId,
|
|
157
161
|
stepId: input.stepId,
|
|
@@ -161,7 +165,22 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
161
165
|
extraEnv: input.extraEnv,
|
|
162
166
|
resources: input.resources,
|
|
163
167
|
};
|
|
164
|
-
const sandbox = await DBOS.runStep(() =>
|
|
168
|
+
const sandbox = await DBOS.runStep(async () => {
|
|
169
|
+
let billing;
|
|
170
|
+
if (deps.resolveBilling) {
|
|
171
|
+
try {
|
|
172
|
+
const bcId = (await deps.resolveBilling(session))[BILLING_CONTEXT_HEADER];
|
|
173
|
+
if (bcId)
|
|
174
|
+
billing = { billingContextId: bcId, userId: input.runSession.identity.user };
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
logger.error("[billing] step billing resolution failed", { analysisId: input.analysisId, ...logger.errorFields(err) });
|
|
178
|
+
}
|
|
179
|
+
if (!billing)
|
|
180
|
+
logger.error("[billing] sandbox spawned without billing labels", { analysisId: input.analysisId });
|
|
181
|
+
}
|
|
182
|
+
return deps.sandboxClient.createSandbox({ ...sandboxMeta, billing }, identity);
|
|
183
|
+
}, { name: "sandbox.create" });
|
|
165
184
|
// Recovery path: re-check `isAlive` on the persisted ref before continuing.
|
|
166
185
|
// A classified-dead sandbox triggers a fresh `createSandbox` (task 4.6).
|
|
167
186
|
// For first execution this is a cheap no-op; on replay it's the seam
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inflexa-ai/harness",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
4
4
|
"description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"design:fixture": "bun scripts/render-fixture.ts",
|
|
37
38
|
"format": "prettier --write src/",
|
|
38
39
|
"format:file": "prettier --write",
|
|
39
40
|
"lint": "eslint .",
|
|
@@ -44,12 +45,15 @@
|
|
|
44
45
|
"typecheck": "tsc --noEmit"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"@ai-sdk/anthropic": "^4.0.
|
|
48
|
-
"@ai-sdk/openai
|
|
49
|
-
"@ai-sdk/
|
|
50
|
-
"@ai-sdk/provider
|
|
51
|
-
"@
|
|
48
|
+
"@ai-sdk/anthropic": "^4.0.38",
|
|
49
|
+
"@ai-sdk/openai": "^4.0.39",
|
|
50
|
+
"@ai-sdk/openai-compatible": "^3.0.30",
|
|
51
|
+
"@ai-sdk/provider": "^4.0.7",
|
|
52
|
+
"@ai-sdk/provider-utils": "5.0.27",
|
|
53
|
+
"@anthropic-ai/sdk": "^0.116.0",
|
|
52
54
|
"@dbos-inc/dbos-sdk": "^4.23.6",
|
|
55
|
+
"@fontsource-variable/space-grotesk": "5.1.1",
|
|
56
|
+
"@fontsource/ibm-plex-mono": "5.1.1",
|
|
53
57
|
"@kubernetes/client-node": "^1.4.0",
|
|
54
58
|
"@opentelemetry/api": "^1.9.1",
|
|
55
59
|
"@opentelemetry/core": "^2.9.0",
|
|
@@ -60,17 +64,19 @@
|
|
|
60
64
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
61
65
|
"@opentelemetry/sdk-trace-node": "^2.9.0",
|
|
62
66
|
"@opentelemetry/semantic-conventions": "^1.43.0",
|
|
63
|
-
"ai": "^7.0.
|
|
67
|
+
"ai": "^7.0.61",
|
|
64
68
|
"cheerio": "^1.2.0",
|
|
65
69
|
"dockerode": "^4.0.12",
|
|
70
|
+
"echarts": "5.5.1",
|
|
66
71
|
"fast-xml-parser": "^5.10.0",
|
|
67
72
|
"hono": "^4.12.30",
|
|
73
|
+
"hyparquet": "^1.28.1",
|
|
68
74
|
"js-tiktoken": "^1.0.21",
|
|
69
75
|
"mime": "^4.1.0",
|
|
70
76
|
"nanoid": "^5.1.16",
|
|
71
77
|
"neverthrow": "^8.2.0",
|
|
72
78
|
"nunjucks": "^3.2.4",
|
|
73
|
-
"openai": "^4.
|
|
79
|
+
"openai": "^7.4.0",
|
|
74
80
|
"p-queue": "^9.3.3",
|
|
75
81
|
"pdf-parse": "^2.4.5",
|
|
76
82
|
"pg": "^8.22.0",
|