@inflexa-ai/harness 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/conversation-agent.d.ts +26 -14
- package/dist/agents/conversation-agent.js +12 -23
- package/dist/agents/report-session-agent.d.ts +31 -5
- package/dist/agents/report-session-agent.js +31 -6
- package/dist/app/chat-turn.js +3 -1
- package/dist/app/message-assembly.d.ts +15 -1
- package/dist/app/message-assembly.js +22 -8
- package/dist/app/report-session-runtime.d.ts +21 -4
- package/dist/app/report-session-runtime.js +53 -6
- package/dist/app/spawn-report-session.d.ts +174 -17
- package/dist/app/spawn-report-session.js +233 -21
- package/dist/contracts/chat-events.d.ts +7 -2
- package/dist/contracts/message.d.ts +10 -0
- package/dist/contracts/report-blocks.d.ts +743 -19
- package/dist/contracts/report-blocks.js +186 -11
- package/dist/contracts/report-reference.d.ts +88 -0
- package/dist/contracts/report-reference.js +48 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -0
- package/dist/lib/chrome.d.ts +26 -6
- package/dist/lib/chrome.js +99 -32
- package/dist/lib/eyes.d.ts +57 -0
- package/dist/lib/eyes.js +45 -0
- package/dist/lib/page-capture.d.ts +14 -0
- package/dist/lib/page-capture.js +56 -2
- package/dist/loop/run-agent.js +58 -23
- package/dist/loop/tool-detail.d.ts +19 -2
- package/dist/loop/tool-detail.js +35 -2
- package/dist/memory/content-to-cortex.d.ts +3 -2
- package/dist/memory/content-to-cortex.js +21 -12
- package/dist/memory/conversation-display-replay.js +14 -7
- package/dist/memory/thread-history.d.ts +60 -3
- package/dist/memory/thread-history.js +55 -16
- package/dist/memory/thread-store.d.ts +11 -1
- package/dist/memory/thread-store.js +12 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +23 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +183 -9
- package/dist/report-model/authoring-grammar.d.ts +463 -0
- package/dist/report-model/authoring-grammar.js +77 -0
- package/dist/report-model/block-walk.d.ts +57 -14
- package/dist/report-model/block-walk.js +116 -11
- package/dist/report-model/draft-finish.d.ts +22 -4
- package/dist/report-model/draft-finish.js +39 -7
- package/dist/report-model/draft-operations.d.ts +3 -0
- package/dist/report-model/draft-operations.js +19 -5
- package/dist/report-model/draft-read.d.ts +8 -5
- package/dist/report-model/draft-read.js +21 -5
- package/dist/report-model/fixture-resolver.js +15 -4
- package/dist/report-model/pin-snapshot.d.ts +24 -6
- package/dist/report-model/pin-snapshot.js +204 -6
- package/dist/report-model/production-resolver.js +15 -4
- package/dist/report-model/reference-resolver.d.ts +50 -1
- package/dist/report-model/reference-resolver.js +94 -1
- package/dist/report-model/reference-stamp.d.ts +45 -0
- package/dist/report-model/reference-stamp.js +173 -0
- package/dist/report-model/structural-validation.d.ts +4 -1
- package/dist/report-model/structural-validation.js +48 -3
- package/dist/report-model/validate.d.ts +1 -1
- package/dist/report-model/validate.js +1 -1
- package/dist/report-render/asset-lookup.d.ts +19 -0
- package/dist/report-render/asset-lookup.js +37 -0
- package/dist/report-render/assets.d.ts +45 -1
- package/dist/report-render/assets.js +65 -6
- package/dist/report-render/chart-presets.d.ts +102 -0
- package/dist/report-render/chart-presets.js +205 -0
- package/dist/report-render/chart.d.ts +112 -10
- package/dist/report-render/chart.js +1164 -41
- package/dist/report-render/design.d.ts +99 -0
- package/dist/report-render/design.js +289 -107
- package/dist/report-render/number-format.d.ts +117 -0
- package/dist/report-render/number-format.js +479 -0
- package/dist/report-render/page.d.ts +121 -5
- package/dist/report-render/page.js +689 -7
- package/dist/report-render/references.d.ts +70 -16
- package/dist/report-render/references.js +54 -22
- package/dist/report-render/render.d.ts +23 -5
- package/dist/report-render/render.js +81 -22
- package/dist/report-render/table-data.d.ts +92 -0
- package/dist/report-render/table-data.js +129 -0
- package/dist/report-render/types.d.ts +15 -0
- package/dist/report-render/value-bridge.js +6 -2
- package/dist/report-render/views/chart-view.d.ts +12 -5
- package/dist/report-render/views/chart-view.js +14 -8
- package/dist/report-render/views/page-view.d.ts +36 -15
- package/dist/report-render/views/page-view.js +45 -23
- package/dist/report-render/views/prose.d.ts +12 -2
- package/dist/report-render/views/prose.js +29 -7
- package/dist/report-render/views/references-view.d.ts +22 -8
- package/dist/report-render/views/references-view.js +101 -13
- package/dist/report-render/views/values.d.ts +128 -13
- package/dist/report-render/views/values.js +199 -18
- package/dist/runtime/assemble.d.ts +39 -5
- package/dist/runtime/assemble.js +47 -6
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +9 -4
- package/dist/sandbox/create-sandbox.js +22 -16
- package/dist/sandbox/docker-client.js +10 -7
- package/dist/sandbox/mount-plan.d.ts +45 -7
- package/dist/sandbox/mount-plan.js +43 -5
- package/dist/sandbox/types.d.ts +6 -0
- package/dist/state/init.js +38 -9
- package/dist/state/report-session-state.d.ts +42 -1
- package/dist/state/report-session-state.js +70 -2
- package/dist/state/report-versions.d.ts +22 -17
- package/dist/state/report-versions.js +48 -35
- package/dist/state/snapshot-parse.js +16 -1
- package/dist/tools/define-tool.d.ts +32 -0
- package/dist/tools/define-tool.js +4 -0
- package/dist/tools/detail-resolver.d.ts +7 -2
- package/dist/tools/detail-resolver.js +7 -2
- package/dist/tools/iterate-report.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
- package/dist/tools/report-authoring/authoring-tools.js +99 -18
- package/dist/tools/report-session/derive-table.d.ts +162 -0
- package/dist/tools/report-session/derive-table.js +459 -0
- package/dist/tools/report-session/examine-page.d.ts +48 -18
- package/dist/tools/report-session/examine-page.js +204 -24
- package/dist/tools/report-session/index.d.ts +2 -0
- package/dist/tools/report-session/index.js +2 -0
- package/dist/tools/report-session/list-artifacts.d.ts +97 -0
- package/dist/tools/report-session/list-artifacts.js +197 -0
- package/dist/tools/report-session/preview-report.d.ts +6 -0
- package/dist/tools/report-session/preview-report.js +180 -28
- package/dist/tools/report-session/record-version.d.ts +22 -10
- package/dist/tools/report-session/record-version.js +99 -14
- package/dist/tools/start-report-session.d.ts +115 -0
- package/dist/tools/start-report-session.js +179 -0
- package/dist/workspace/paths.d.ts +50 -0
- package/dist/workspace/paths.js +63 -0
- package/package.json +2 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The derivation tool of a report session.
|
|
3
|
+
*
|
|
4
|
+
* A run writes statistical tables, and not plot-ready ones. This tool reshapes the pinned evidence into one
|
|
5
|
+
* table that a block can bind: a join, a pivot, or an aggregate. The agent writes the script, and the record
|
|
6
|
+
* chains the derived table back to the evidence that made it.
|
|
7
|
+
*
|
|
8
|
+
* A session derivation is not an analysis run. It mints no run id, it registers no artifact, and it writes
|
|
9
|
+
* under the session directory alone. The rails are the rails of the value tier (`tasks/extract-values.ts`):
|
|
10
|
+
* the sandbox client, the identity mint, and the authorizer on every terminal path. The container is
|
|
11
|
+
* ephemeral, and it goes away with the work.
|
|
12
|
+
*
|
|
13
|
+
* The container mounts the analysis tree read-only, and one write mount covers the `derived/` directory of
|
|
14
|
+
* the session alone. The script writes its table into that mount directly, thus a large table makes no
|
|
15
|
+
* roundtrip and no write of the script can reach the workspace tree. The working directory of the exec is
|
|
16
|
+
* that same mount, and the standard output carries the logs of the script alone.
|
|
17
|
+
*
|
|
18
|
+
* The host then reads the file that the container wrote. A path inside the mount is still untrusted bytes: a
|
|
19
|
+
* script can leave a symbolic link in place of its output. Thus the host classifies the file against the
|
|
20
|
+
* workspace root before it hashes one byte, and an escape refuses as typed data.
|
|
21
|
+
*
|
|
22
|
+
* Each declared input must sit in the served membership, and its hash comes from there. The declaration is
|
|
23
|
+
* what the record pins, thus a derived cell traces to a pinned artifact and to the script that read it.
|
|
24
|
+
*
|
|
25
|
+
* The tool refuses an output name that the membership already holds. A record is immutable, and a second
|
|
26
|
+
* derivation of one table takes a new name.
|
|
27
|
+
*
|
|
28
|
+
* A composition with no sandbox client and no run authorizer cannot derive at all. The tool reports that
|
|
29
|
+
* condition one time, up front, the same discipline as the eyes. A per-attempt failure would instead read as
|
|
30
|
+
* a transient fault, and it would invite a repeat of a call that can never pass.
|
|
31
|
+
*
|
|
32
|
+
* The transport of the client is the second such bound. A derivation runs inside one live turn and not
|
|
33
|
+
* inside a workflow body. The poll transport awaits with plain steps and a plain sleep, thus it settles
|
|
34
|
+
* there. The callback transport awaits through `DBOS.recv`, which a workflow body alone can call, thus this
|
|
35
|
+
* tool refuses up front under it rather than start a container that nothing can await.
|
|
36
|
+
*/
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
import type { RunAuthorizer } from "../../execution/run-authorizer.js";
|
|
39
|
+
import { type Logger } from "../../lib/logger.js";
|
|
40
|
+
import type { SandboxClient } from "../../sandbox/client.js";
|
|
41
|
+
import type { ExecResult, SubmitExecBody } from "../../sandbox/types.js";
|
|
42
|
+
import type { DerivationSource, ReportSessionStateStore } from "../../state/report-session-state.js";
|
|
43
|
+
import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
|
|
44
|
+
import { type Tool } from "../define-tool.js";
|
|
45
|
+
import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
|
|
46
|
+
/** The input of one derivation: the script, the declared pinned inputs, and the name of the output file. */
|
|
47
|
+
declare const deriveTableInput: z.ZodObject<{
|
|
48
|
+
script: z.ZodString;
|
|
49
|
+
inputs: z.ZodArray<z.ZodString>;
|
|
50
|
+
output: z.ZodString;
|
|
51
|
+
}, z.core.$strip>;
|
|
52
|
+
export type DeriveTableInput = z.infer<typeof deriveTableInput>;
|
|
53
|
+
/**
|
|
54
|
+
* The typed outcome of the derivation tool. Each arm is ok-channel data, thus the tool never throws for one
|
|
55
|
+
* of them.
|
|
56
|
+
*
|
|
57
|
+
* `derived` carries the workspace-relative path of the derived table, its content hash, the hash of the
|
|
58
|
+
* script, the sources that the record pins, and the header columns of the output. Each other arm names a
|
|
59
|
+
* condition that the agent repairs: a call that is out of bounds, an input that the membership does not
|
|
60
|
+
* hold, a name that a record already holds, a script that failed in the container, and a green script that
|
|
61
|
+
* left no file at the output name.
|
|
62
|
+
*/
|
|
63
|
+
export type DeriveTableResult = {
|
|
64
|
+
outcome: "refused";
|
|
65
|
+
refusal: SessionRefusal;
|
|
66
|
+
} | {
|
|
67
|
+
outcome: "unavailable";
|
|
68
|
+
detail: string;
|
|
69
|
+
} | {
|
|
70
|
+
outcome: "script-too-large";
|
|
71
|
+
bytes: number;
|
|
72
|
+
cap: number;
|
|
73
|
+
detail: string;
|
|
74
|
+
} | {
|
|
75
|
+
outcome: "too-many-inputs";
|
|
76
|
+
count: number;
|
|
77
|
+
cap: number;
|
|
78
|
+
detail: string;
|
|
79
|
+
} | {
|
|
80
|
+
outcome: "unsafe-name";
|
|
81
|
+
detail: string;
|
|
82
|
+
} | {
|
|
83
|
+
outcome: "absent-input";
|
|
84
|
+
path: string;
|
|
85
|
+
detail: string;
|
|
86
|
+
} | {
|
|
87
|
+
outcome: "repeated-name";
|
|
88
|
+
outputPath: string;
|
|
89
|
+
detail: string;
|
|
90
|
+
} | {
|
|
91
|
+
outcome: "exec-failed";
|
|
92
|
+
detail: string;
|
|
93
|
+
} | {
|
|
94
|
+
outcome: "no-output";
|
|
95
|
+
detail: string;
|
|
96
|
+
} | {
|
|
97
|
+
outcome: "derived";
|
|
98
|
+
path: string;
|
|
99
|
+
hash: string;
|
|
100
|
+
scriptHash: string;
|
|
101
|
+
sources: DerivationSource[];
|
|
102
|
+
columns?: string[];
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The construction deps of the derivation tool.
|
|
106
|
+
*
|
|
107
|
+
* `derivations` is the append side of the durable session state, thus the record lands beside the document
|
|
108
|
+
* and the pin. `sandboxClient` and `runAuthorizer` are the two rails of the exec, and a composition that
|
|
109
|
+
* binds neither leaves the session with no derivation at all. `resolveWorkspaceRoot` maps the analysis of
|
|
110
|
+
* the call onto its workspace root, thus one singleton tool serves every analysis.
|
|
111
|
+
*/
|
|
112
|
+
export interface DeriveTableToolDeps {
|
|
113
|
+
readonly gateway: ReportSessionStateGateway;
|
|
114
|
+
readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
|
|
115
|
+
readonly derivations: Pick<ReportSessionStateStore, "appendDerivation">;
|
|
116
|
+
readonly sandboxClient?: SandboxClient;
|
|
117
|
+
readonly runAuthorizer?: RunAuthorizer;
|
|
118
|
+
readonly logger?: Logger;
|
|
119
|
+
}
|
|
120
|
+
/** The environment variable that carries the declared inputs into the container. */
|
|
121
|
+
export declare const DERIVE_INPUT_ENV = "DERIVE_INPUTS";
|
|
122
|
+
/** The environment variable that carries the container path of the output file into the container. */
|
|
123
|
+
export declare const DERIVE_OUTPUT_ENV = "DERIVE_OUTPUT";
|
|
124
|
+
/** One declared input, as the container reads it: the mounted path of the file, and its pinned hash. */
|
|
125
|
+
export interface DerivationInputMount {
|
|
126
|
+
readonly path: string;
|
|
127
|
+
readonly hash: string;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Build the sandbox exec of one derivation. The command runs the script of the agent through `python3 -c`.
|
|
131
|
+
*
|
|
132
|
+
* The working directory is the write mount, thus the script writes its table beside where it stands. Each
|
|
133
|
+
* declared input rides as its mounted path, thus the script opens the file wherever it works from. The
|
|
134
|
+
* output path rides too, thus the script names no directory of its own.
|
|
135
|
+
*
|
|
136
|
+
* The function is pure, thus a test asserts the command shape without a sandbox.
|
|
137
|
+
*/
|
|
138
|
+
export declare function buildDerivationExec(args: {
|
|
139
|
+
readonly script: string;
|
|
140
|
+
readonly execId: string;
|
|
141
|
+
readonly workingDir: string;
|
|
142
|
+
readonly inputs: readonly DerivationInputMount[];
|
|
143
|
+
readonly output: string;
|
|
144
|
+
}): SubmitExecBody;
|
|
145
|
+
/**
|
|
146
|
+
* Give the failure of an exec result, or `undefined` when the script ran to a clean exit.
|
|
147
|
+
*
|
|
148
|
+
* A synthetic failure, a timeout, and a non-zero exit each mean that no table came back. The detail of a
|
|
149
|
+
* non-zero exit carries the tail of the standard error, thus the agent repairs the script from what the
|
|
150
|
+
* container reported.
|
|
151
|
+
*
|
|
152
|
+
* The function is pure, thus a test asserts the mapping without a sandbox.
|
|
153
|
+
*/
|
|
154
|
+
export declare function describeExecFailure(result: ExecResult): string | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* Make the derivation tool over the session-state gateway, the derivation ledger, and the sandbox rails.
|
|
157
|
+
*
|
|
158
|
+
* The tool reads the thread id from the scope of the call, and it loads the membership through the gateway.
|
|
159
|
+
* Thus one factory serves every thread, and the tool holds no per-session value.
|
|
160
|
+
*/
|
|
161
|
+
export declare function createDeriveTableTool(deps: DeriveTableToolDeps): Tool<DeriveTableInput, DeriveTableResult>;
|
|
162
|
+
export {};
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The derivation tool of a report session.
|
|
3
|
+
*
|
|
4
|
+
* A run writes statistical tables, and not plot-ready ones. This tool reshapes the pinned evidence into one
|
|
5
|
+
* table that a block can bind: a join, a pivot, or an aggregate. The agent writes the script, and the record
|
|
6
|
+
* chains the derived table back to the evidence that made it.
|
|
7
|
+
*
|
|
8
|
+
* A session derivation is not an analysis run. It mints no run id, it registers no artifact, and it writes
|
|
9
|
+
* under the session directory alone. The rails are the rails of the value tier (`tasks/extract-values.ts`):
|
|
10
|
+
* the sandbox client, the identity mint, and the authorizer on every terminal path. The container is
|
|
11
|
+
* ephemeral, and it goes away with the work.
|
|
12
|
+
*
|
|
13
|
+
* The container mounts the analysis tree read-only, and one write mount covers the `derived/` directory of
|
|
14
|
+
* the session alone. The script writes its table into that mount directly, thus a large table makes no
|
|
15
|
+
* roundtrip and no write of the script can reach the workspace tree. The working directory of the exec is
|
|
16
|
+
* that same mount, and the standard output carries the logs of the script alone.
|
|
17
|
+
*
|
|
18
|
+
* The host then reads the file that the container wrote. A path inside the mount is still untrusted bytes: a
|
|
19
|
+
* script can leave a symbolic link in place of its output. Thus the host classifies the file against the
|
|
20
|
+
* workspace root before it hashes one byte, and an escape refuses as typed data.
|
|
21
|
+
*
|
|
22
|
+
* Each declared input must sit in the served membership, and its hash comes from there. The declaration is
|
|
23
|
+
* what the record pins, thus a derived cell traces to a pinned artifact and to the script that read it.
|
|
24
|
+
*
|
|
25
|
+
* The tool refuses an output name that the membership already holds. A record is immutable, and a second
|
|
26
|
+
* derivation of one table takes a new name.
|
|
27
|
+
*
|
|
28
|
+
* A composition with no sandbox client and no run authorizer cannot derive at all. The tool reports that
|
|
29
|
+
* condition one time, up front, the same discipline as the eyes. A per-attempt failure would instead read as
|
|
30
|
+
* a transient fault, and it would invite a repeat of a call that can never pass.
|
|
31
|
+
*
|
|
32
|
+
* The transport of the client is the second such bound. A derivation runs inside one live turn and not
|
|
33
|
+
* inside a workflow body. The poll transport awaits with plain steps and a plain sleep, thus it settles
|
|
34
|
+
* there. The callback transport awaits through `DBOS.recv`, which a workflow body alone can call, thus this
|
|
35
|
+
* tool refuses up front under it rather than start a container that nothing can await.
|
|
36
|
+
*/
|
|
37
|
+
import { err, ok } from "neverthrow";
|
|
38
|
+
import { unlink } from "node:fs/promises";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
import { createNoopLogger } from "../../lib/console-logger.js";
|
|
42
|
+
import { classifyWithinRoot, computeSha256, computeSha256File } from "../../lib/fs-helpers.js";
|
|
43
|
+
import { defaultErrorFields } from "../../lib/logger.js";
|
|
44
|
+
import { snapshotEntry } from "../../report-model/reference-resolver.js";
|
|
45
|
+
import { generateExecutionId } from "../../sandbox/execution-id.js";
|
|
46
|
+
import { mintSandboxIdentity } from "../../sandbox/identity.js";
|
|
47
|
+
import { isSafeId, reportSessionDerivedDir, toSandboxPath } from "../../workspace/paths.js";
|
|
48
|
+
import { defineTool } from "../define-tool.js";
|
|
49
|
+
import { openReportThread } from "../report-authoring/authoring-tools.js";
|
|
50
|
+
import { readHeaderColumns } from "./list-artifacts.js";
|
|
51
|
+
/** The input of one derivation: the script, the declared pinned inputs, and the name of the output file. */
|
|
52
|
+
const deriveTableInput = z.object({
|
|
53
|
+
script: z.string().min(1),
|
|
54
|
+
inputs: z.array(z.string()).min(1),
|
|
55
|
+
output: z.string().min(1),
|
|
56
|
+
});
|
|
57
|
+
/** The synthetic run id and step id of one derivation. Both are constants, and neither names a run row. */
|
|
58
|
+
const DERIVE_RUN_LITERAL = "derive-table";
|
|
59
|
+
const DERIVE_STEP_LITERAL = "derive";
|
|
60
|
+
/** The provenance agent id. No agent loop runs in the container, thus this names the derivation pass. */
|
|
61
|
+
const DERIVE_AGENT_ID = "table-deriver";
|
|
62
|
+
/** The exec budget of one derivation. It matches the budget of the value tier. */
|
|
63
|
+
const DERIVATION_DEADLINE_MS = 300_000;
|
|
64
|
+
/**
|
|
65
|
+
* The container size of one derivation. A join or a pivot loads each declared input into pandas at one
|
|
66
|
+
* time, thus the container needs the headroom of the value tier.
|
|
67
|
+
*/
|
|
68
|
+
const DERIVATION_RESOURCES = { cpu: 2, memoryGb: 8 };
|
|
69
|
+
/** The cap of the script, in bytes. A reshaping script sits far under it, and the command line carries it. */
|
|
70
|
+
const SCRIPT_CAP_BYTES = 64 * 1024;
|
|
71
|
+
/** The cap of the declared inputs. One derivation reads the evidence of a few artifacts, and never a tree. */
|
|
72
|
+
const INPUT_CAP = 20;
|
|
73
|
+
/** The environment variable that carries the declared inputs into the container. */
|
|
74
|
+
export const DERIVE_INPUT_ENV = "DERIVE_INPUTS";
|
|
75
|
+
/** The environment variable that carries the container path of the output file into the container. */
|
|
76
|
+
export const DERIVE_OUTPUT_ENV = "DERIVE_OUTPUT";
|
|
77
|
+
/** The cap of the failure detail that the tool copies out of the standard error of the script. */
|
|
78
|
+
const STDERR_TAIL_CAP = 2_000;
|
|
79
|
+
/** The awaitExec callback. A derivation reports no live activity, thus the callback drops each event. */
|
|
80
|
+
const noopEmit = () => { };
|
|
81
|
+
/** The line that the agent reads when the composition binds no sandbox rails. */
|
|
82
|
+
const NO_SANDBOX_DETAIL = "the composition gives no sandbox, thus this session cannot derive a table";
|
|
83
|
+
/** The line that the agent reads when the sandbox client awaits under a transport that a turn cannot drive. */
|
|
84
|
+
const CALLBACK_TRANSPORT_DETAIL = "the sandbox of this composition reports through callbacks, which a report turn cannot await, thus it derives no table";
|
|
85
|
+
/**
|
|
86
|
+
* Build the sandbox exec of one derivation. The command runs the script of the agent through `python3 -c`.
|
|
87
|
+
*
|
|
88
|
+
* The working directory is the write mount, thus the script writes its table beside where it stands. Each
|
|
89
|
+
* declared input rides as its mounted path, thus the script opens the file wherever it works from. The
|
|
90
|
+
* output path rides too, thus the script names no directory of its own.
|
|
91
|
+
*
|
|
92
|
+
* The function is pure, thus a test asserts the command shape without a sandbox.
|
|
93
|
+
*/
|
|
94
|
+
export function buildDerivationExec(args) {
|
|
95
|
+
return {
|
|
96
|
+
command: ["python3", "-c", args.script],
|
|
97
|
+
execId: args.execId,
|
|
98
|
+
cwd: args.workingDir,
|
|
99
|
+
env: {
|
|
100
|
+
[DERIVE_INPUT_ENV]: JSON.stringify(args.inputs.map((input) => ({ path: input.path, hash: input.hash }))),
|
|
101
|
+
[DERIVE_OUTPUT_ENV]: args.output,
|
|
102
|
+
},
|
|
103
|
+
timeoutSeconds: Math.floor(DERIVATION_DEADLINE_MS / 1000),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Give the failure of an exec result, or `undefined` when the script ran to a clean exit.
|
|
108
|
+
*
|
|
109
|
+
* A synthetic failure, a timeout, and a non-zero exit each mean that no table came back. The detail of a
|
|
110
|
+
* non-zero exit carries the tail of the standard error, thus the agent repairs the script from what the
|
|
111
|
+
* container reported.
|
|
112
|
+
*
|
|
113
|
+
* The function is pure, thus a test asserts the mapping without a sandbox.
|
|
114
|
+
*/
|
|
115
|
+
export function describeExecFailure(result) {
|
|
116
|
+
if (result.syntheticFailure !== undefined) {
|
|
117
|
+
return `the derivation sandbox failed: ${result.syntheticFailure.reason}`;
|
|
118
|
+
}
|
|
119
|
+
if (result.timedOut) {
|
|
120
|
+
return "the derivation reached the deadline";
|
|
121
|
+
}
|
|
122
|
+
if (result.exitCode !== 0) {
|
|
123
|
+
const tail = result.stderr.slice(-STDERR_TAIL_CAP).trim();
|
|
124
|
+
const exit = `the script exited with code ${String(result.exitCode)}`;
|
|
125
|
+
return tail.length === 0 ? exit : `${exit}: ${tail}`;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Run one derivation in an ephemeral container, and give the exec result back.
|
|
131
|
+
*
|
|
132
|
+
* The container mounts the analysis tree read-only, and the declared write tail is its one writable mount.
|
|
133
|
+
* Thus the script reads the evidence and it writes into the `derived/` directory of the session and nowhere
|
|
134
|
+
* else. The teardown runs on both paths, thus no machine outlives the call. A teardown fault reaches the log
|
|
135
|
+
* alone, because the work is done.
|
|
136
|
+
*
|
|
137
|
+
* Each seam call is guarded, thus a fault of the sandbox becomes a short detail and the tool keeps its
|
|
138
|
+
* no-throw contract.
|
|
139
|
+
*/
|
|
140
|
+
async function runDerivationExec(args) {
|
|
141
|
+
let sandbox;
|
|
142
|
+
try {
|
|
143
|
+
sandbox = await args.client.createSandbox({
|
|
144
|
+
runId: DERIVE_RUN_LITERAL,
|
|
145
|
+
stepId: DERIVE_STEP_LITERAL,
|
|
146
|
+
analysisId: args.analysisId,
|
|
147
|
+
childWorkflowId: `${DERIVE_RUN_LITERAL}:${args.executionId}`,
|
|
148
|
+
resources: DERIVATION_RESOURCES,
|
|
149
|
+
writableTail: args.writableTail,
|
|
150
|
+
}, mintSandboxIdentity(DERIVE_RUN_LITERAL));
|
|
151
|
+
}
|
|
152
|
+
catch (cause) {
|
|
153
|
+
args.logger.error("the derivation sandbox did not start", args.logger.errorFields(cause));
|
|
154
|
+
return err("the derivation sandbox did not start");
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
// The tool runs inside one live turn and it never replays, thus the wall clock is the deadline.
|
|
158
|
+
const deadline = Date.now() + DERIVATION_DEADLINE_MS;
|
|
159
|
+
await args.client.submitExec(sandbox, buildDerivationExec({
|
|
160
|
+
script: args.script,
|
|
161
|
+
execId: args.executionId,
|
|
162
|
+
workingDir: args.workingDir,
|
|
163
|
+
inputs: args.inputs,
|
|
164
|
+
output: args.output,
|
|
165
|
+
}));
|
|
166
|
+
return ok(await args.client.awaitExec(sandbox, args.executionId, noopEmit, deadline));
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
args.logger.error("the derivation exec did not complete", args.logger.errorFields(cause));
|
|
170
|
+
return err("the derivation exec did not complete");
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
try {
|
|
174
|
+
await args.client.teardown(sandbox);
|
|
175
|
+
}
|
|
176
|
+
catch (cause) {
|
|
177
|
+
args.logger.warn("the derivation sandbox did not tear down", args.logger.errorFields(cause));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Make the derivation tool over the session-state gateway, the derivation ledger, and the sandbox rails.
|
|
183
|
+
*
|
|
184
|
+
* The tool reads the thread id from the scope of the call, and it loads the membership through the gateway.
|
|
185
|
+
* Thus one factory serves every thread, and the tool holds no per-session value.
|
|
186
|
+
*/
|
|
187
|
+
export function createDeriveTableTool(deps) {
|
|
188
|
+
const logger = (deps.logger ?? createNoopLogger()).named("derive-table");
|
|
189
|
+
const { sandboxClient, runAuthorizer } = deps;
|
|
190
|
+
return defineTool({
|
|
191
|
+
id: "derive_table",
|
|
192
|
+
description: "Derive one table from the pinned evidence with a Python script, and pin the result to this session. " +
|
|
193
|
+
"Reach for it when a real reshaping stands between the evidence and the block: a join of two tables, a pivot, or an aggregate. " +
|
|
194
|
+
"A per-row transform is not a derivation, because a chart block reads the column that it needs. " +
|
|
195
|
+
"Name each pinned path that the script reads in `inputs`: each one must sit in the pinned evidence, and the record pins its hash. " +
|
|
196
|
+
'Give the file name of the result in "output": one name, with no directory and no separator, for example "yield_by_group.csv". ' +
|
|
197
|
+
`The script reads the ${DERIVE_INPUT_ENV} environment variable for its inputs: a JSON list that gives the mounted path and the hash of each declared one. ` +
|
|
198
|
+
`It writes the whole table as CSV to the path in the ${DERIVE_OUTPUT_ENV} environment variable, which sits in the working directory of the script. ` +
|
|
199
|
+
"That directory is the one place that the script can write: the analysis mounts read-only, and the container has no network. " +
|
|
200
|
+
"The standard output carries the logs of the script alone, thus a table that it prints there reaches nobody. " +
|
|
201
|
+
"The derived table joins the pinned evidence of this session, thus a block binds its path the same way as a pinned artifact. " +
|
|
202
|
+
"A name that this session already derived is refused, thus a second derivation of one table takes a new name.",
|
|
203
|
+
inputSchema: deriveTableInput,
|
|
204
|
+
executionMode: "inline",
|
|
205
|
+
describeCall: (input) => `derive ${input.output}`,
|
|
206
|
+
// The derived path is the one durable product of the call, thus the derived arm names it. Each other
|
|
207
|
+
// arm is a condition that refused, and the kind of the arm is what a watcher must read.
|
|
208
|
+
describeResult: (_input, result) => (result.outcome === "derived" ? result.path : result.outcome),
|
|
209
|
+
execute: async (input, ctx) => {
|
|
210
|
+
// The check runs before every read, thus one clear signal replaces a failure for each attempt.
|
|
211
|
+
// It also narrows the two rails below, thus the exec needs no assertion.
|
|
212
|
+
if (sandboxClient === undefined || runAuthorizer === undefined) {
|
|
213
|
+
logger.warn("the composition gives no sandbox, thus no derivation can run");
|
|
214
|
+
return ok({ outcome: "unavailable", detail: NO_SANDBOX_DETAIL });
|
|
215
|
+
}
|
|
216
|
+
// The await of the callback transport is a workflow-body call, and a report turn is not one. An
|
|
217
|
+
// absent field states nothing, thus it reads as the poll default and it refuses nothing.
|
|
218
|
+
if (sandboxClient.transport === "callback") {
|
|
219
|
+
logger.warn("the sandbox client awaits through callbacks, thus no derivation can run in a turn");
|
|
220
|
+
return ok({ outcome: "unavailable", detail: CALLBACK_TRANSPORT_DETAIL });
|
|
221
|
+
}
|
|
222
|
+
const opened = await openReportThread(deps.gateway, ctx.session.scope);
|
|
223
|
+
if (opened.isErr()) {
|
|
224
|
+
return ok({ outcome: "refused", refusal: opened.error });
|
|
225
|
+
}
|
|
226
|
+
const { threadId, analysisId, state } = opened.value;
|
|
227
|
+
const bytes = Buffer.byteLength(input.script, "utf8");
|
|
228
|
+
if (bytes > SCRIPT_CAP_BYTES) {
|
|
229
|
+
return ok({
|
|
230
|
+
outcome: "script-too-large",
|
|
231
|
+
bytes,
|
|
232
|
+
cap: SCRIPT_CAP_BYTES,
|
|
233
|
+
detail: `the script is ${String(bytes)} bytes, and the cap is ${String(SCRIPT_CAP_BYTES)} bytes`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// A repeated path names one source, thus the list keeps the first occurrence and the record pins
|
|
237
|
+
// each source one time.
|
|
238
|
+
const declared = [...new Set(input.inputs)];
|
|
239
|
+
if (declared.length > INPUT_CAP) {
|
|
240
|
+
return ok({
|
|
241
|
+
outcome: "too-many-inputs",
|
|
242
|
+
count: declared.length,
|
|
243
|
+
cap: INPUT_CAP,
|
|
244
|
+
detail: `the call declares ${String(declared.length)} inputs, and the cap is ${String(INPUT_CAP)}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (!isSafeId(input.output)) {
|
|
248
|
+
return ok({
|
|
249
|
+
outcome: "unsafe-name",
|
|
250
|
+
detail: "the output names one file, with no directory, no separator, and no traversal",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
// Each declared input takes its hash from the served membership. A path that the membership does
|
|
254
|
+
// not hold has no pinned hash, thus the record could chain nothing to it.
|
|
255
|
+
const sources = [];
|
|
256
|
+
for (const path of declared) {
|
|
257
|
+
const entry = snapshotEntry(state.snapshot, path);
|
|
258
|
+
if (entry === undefined) {
|
|
259
|
+
return ok({
|
|
260
|
+
outcome: "absent-input",
|
|
261
|
+
path,
|
|
262
|
+
detail: `the pinned evidence of this session holds no artifact at ${path}`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
sources.push({ path, hash: entry.hash });
|
|
266
|
+
}
|
|
267
|
+
// The write tail is the one writable mount of the container, and the output sits inside it. Both
|
|
268
|
+
// compose from the session-directory builder, thus the layout has one owner.
|
|
269
|
+
const writableTail = reportSessionDerivedDir(threadId);
|
|
270
|
+
const outputPath = `${writableTail}/${input.output}`;
|
|
271
|
+
// The served membership carries each derivation of the session, thus this one test refuses a
|
|
272
|
+
// repeated name and a collision with a pinned artifact alike.
|
|
273
|
+
if (snapshotEntry(state.snapshot, outputPath) !== undefined) {
|
|
274
|
+
return ok({
|
|
275
|
+
outcome: "repeated-name",
|
|
276
|
+
outputPath,
|
|
277
|
+
detail: `the evidence of this session already holds ${outputPath}, thus this derivation takes a new name`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
let root;
|
|
281
|
+
try {
|
|
282
|
+
root = deps.resolveWorkspaceRoot(analysisId);
|
|
283
|
+
}
|
|
284
|
+
catch (cause) {
|
|
285
|
+
logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
|
|
286
|
+
return ok({ outcome: "unavailable", detail: "the workspace root did not resolve, thus the derived table has no place to land" });
|
|
287
|
+
}
|
|
288
|
+
let authorization;
|
|
289
|
+
try {
|
|
290
|
+
authorization = await runAuthorizer.authorize({
|
|
291
|
+
auth: ctx.session.auth,
|
|
292
|
+
scope: { kind: "analysis", analysisId },
|
|
293
|
+
provenance: { agentId: DERIVE_AGENT_ID, callPath: [DERIVE_AGENT_ID] },
|
|
294
|
+
frame: { runId: DERIVE_RUN_LITERAL, stepId: DERIVE_STEP_LITERAL },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
catch (cause) {
|
|
298
|
+
logger.error("the derivation was not authorized", { threadId, analysisId, ...defaultErrorFields(cause) });
|
|
299
|
+
return ok({ outcome: "unavailable", detail: "the derivation was not authorized" });
|
|
300
|
+
}
|
|
301
|
+
// The work sits inside the `try`, and the revoke sits in the `finally`. Thus the authorization
|
|
302
|
+
// revokes on every terminal path, the thrown one included: a path builder refuses a hostile
|
|
303
|
+
// stored path with a throw, and that throw must not leave an authorization standing.
|
|
304
|
+
let result;
|
|
305
|
+
try {
|
|
306
|
+
result = await derive({
|
|
307
|
+
client: sandboxClient,
|
|
308
|
+
derivations: deps.derivations,
|
|
309
|
+
root,
|
|
310
|
+
threadId,
|
|
311
|
+
analysisId,
|
|
312
|
+
script: input.script,
|
|
313
|
+
sources,
|
|
314
|
+
writableTail,
|
|
315
|
+
outputPath,
|
|
316
|
+
outputName: input.output,
|
|
317
|
+
logger,
|
|
318
|
+
});
|
|
319
|
+
return ok(result);
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
// A revoke fault changes no outcome of the call, thus it reaches the log alone. An absent
|
|
323
|
+
// result means that the work threw, and a throw is a failed derivation.
|
|
324
|
+
try {
|
|
325
|
+
await runAuthorizer.revoke(authorization, result?.outcome === "derived" ? "derive-table-completed" : "derive-table-failed");
|
|
326
|
+
}
|
|
327
|
+
catch (cause) {
|
|
328
|
+
logger.warn("the derivation authorization did not revoke", { threadId, analysisId, ...defaultErrorFields(cause) });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Run one derivation and record what the script wrote.
|
|
336
|
+
*
|
|
337
|
+
* The order is the order of the evidence. The script writes into the write mount, the host classifies the
|
|
338
|
+
* file, the hash comes off the disk, and the record lands last. Thus the recorded hash is the hash of the
|
|
339
|
+
* file that a verifier reads.
|
|
340
|
+
*
|
|
341
|
+
* The classification is the guard of the read. A script can leave a symbolic link at the output name, and a
|
|
342
|
+
* link that resolves outside the tree would otherwise carry a host file into the evidence of the session.
|
|
343
|
+
*
|
|
344
|
+
* The output path clears before the exec. The host owns the `derived/` directory, and a failed attempt can
|
|
345
|
+
* leave a file at the name that this attempt declares. Without the clearance a script that writes nothing
|
|
346
|
+
* would adopt those bytes as its own table, and the record would chain a script to a file that it never
|
|
347
|
+
* wrote. A clearance fault refuses the derivation, because the same doubt stands.
|
|
348
|
+
*/
|
|
349
|
+
async function derive(args) {
|
|
350
|
+
const executionId = generateExecutionId(DERIVE_AGENT_ID);
|
|
351
|
+
const absolute = join(args.root, args.outputPath);
|
|
352
|
+
// The container sees the tree at its own mount point, thus each path that the script reads maps through
|
|
353
|
+
// the one host-to-container mapper and never through a formula here.
|
|
354
|
+
const workingDir = toSandboxPath(args.root, args.analysisId, join(args.root, args.writableTail));
|
|
355
|
+
// `unlink` removes a symbolic link as the link, and never the file that it names. An absent path is the
|
|
356
|
+
// normal condition, thus it is not a fault.
|
|
357
|
+
try {
|
|
358
|
+
await unlink(absolute);
|
|
359
|
+
}
|
|
360
|
+
catch (cause) {
|
|
361
|
+
if (cause.code !== "ENOENT") {
|
|
362
|
+
args.logger.error("the output path did not clear before the derivation", { threadId: args.threadId, ...defaultErrorFields(cause) });
|
|
363
|
+
return { outcome: "exec-failed", detail: `${args.outputName} already holds a file that did not clear, thus this derivation takes a new name` };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const executed = await runDerivationExec({
|
|
367
|
+
client: args.client,
|
|
368
|
+
analysisId: args.analysisId,
|
|
369
|
+
executionId,
|
|
370
|
+
script: args.script,
|
|
371
|
+
writableTail: args.writableTail,
|
|
372
|
+
workingDir,
|
|
373
|
+
inputs: args.sources.map((source) => ({ path: toSandboxPath(args.root, args.analysisId, join(args.root, source.path)), hash: source.hash })),
|
|
374
|
+
output: toSandboxPath(args.root, args.analysisId, absolute),
|
|
375
|
+
logger: args.logger,
|
|
376
|
+
});
|
|
377
|
+
if (executed.isErr()) {
|
|
378
|
+
return { outcome: "exec-failed", detail: executed.error };
|
|
379
|
+
}
|
|
380
|
+
const failure = describeExecFailure(executed.value);
|
|
381
|
+
if (failure !== undefined) {
|
|
382
|
+
return { outcome: "exec-failed", detail: failure };
|
|
383
|
+
}
|
|
384
|
+
const verdict = await classifyWithinRoot(args.root, absolute).catch((cause) => {
|
|
385
|
+
args.logger.error("the derived table did not classify", { threadId: args.threadId, ...defaultErrorFields(cause) });
|
|
386
|
+
return "escaped";
|
|
387
|
+
});
|
|
388
|
+
if (verdict === "absent") {
|
|
389
|
+
// A clean exit with no file at the output path means that the script wrote nothing, or that it wrote
|
|
390
|
+
// under a different name inside the mount. Either way this derivation has no table.
|
|
391
|
+
return {
|
|
392
|
+
outcome: "no-output",
|
|
393
|
+
detail: `the script ran to a clean exit and it wrote no file at ${args.outputName}`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (verdict === "escaped") {
|
|
397
|
+
args.logger.error("the derived table resolves outside the workspace root", { threadId: args.threadId, path: args.outputPath });
|
|
398
|
+
return { outcome: "exec-failed", detail: `${args.outputName} resolves outside the workspace tree, thus it is not evidence of this session` };
|
|
399
|
+
}
|
|
400
|
+
let outputHash;
|
|
401
|
+
try {
|
|
402
|
+
outputHash = await computeSha256File(absolute);
|
|
403
|
+
}
|
|
404
|
+
catch (cause) {
|
|
405
|
+
args.logger.error("the derived table did not hash", { threadId: args.threadId, ...defaultErrorFields(cause) });
|
|
406
|
+
return { outcome: "exec-failed", detail: "the derived table did not hash" };
|
|
407
|
+
}
|
|
408
|
+
const record = {
|
|
409
|
+
outputPath: args.outputPath,
|
|
410
|
+
outputHash,
|
|
411
|
+
sources: [...args.sources],
|
|
412
|
+
scriptHash: computeSha256(Buffer.from(args.script, "utf8")),
|
|
413
|
+
script: args.script,
|
|
414
|
+
};
|
|
415
|
+
const appended = await args.derivations.appendDerivation(args.threadId, record);
|
|
416
|
+
if (appended.isErr()) {
|
|
417
|
+
args.logger.error("the derivation record did not land", { threadId: args.threadId, ...args.logger.errorFields(appended.error.cause) });
|
|
418
|
+
return { outcome: "exec-failed", detail: "the derivation record did not land, thus the table is not evidence of this session" };
|
|
419
|
+
}
|
|
420
|
+
if (appended.value === "duplicate") {
|
|
421
|
+
// The membership test above already refused a repeated name. A duplicate here means that another
|
|
422
|
+
// turn landed the same name inside this call, thus the name rule of the store is the last word.
|
|
423
|
+
return {
|
|
424
|
+
outcome: "repeated-name",
|
|
425
|
+
outputPath: args.outputPath,
|
|
426
|
+
detail: `another turn recorded ${args.outputPath} first, thus this derivation takes a new name`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (appended.value === "absent") {
|
|
430
|
+
args.logger.error("the derivation record matched no session row", { threadId: args.threadId });
|
|
431
|
+
return { outcome: "exec-failed", detail: "no report session state row exists to hold the derivation" };
|
|
432
|
+
}
|
|
433
|
+
// The columns describe the derived table the same way as the listing describes a pinned one. A file that
|
|
434
|
+
// gives no header gives no columns, and absence is a normal condition here.
|
|
435
|
+
const columns = await readColumnsQuietly(args.outputPath, absolute, args.logger);
|
|
436
|
+
return {
|
|
437
|
+
outcome: "derived",
|
|
438
|
+
path: args.outputPath,
|
|
439
|
+
hash: outputHash,
|
|
440
|
+
scriptHash: record.scriptHash,
|
|
441
|
+
sources: [...args.sources],
|
|
442
|
+
...(columns === undefined ? {} : { columns }),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The header columns of the derived table, or `undefined` when the read gives none.
|
|
447
|
+
*
|
|
448
|
+
* The derivation already landed at this point, thus a read fault costs the columns alone. The read never
|
|
449
|
+
* throws for an absent file, and this guard covers a genuine fault of the disk.
|
|
450
|
+
*/
|
|
451
|
+
async function readColumnsQuietly(path, absolute, logger) {
|
|
452
|
+
try {
|
|
453
|
+
return await readHeaderColumns(path, absolute);
|
|
454
|
+
}
|
|
455
|
+
catch (cause) {
|
|
456
|
+
logger.warn("the derived table gave no columns", { path, ...defaultErrorFields(cause) });
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
}
|