@inflexa-ai/harness 0.25.0 → 0.26.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 (47) hide show
  1. package/dist/agents/report-session-agent.d.ts +5 -4
  2. package/dist/agents/report-session-agent.js +2 -2
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +5 -3
  5. package/dist/lib/page-capture.d.ts +44 -15
  6. package/dist/lib/page-capture.js +78 -19
  7. package/dist/lib/zod-issues.d.ts +9 -4
  8. package/dist/lib/zod-issues.js +35 -4
  9. package/dist/loop/run-agent.js +36 -19
  10. package/dist/memory/card-builders.d.ts +4 -6
  11. package/dist/memory/card-builders.js +4 -6
  12. package/dist/memory/conversation-display-replay.d.ts +7 -6
  13. package/dist/memory/conversation-display-replay.js +7 -6
  14. package/dist/memory/conversation-display-storage.d.ts +2 -10
  15. package/dist/memory/conversation-display-storage.js +79 -32
  16. package/dist/memory/thread-history.d.ts +31 -20
  17. package/dist/memory/thread-history.js +45 -45
  18. package/dist/prompts/report-session.d.ts +1 -1
  19. package/dist/prompts/report-session.js +22 -13
  20. package/dist/runtime/assemble.d.ts +3 -0
  21. package/dist/runtime/assemble.js +9 -1
  22. package/dist/runtime/boot.d.ts +7 -8
  23. package/dist/runtime/boot.js +7 -19
  24. package/dist/sandbox/client.d.ts +9 -13
  25. package/dist/sandbox/client.js +8 -3
  26. package/dist/sandbox/create-sandbox.js +0 -1
  27. package/dist/tasks/derive-table-exec.d.ts +50 -0
  28. package/dist/tasks/derive-table-exec.js +84 -0
  29. package/dist/tools/define-tool.d.ts +19 -8
  30. package/dist/tools/define-tool.js +28 -12
  31. package/dist/tools/report-session/derive-table.d.ts +39 -7
  32. package/dist/tools/report-session/derive-table.js +24 -83
  33. package/dist/tools/report-session/examine-page.d.ts +22 -13
  34. package/dist/tools/report-session/examine-page.js +15 -10
  35. package/dist/tools/report-session/index.d.ts +1 -1
  36. package/dist/tools/research/generate-plan.js +3 -8
  37. package/dist/tools/research/inspect-data-profile.js +10 -11
  38. package/dist/tools/start-report-session.js +6 -2
  39. package/package.json +1 -1
  40. package/dist/app/data-profile-policy.d.ts +0 -62
  41. package/dist/app/data-profile-policy.js +0 -59
  42. package/dist/memory/content-to-cortex.d.ts +0 -75
  43. package/dist/memory/content-to-cortex.js +0 -201
  44. package/dist/memory/conversation-display-backfill.d.ts +0 -23
  45. package/dist/memory/conversation-display-backfill.js +0 -91
  46. package/dist/memory/reconstruct-cards.d.ts +0 -27
  47. package/dist/memory/reconstruct-cards.js +0 -57
@@ -20,27 +20,24 @@
20
20
  * misconfigured pool fails loudly at boot, not under load.
21
21
  * 5. `assembleCoreRuntime(core)` — registers the durable-workflow cohort and
22
22
  * builds the conversation agent (register-before-launch invariant).
23
- * 6. `backfillConversationDisplayEnvelopes(...)` — validates every stored
24
- * display envelope and freezes one for each legacy turn that has none, so
25
- * the runtime read path never meets a row without a display projection.
26
- * Runs AFTER assembly because migrating a legacy call's one-line detail
27
- * needs the assembled conversation roster, embedder-contributed tools
28
- * included; assembly starts no traffic, so this is still before any read.
29
- * 7. `beforeLaunch()` — embedder hook for host-specific pre-launch work that
23
+ * 6. `beforeLaunch()` — embedder hook for host-specific pre-launch work that
30
24
  * must attach before DBOS launch re-emits events (scheduled sweeps, an
31
25
  * legacy-workflow reap, an agent-switch install). Runs after registration
32
26
  * so it may close over the registered callables.
33
- * 8. `launchDbos(...)` — the last registration-dependent step.
27
+ * 7. `launchDbos(...)` — the last registration-dependent step.
28
+ *
29
+ * No step reads or rewrites stored conversation display. A boot that validated
30
+ * every `messages` row is what let a single retired part key fail startup for a
31
+ * whole deployment; the read tolerates such a row by itself now
32
+ * (`parseStoredDisplayEnvelope`), which is where a per-row concern belongs.
34
33
  *
35
34
  * Boot-step errors PROPAGATE (the embedder's composition root catches them and
36
35
  * releases whatever it acquired). Only `shutdown` swallows per-step failures —
37
36
  * once shutdown starts, exiting takes precedence (see `runShutdownSequence`).
38
37
  */
39
- import { unwrapOrThrow } from "../lib/result.js";
40
38
  import { SANDBOX_AGENT_META } from "../agents/sandbox/index.js";
41
39
  import { validateAgentSkills } from "../agents/sandbox/validate-skills.js";
42
40
  import { initCortexState } from "../state/init.js";
43
- import { backfillConversationDisplayEnvelopes } from "../memory/conversation-display-backfill.js";
44
41
  import { assembleCoreRuntime } from "./assemble.js";
45
42
  import { assertConnectionBudget } from "./connection-budget.js";
46
43
  import { launchDbos, shutdownDbos } from "./dbos.js";
@@ -57,15 +54,6 @@ export async function bootHarness(deps) {
57
54
  await initCortexState(pool);
58
55
  await assertConnectionBudget({ pool, logger, config: deps.connectionBudget });
59
56
  const runtime = assembleCoreRuntime(core);
60
- // Assembly registers `conversation` unconditionally, so this resolution can
61
- // never refuse; `unwrapOrThrow` turns the impossible error branch into a
62
- // boot-propagating throw rather than threading a Result through the boot
63
- // sequence, whose steps already fail by propagation.
64
- const conversationAgent = unwrapOrThrow(runtime.agents.forThread("conversation"));
65
- await backfillConversationDisplayEnvelopes({
66
- pool,
67
- tools: conversationAgent.tools,
68
- });
69
57
  await deps.beforeLaunch?.();
70
58
  await launchDbos({ config: deps.dbos, logger });
71
59
  logger.named("boot").info("harness booted", { executorId: deps.dbos.executorId });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `SandboxClient` — the five-method seam every sandbox-backed module in
2
+ * `SandboxClient` — the seam every sandbox-backed module in
3
3
  * the harness sits on. Implementations are backend-selected
4
4
  * (`docker`/`k8s`) by `createSandboxClient()` in `create-sandbox.ts` and
5
5
  * injected at the composition root as a construction-time dependency
@@ -17,20 +17,16 @@
17
17
  * Many execs may fire against the same machine.
18
18
  * - The **exec** lifetime is one `submitExec → awaitExec`.
19
19
  *
20
- * `awaitExec` runs in the workflow body (not as a DBOS step) because
21
- * `DBOS.recv` and `DBOS.writeStream` are body-only.
20
+ * **Every caller of `awaitExec` runs inside a DBOS workflow body.** `DBOS.recv`
21
+ * and `DBOS.writeStream` are body-only, thus the callback transport cannot
22
+ * settle anywhere else, and the poll transport reaches for `DBOS.runStep` and
23
+ * `DBOS.sleepms` defaults. A caller that starts from a live chat turn registers
24
+ * its own workflow and starts it: `tasks/extract-values.ts` and
25
+ * `tasks/derive-table-exec.ts` are the two worked examples. No type carries this
26
+ * rule yet, thus a new caller must read this paragraph.
22
27
  */
23
- import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport, SubmitExecBody } from "./types.js";
28
+ import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody } from "./types.js";
24
29
  export interface SandboxClient {
25
- /**
26
- * The result transport this client awaits under, fixed at construction. A caller that runs outside a
27
- * workflow body reads it: `callback` awaits through `DBOS.recv`, which is body-only, thus such a caller
28
- * must refuse before it starts a container it can never await.
29
- *
30
- * Optional, because a hand-written realization states nothing here. Absent means unknown, and a caller
31
- * treats it as the poll default rather than refusing every composition that omits it.
32
- */
33
- readonly transport?: SandboxTransport;
34
30
  /**
35
31
  * DBOS step (`sandbox.create`) — the spawn half of the two-step create
36
32
  * (see the harness-sandbox-exec spec). Launches the sandbox-base container/Job under the
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `SandboxClient` — the five-method seam every sandbox-backed module in
2
+ * `SandboxClient` — the seam every sandbox-backed module in
3
3
  * the harness sits on. Implementations are backend-selected
4
4
  * (`docker`/`k8s`) by `createSandboxClient()` in `create-sandbox.ts` and
5
5
  * injected at the composition root as a construction-time dependency
@@ -17,7 +17,12 @@
17
17
  * Many execs may fire against the same machine.
18
18
  * - The **exec** lifetime is one `submitExec → awaitExec`.
19
19
  *
20
- * `awaitExec` runs in the workflow body (not as a DBOS step) because
21
- * `DBOS.recv` and `DBOS.writeStream` are body-only.
20
+ * **Every caller of `awaitExec` runs inside a DBOS workflow body.** `DBOS.recv`
21
+ * and `DBOS.writeStream` are body-only, thus the callback transport cannot
22
+ * settle anywhere else, and the poll transport reaches for `DBOS.runStep` and
23
+ * `DBOS.sleepms` defaults. A caller that starts from a live chat turn registers
24
+ * its own workflow and starts it: `tasks/extract-values.ts` and
25
+ * `tasks/derive-table-exec.ts` are the two worked examples. No type carries this
26
+ * rule yet, thus a new caller must read this paragraph.
22
27
  */
23
28
  export {};
@@ -127,7 +127,6 @@ export function createSandboxClient(config) {
127
127
  // poll loop's escalation probe are the same backend inspect.
128
128
  const isAlive = async (ref) => unwrapOrThrow(await ops.isAlive(ref));
129
129
  return {
130
- transport,
131
130
  createSandbox: async (meta, identity) => {
132
131
  // The config states an engine fact (binds preserve host ownership);
133
132
  // which remediation that fact demands — today, a world-writable step
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The container of one report-session derivation, as a registered DBOS workflow.
3
+ *
4
+ * The `derive_table` tool runs inside one live chat turn, and a chat turn is not a workflow body. An await
5
+ * of an exec under the callback transport calls `DBOS.recv`, which a workflow body alone can call. Thus the
6
+ * container lives here, behind a registered workflow, and the tool starts it and awaits the handle.
7
+ *
8
+ * The shape is the shape of `tasks/extract-values.ts`: a body that a test drives directly, a registration
9
+ * that closes over the construction-time deps, and a trigger that starts the workflow and gives the result
10
+ * back. The tool holds the trigger as a plain function, thus no module under `tools/` imports DBOS.
11
+ *
12
+ * The body owns the whole machine lifetime. It creates the container, submits one exec, awaits the terminal
13
+ * result, and tears the container down on both paths. A teardown fault reaches the log alone, because the
14
+ * work is done by then.
15
+ */
16
+ import type { Logger } from "../lib/logger.js";
17
+ import type { SandboxClient } from "../sandbox/client.js";
18
+ import type { ExecResult } from "../sandbox/types.js";
19
+ import { type DeriveTableExecInput } from "../tools/report-session/derive-table.js";
20
+ /** The construction-time deps of the body. The registration closes over them, thus the trigger holds none. */
21
+ export interface DeriveTableExecDeps {
22
+ readonly sandboxClient: SandboxClient;
23
+ /**
24
+ * The checkpointed clock of the deadline. It defaults to `DBOS.now()`, which a replay reads again from
25
+ * the checkpoint. A test injects a fixed clock, thus it drives this body with no launched runtime.
26
+ */
27
+ readonly now?: () => Promise<number>;
28
+ readonly logger?: Logger;
29
+ }
30
+ /**
31
+ * The body, exported so a test drives it without a registered workflow.
32
+ *
33
+ * A fault of any seam call throws. The tool reads a rejection as one short detail, thus the caller needs no
34
+ * result type here.
35
+ */
36
+ export declare function runDeriveTableExecBody(input: DeriveTableExecInput, deps: DeriveTableExecDeps): Promise<ExecResult>;
37
+ /**
38
+ * Register the derivation workflow with DBOS. It gives back the registered callable, thus a trigger
39
+ * dispatches with `DBOS.startWorkflow`.
40
+ */
41
+ export declare function registerDeriveTableExecWorkflow(deps: DeriveTableExecDeps): (input: DeriveTableExecInput) => Promise<ExecResult>;
42
+ /** The per-attempt workflow id. The execution id is unique already, and the nonce keeps a retry distinct. */
43
+ export declare function deriveTableExecWorkflowId(executionId: string, nonce: string): string;
44
+ /**
45
+ * The async edge. It starts the registered workflow and it awaits the terminal result.
46
+ *
47
+ * The tool authorizes the derivation before this call and it revokes after, thus this edge holds no
48
+ * lifecycle of its own. A workflow fault rejects the returned promise.
49
+ */
50
+ export declare function triggerDeriveTableExec(workflow: (input: DeriveTableExecInput) => Promise<ExecResult>, input: DeriveTableExecInput): Promise<ExecResult>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The container of one report-session derivation, as a registered DBOS workflow.
3
+ *
4
+ * The `derive_table` tool runs inside one live chat turn, and a chat turn is not a workflow body. An await
5
+ * of an exec under the callback transport calls `DBOS.recv`, which a workflow body alone can call. Thus the
6
+ * container lives here, behind a registered workflow, and the tool starts it and awaits the handle.
7
+ *
8
+ * The shape is the shape of `tasks/extract-values.ts`: a body that a test drives directly, a registration
9
+ * that closes over the construction-time deps, and a trigger that starts the workflow and gives the result
10
+ * back. The tool holds the trigger as a plain function, thus no module under `tools/` imports DBOS.
11
+ *
12
+ * The body owns the whole machine lifetime. It creates the container, submits one exec, awaits the terminal
13
+ * result, and tears the container down on both paths. A teardown fault reaches the log alone, because the
14
+ * work is done by then.
15
+ */
16
+ import { DBOS } from "@dbos-inc/dbos-sdk";
17
+ import { randomUUID } from "node:crypto";
18
+ import { createNoopLogger } from "../lib/console-logger.js";
19
+ import { mintSandboxIdentity } from "../sandbox/identity.js";
20
+ import { buildDerivationExec, DERIVATION_DEADLINE_MS, DERIVATION_RESOURCES, DERIVE_RUN_LITERAL, DERIVE_STEP_LITERAL, } from "../tools/report-session/derive-table.js";
21
+ /** The awaitExec callback. A derivation reports no live activity, thus the callback drops each event. */
22
+ const noopEmit = () => { };
23
+ /**
24
+ * The body, exported so a test drives it without a registered workflow.
25
+ *
26
+ * A fault of any seam call throws. The tool reads a rejection as one short detail, thus the caller needs no
27
+ * result type here.
28
+ */
29
+ export async function runDeriveTableExecBody(input, deps) {
30
+ const logger = (deps.logger ?? createNoopLogger()).named("derive-table-exec").with({ analysisId: input.analysisId });
31
+ const workflowId = DBOS.workflowID ?? `${DERIVE_RUN_LITERAL}:${input.executionId}`;
32
+ const sandbox = await deps.sandboxClient.createSandbox({
33
+ runId: DERIVE_RUN_LITERAL,
34
+ stepId: DERIVE_STEP_LITERAL,
35
+ analysisId: input.analysisId,
36
+ childWorkflowId: workflowId,
37
+ resources: DERIVATION_RESOURCES,
38
+ writableTail: input.writableTail,
39
+ }, mintSandboxIdentity(DERIVE_RUN_LITERAL));
40
+ try {
41
+ // A checkpointed clock, not `Date.now()`: the await gates on this absolute deadline, and a
42
+ // wall-clock deadline that grew on replay would shift which loop iteration crosses it.
43
+ const deadline = (await (deps.now ?? (() => DBOS.now()))()) + DERIVATION_DEADLINE_MS;
44
+ await deps.sandboxClient.submitExec(sandbox, buildDerivationExec({
45
+ script: input.script,
46
+ execId: input.executionId,
47
+ workingDir: input.workingDir,
48
+ inputs: input.inputs,
49
+ output: input.output,
50
+ }));
51
+ return await deps.sandboxClient.awaitExec(sandbox, input.executionId, noopEmit, deadline);
52
+ }
53
+ finally {
54
+ try {
55
+ await deps.sandboxClient.teardown(sandbox);
56
+ }
57
+ catch (cause) {
58
+ logger.warn("the derivation sandbox did not tear down", logger.errorFields(cause));
59
+ }
60
+ }
61
+ }
62
+ /**
63
+ * Register the derivation workflow with DBOS. It gives back the registered callable, thus a trigger
64
+ * dispatches with `DBOS.startWorkflow`.
65
+ */
66
+ export function registerDeriveTableExecWorkflow(deps) {
67
+ return DBOS.registerWorkflow((input) => runDeriveTableExecBody(input, deps), { name: "derive-table-exec" });
68
+ }
69
+ /** The per-attempt workflow id. The execution id is unique already, and the nonce keeps a retry distinct. */
70
+ export function deriveTableExecWorkflowId(executionId, nonce) {
71
+ return `${DERIVE_RUN_LITERAL}:${executionId}:${nonce}`;
72
+ }
73
+ /**
74
+ * The async edge. It starts the registered workflow and it awaits the terminal result.
75
+ *
76
+ * The tool authorizes the derivation before this call and it revokes after, thus this edge holds no
77
+ * lifecycle of its own. A workflow fault rejects the returned promise.
78
+ */
79
+ export async function triggerDeriveTableExec(workflow, input) {
80
+ const handle = (await DBOS.startWorkflow(workflow, {
81
+ workflowID: deriveTableExecWorkflowId(input.executionId, randomUUID()),
82
+ })(input));
83
+ return handle.getResult();
84
+ }
@@ -39,9 +39,9 @@ export type ToolExecutionMode = "step" | "workflow" | "inline";
39
39
  /** Runtime guard — does a `Result`'s error value carry the `ToolError` shape? */
40
40
  export declare function isToolError(value: unknown): value is ToolError;
41
41
  /**
42
- * The picture that a tool ok value carries beside its JSON data. `base64` holds
42
+ * One picture that a tool ok value carries beside its JSON data. `base64` holds
43
43
  * the bytes, and `mediaType` names the IANA type, for example "image/png". The
44
- * loop splits the picture out of the JSON text, and it rides the tool result as
44
+ * loop splits each picture out of the JSON text, and it rides the tool result as
45
45
  * an image content block. Thus the model sees the picture, and the JSON text
46
46
  * holds no bytes.
47
47
  */
@@ -50,23 +50,34 @@ export interface ToolResultImage {
50
50
  readonly mediaType: string;
51
51
  }
52
52
  /**
53
- * The reserved key that carries a picture on a tool ok value. It is a symbol,
53
+ * The reserved key that carries the pictures on a tool ok value. It is a symbol,
54
54
  * thus it never collides with a data field. `JSON.stringify` also omits a symbol
55
55
  * key, thus the bytes never reach the JSON text by accident.
56
56
  */
57
57
  export declare const toolResultImageKey: unique symbol;
58
- /** A tool ok value that can carry a picture under the reserved key. */
58
+ /** A tool ok value that can carry one picture or an ordered list of them under the reserved key. */
59
59
  export type WithToolResultImage<T> = T & {
60
- readonly [toolResultImageKey]?: ToolResultImage;
60
+ readonly [toolResultImageKey]?: ToolResultImage | readonly ToolResultImage[];
61
61
  };
62
62
  /**
63
- * Attach a picture to a tool ok value. The value keeps its own fields, and the
63
+ * Attach one picture to a tool ok value. The value keeps its own fields, and the
64
64
  * picture rides under the reserved key. The loop reads the key, and it moves the
65
65
  * bytes into an image content block on the tool result.
66
66
  */
67
67
  export declare function withToolResultImage<T extends object>(value: T, image: ToolResultImage): WithToolResultImage<T>;
68
- /** Read the picture that a tool ok value carries, or undefined when it carries none. */
69
- export declare function readToolResultImage(value: unknown): ToolResultImage | undefined;
68
+ /**
69
+ * Attach an ordered list of pictures to a tool ok value. The order is the order
70
+ * that the model reads, and the loop keeps it on the wire. A tool that slices a
71
+ * page attaches the slices in document order.
72
+ */
73
+ export declare function withToolResultImages<T extends object>(value: T, images: readonly ToolResultImage[]): WithToolResultImage<T>;
74
+ /**
75
+ * Read the pictures that a tool ok value carries, in order. A value with none
76
+ * gives the empty list. The one-picture shape reads as a list of one, thus a
77
+ * tool that attaches one picture and a tool that attaches a list meet the loop
78
+ * in the same shape.
79
+ */
80
+ export declare function readToolResultImages(value: unknown): ToolResultImage[];
70
81
  /**
71
82
  * The request-scoped values passed to every tool's `execute`. No injected
72
83
  * dependencies (see the harness-durable-runtime spec) — invocation identity,
@@ -21,30 +21,46 @@ export function isToolError(value) {
21
21
  return typeof value === "object" && value !== null && typeof value.error === "string" && typeof value.retryable === "boolean";
22
22
  }
23
23
  /**
24
- * The reserved key that carries a picture on a tool ok value. It is a symbol,
24
+ * The reserved key that carries the pictures on a tool ok value. It is a symbol,
25
25
  * thus it never collides with a data field. `JSON.stringify` also omits a symbol
26
26
  * key, thus the bytes never reach the JSON text by accident.
27
27
  */
28
28
  export const toolResultImageKey = Symbol("toolResultImage");
29
29
  /**
30
- * Attach a picture to a tool ok value. The value keeps its own fields, and the
30
+ * Attach one picture to a tool ok value. The value keeps its own fields, and the
31
31
  * picture rides under the reserved key. The loop reads the key, and it moves the
32
32
  * bytes into an image content block on the tool result.
33
33
  */
34
34
  export function withToolResultImage(value, image) {
35
35
  return { ...value, [toolResultImageKey]: image };
36
36
  }
37
- /** Read the picture that a tool ok value carries, or undefined when it carries none. */
38
- export function readToolResultImage(value) {
37
+ /**
38
+ * Attach an ordered list of pictures to a tool ok value. The order is the order
39
+ * that the model reads, and the loop keeps it on the wire. A tool that slices a
40
+ * page attaches the slices in document order.
41
+ */
42
+ export function withToolResultImages(value, images) {
43
+ return { ...value, [toolResultImageKey]: images };
44
+ }
45
+ /** Does one entry under the reserved key carry the picture shape? */
46
+ function isToolResultImage(entry) {
47
+ if (typeof entry !== "object" || entry === null)
48
+ return false;
49
+ const { base64, mediaType } = entry;
50
+ return typeof base64 === "string" && typeof mediaType === "string";
51
+ }
52
+ /**
53
+ * Read the pictures that a tool ok value carries, in order. A value with none
54
+ * gives the empty list. The one-picture shape reads as a list of one, thus a
55
+ * tool that attaches one picture and a tool that attaches a list meet the loop
56
+ * in the same shape.
57
+ */
58
+ export function readToolResultImages(value) {
39
59
  if (typeof value !== "object" || value === null)
40
- return undefined;
41
- const image = value[toolResultImageKey];
42
- if (typeof image !== "object" || image === null)
43
- return undefined;
44
- const { base64, mediaType } = image;
45
- if (typeof base64 !== "string" || typeof mediaType !== "string")
46
- return undefined;
47
- return { base64, mediaType };
60
+ return [];
61
+ const carried = value[toolResultImageKey];
62
+ const entries = Array.isArray(carried) ? carried : [carried];
63
+ return entries.filter(isToolResultImage);
48
64
  }
49
65
  /**
50
66
  * Package a `Tool`. The emitted JSON Schema must have a top-level
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * A session derivation is not an analysis run. It mints no run id, it registers no artifact, and it writes
9
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
10
+ * the injected exec runner, the identity mint, and the authorizer on every terminal path. The container is
11
11
  * ephemeral, and it goes away with the work.
12
12
  *
13
13
  * The container mounts the analysis tree read-only, and one write mount covers the `derived/` directory of
@@ -29,15 +29,14 @@
29
29
  * condition one time, up front, the same discipline as the eyes. A per-attempt failure would instead read as
30
30
  * a transient fault, and it would invite a repeat of a call that can never pass.
31
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.
32
+ * The container runs behind a seam, and never in the turn. An await of an exec is a workflow-body call
33
+ * under the callback transport, and a report turn is not a body. Thus the tool takes an injected runner,
34
+ * and a registered workflow owns the container. The tool holds no sandbox client, and it imports no DBOS.
36
35
  */
37
36
  import { z } from "zod";
38
37
  import type { RunAuthorizer } from "../../execution/run-authorizer.js";
39
38
  import { type Logger } from "../../lib/logger.js";
40
- import type { SandboxClient } from "../../sandbox/client.js";
39
+ import type { ResourceSpec } from "../../config/resource-limits.js";
41
40
  import type { ExecResult, SubmitExecBody } from "../../sandbox/types.js";
42
41
  import type { DerivationSource, ReportSessionStateStore } from "../../state/report-session-state.js";
43
42
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
@@ -113,10 +112,20 @@ export interface DeriveTableToolDeps {
113
112
  readonly gateway: ReportSessionStateGateway;
114
113
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
115
114
  readonly derivations: Pick<ReportSessionStateStore, "appendDerivation">;
116
- readonly sandboxClient?: SandboxClient;
115
+ readonly runDerivation?: DeriveTableRunner;
117
116
  readonly runAuthorizer?: RunAuthorizer;
118
117
  readonly logger?: Logger;
119
118
  }
119
+ /** The synthetic run id and step id of one derivation. Both are constants, and neither names a run row. */
120
+ export declare const DERIVE_RUN_LITERAL: "derive-table";
121
+ export declare const DERIVE_STEP_LITERAL: "derive";
122
+ /** The exec budget of one derivation. It matches the budget of the value tier. */
123
+ export declare const DERIVATION_DEADLINE_MS = 300000;
124
+ /**
125
+ * The container size of one derivation. A join or a pivot loads each declared input into pandas at one
126
+ * time, thus the container needs the headroom of the value tier.
127
+ */
128
+ export declare const DERIVATION_RESOURCES: ResourceSpec;
120
129
  /** The environment variable that carries the declared inputs into the container. */
121
130
  export declare const DERIVE_INPUT_ENV = "DERIVE_INPUTS";
122
131
  /** The environment variable that carries the container path of the output file into the container. */
@@ -152,6 +161,29 @@ export declare function buildDerivationExec(args: {
152
161
  * The function is pure, thus a test asserts the mapping without a sandbox.
153
162
  */
154
163
  export declare function describeExecFailure(result: ExecResult): string | undefined;
164
+ /**
165
+ * The exec of one derivation, as a registered workflow reads it.
166
+ *
167
+ * Each field is plain data, thus the value crosses the workflow boundary and it survives a replay. The
168
+ * paths are container paths already: the host maps them one time, before the start.
169
+ */
170
+ export interface DeriveTableExecInput {
171
+ readonly analysisId: string;
172
+ readonly executionId: string;
173
+ readonly script: string;
174
+ readonly writableTail: string;
175
+ readonly workingDir: string;
176
+ readonly inputs: readonly DerivationInputMount[];
177
+ readonly output: string;
178
+ }
179
+ /**
180
+ * Run one derivation exec and give the terminal result back.
181
+ *
182
+ * The composition realizes it over a registered workflow, thus the container lives inside a workflow body
183
+ * and the await is legal under each transport. A fault of the sandbox rejects the promise, and the tool
184
+ * turns that rejection into one short detail.
185
+ */
186
+ export type DeriveTableRunner = (input: DeriveTableExecInput) => Promise<ExecResult>;
155
187
  /**
156
188
  * Make the derivation tool over the session-state gateway, the derivation ledger, and the sandbox rails.
157
189
  *
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * A session derivation is not an analysis run. It mints no run id, it registers no artifact, and it writes
9
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
10
+ * the injected exec runner, the identity mint, and the authorizer on every terminal path. The container is
11
11
  * ephemeral, and it goes away with the work.
12
12
  *
13
13
  * The container mounts the analysis tree read-only, and one write mount covers the `derived/` directory of
@@ -29,12 +29,11 @@
29
29
  * condition one time, up front, the same discipline as the eyes. A per-attempt failure would instead read as
30
30
  * a transient fault, and it would invite a repeat of a call that can never pass.
31
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.
32
+ * The container runs behind a seam, and never in the turn. An await of an exec is a workflow-body call
33
+ * under the callback transport, and a report turn is not a body. Thus the tool takes an injected runner,
34
+ * and a registered workflow owns the container. The tool holds no sandbox client, and it imports no DBOS.
36
35
  */
37
- import { err, ok } from "neverthrow";
36
+ import { ok } from "neverthrow";
38
37
  import { unlink } from "node:fs/promises";
39
38
  import { join } from "node:path";
40
39
  import { z } from "zod";
@@ -43,7 +42,6 @@ import { classifyWithinRoot, computeSha256, computeSha256File } from "../../lib/
43
42
  import { defaultErrorFields } from "../../lib/logger.js";
44
43
  import { snapshotEntry } from "../../report-model/reference-resolver.js";
45
44
  import { generateExecutionId } from "../../sandbox/execution-id.js";
46
- import { mintSandboxIdentity } from "../../sandbox/identity.js";
47
45
  import { isSafeId, reportSessionDerivedDir, toSandboxPath } from "../../workspace/paths.js";
48
46
  import { defineTool } from "../define-tool.js";
49
47
  import { openReportThread } from "../report-authoring/authoring-tools.js";
@@ -55,17 +53,17 @@ const deriveTableInput = z.object({
55
53
  output: z.string().min(1),
56
54
  });
57
55
  /** 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";
56
+ export const DERIVE_RUN_LITERAL = "derive-table";
57
+ export const DERIVE_STEP_LITERAL = "derive";
60
58
  /** The provenance agent id. No agent loop runs in the container, thus this names the derivation pass. */
61
59
  const DERIVE_AGENT_ID = "table-deriver";
62
60
  /** The exec budget of one derivation. It matches the budget of the value tier. */
63
- const DERIVATION_DEADLINE_MS = 300_000;
61
+ export const DERIVATION_DEADLINE_MS = 300_000;
64
62
  /**
65
63
  * The container size of one derivation. A join or a pivot loads each declared input into pandas at one
66
64
  * time, thus the container needs the headroom of the value tier.
67
65
  */
68
- const DERIVATION_RESOURCES = { cpu: 2, memoryGb: 8 };
66
+ export const DERIVATION_RESOURCES = { cpu: 2, memoryGb: 8 };
69
67
  /** The cap of the script, in bytes. A reshaping script sits far under it, and the command line carries it. */
70
68
  const SCRIPT_CAP_BYTES = 64 * 1024;
71
69
  /** The cap of the declared inputs. One derivation reads the evidence of a few artifacts, and never a tree. */
@@ -76,12 +74,8 @@ export const DERIVE_INPUT_ENV = "DERIVE_INPUTS";
76
74
  export const DERIVE_OUTPUT_ENV = "DERIVE_OUTPUT";
77
75
  /** The cap of the failure detail that the tool copies out of the standard error of the script. */
78
76
  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
77
  /** The line that the agent reads when the composition binds no sandbox rails. */
82
78
  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
79
  /**
86
80
  * Build the sandbox exec of one derivation. The command runs the script of the agent through `python3 -c`.
87
81
  *
@@ -126,58 +120,6 @@ export function describeExecFailure(result) {
126
120
  }
127
121
  return undefined;
128
122
  }
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
123
  /**
182
124
  * Make the derivation tool over the session-state gateway, the derivation ledger, and the sandbox rails.
183
125
  *
@@ -186,7 +128,7 @@ async function runDerivationExec(args) {
186
128
  */
187
129
  export function createDeriveTableTool(deps) {
188
130
  const logger = (deps.logger ?? createNoopLogger()).named("derive-table");
189
- const { sandboxClient, runAuthorizer } = deps;
131
+ const { runDerivation, runAuthorizer } = deps;
190
132
  return defineTool({
191
133
  id: "derive_table",
192
134
  description: "Derive one table from the pinned evidence with a Python script, and pin the result to this session. " +
@@ -209,16 +151,10 @@ export function createDeriveTableTool(deps) {
209
151
  execute: async (input, ctx) => {
210
152
  // The check runs before every read, thus one clear signal replaces a failure for each attempt.
211
153
  // It also narrows the two rails below, thus the exec needs no assertion.
212
- if (sandboxClient === undefined || runAuthorizer === undefined) {
154
+ if (runDerivation === undefined || runAuthorizer === undefined) {
213
155
  logger.warn("the composition gives no sandbox, thus no derivation can run");
214
156
  return ok({ outcome: "unavailable", detail: NO_SANDBOX_DETAIL });
215
157
  }
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
158
  const opened = await openReportThread(deps.gateway, ctx.session.scope);
223
159
  if (opened.isErr()) {
224
160
  return ok({ outcome: "refused", refusal: opened.error });
@@ -304,7 +240,7 @@ export function createDeriveTableTool(deps) {
304
240
  let result;
305
241
  try {
306
242
  result = await derive({
307
- client: sandboxClient,
243
+ runDerivation,
308
244
  derivations: deps.derivations,
309
245
  root,
310
246
  threadId,
@@ -363,8 +299,9 @@ async function derive(args) {
363
299
  return { outcome: "exec-failed", detail: `${args.outputName} already holds a file that did not clear, thus this derivation takes a new name` };
364
300
  }
365
301
  }
366
- const executed = await runDerivationExec({
367
- client: args.client,
302
+ // The path mapper refuses a stored path that escapes the root, with a throw. It runs before the try,
303
+ // thus that refusal stays a throw and it never reads as a fault of the container.
304
+ const execInput = {
368
305
  analysisId: args.analysisId,
369
306
  executionId,
370
307
  script: args.script,
@@ -372,12 +309,16 @@ async function derive(args) {
372
309
  workingDir,
373
310
  inputs: args.sources.map((source) => ({ path: toSandboxPath(args.root, args.analysisId, join(args.root, source.path)), hash: source.hash })),
374
311
  output: toSandboxPath(args.root, args.analysisId, absolute),
375
- logger: args.logger,
376
- });
377
- if (executed.isErr()) {
378
- return { outcome: "exec-failed", detail: executed.error };
312
+ };
313
+ let executed;
314
+ try {
315
+ executed = await args.runDerivation(execInput);
316
+ }
317
+ catch (cause) {
318
+ args.logger.error("the derivation exec did not complete", { threadId: args.threadId, ...defaultErrorFields(cause) });
319
+ return { outcome: "exec-failed", detail: "the derivation exec did not complete" };
379
320
  }
380
- const failure = describeExecFailure(executed.value);
321
+ const failure = describeExecFailure(executed);
381
322
  if (failure !== undefined) {
382
323
  return { outcome: "exec-failed", detail: failure };
383
324
  }