@inflexa-ai/harness 0.19.2 → 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.
Files changed (139) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +31 -6
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/literature/sources/pubmed.js +2 -1
  27. package/dist/loop/run-agent.js +58 -23
  28. package/dist/loop/tool-detail.d.ts +19 -2
  29. package/dist/loop/tool-detail.js +35 -2
  30. package/dist/memory/content-to-cortex.d.ts +3 -2
  31. package/dist/memory/content-to-cortex.js +21 -12
  32. package/dist/memory/conversation-display-replay.js +14 -7
  33. package/dist/memory/thread-history.d.ts +60 -3
  34. package/dist/memory/thread-history.js +55 -16
  35. package/dist/memory/thread-store.d.ts +11 -1
  36. package/dist/memory/thread-store.js +12 -2
  37. package/dist/prompts/conversation.d.ts +1 -1
  38. package/dist/prompts/conversation.js +23 -6
  39. package/dist/prompts/report-session.d.ts +1 -1
  40. package/dist/prompts/report-session.js +183 -9
  41. package/dist/report-model/authoring-grammar.d.ts +463 -0
  42. package/dist/report-model/authoring-grammar.js +77 -0
  43. package/dist/report-model/block-walk.d.ts +57 -14
  44. package/dist/report-model/block-walk.js +116 -11
  45. package/dist/report-model/draft-finish.d.ts +22 -4
  46. package/dist/report-model/draft-finish.js +39 -7
  47. package/dist/report-model/draft-operations.d.ts +3 -0
  48. package/dist/report-model/draft-operations.js +19 -5
  49. package/dist/report-model/draft-read.d.ts +8 -5
  50. package/dist/report-model/draft-read.js +21 -5
  51. package/dist/report-model/fixture-resolver.js +15 -4
  52. package/dist/report-model/pin-snapshot.d.ts +24 -6
  53. package/dist/report-model/pin-snapshot.js +204 -6
  54. package/dist/report-model/production-resolver.js +15 -4
  55. package/dist/report-model/reference-resolver.d.ts +50 -1
  56. package/dist/report-model/reference-resolver.js +94 -1
  57. package/dist/report-model/reference-stamp.d.ts +45 -0
  58. package/dist/report-model/reference-stamp.js +173 -0
  59. package/dist/report-model/structural-validation.d.ts +4 -1
  60. package/dist/report-model/structural-validation.js +48 -3
  61. package/dist/report-model/validate.d.ts +1 -1
  62. package/dist/report-model/validate.js +1 -1
  63. package/dist/report-render/asset-lookup.d.ts +19 -0
  64. package/dist/report-render/asset-lookup.js +37 -0
  65. package/dist/report-render/assets.d.ts +45 -1
  66. package/dist/report-render/assets.js +65 -6
  67. package/dist/report-render/chart-presets.d.ts +102 -0
  68. package/dist/report-render/chart-presets.js +205 -0
  69. package/dist/report-render/chart.d.ts +112 -10
  70. package/dist/report-render/chart.js +1164 -41
  71. package/dist/report-render/design.d.ts +99 -0
  72. package/dist/report-render/design.js +289 -107
  73. package/dist/report-render/number-format.d.ts +117 -0
  74. package/dist/report-render/number-format.js +479 -0
  75. package/dist/report-render/page.d.ts +121 -5
  76. package/dist/report-render/page.js +689 -7
  77. package/dist/report-render/references.d.ts +70 -16
  78. package/dist/report-render/references.js +54 -22
  79. package/dist/report-render/render.d.ts +23 -5
  80. package/dist/report-render/render.js +81 -22
  81. package/dist/report-render/table-data.d.ts +92 -0
  82. package/dist/report-render/table-data.js +129 -0
  83. package/dist/report-render/types.d.ts +15 -0
  84. package/dist/report-render/value-bridge.js +6 -2
  85. package/dist/report-render/views/chart-view.d.ts +12 -5
  86. package/dist/report-render/views/chart-view.js +14 -8
  87. package/dist/report-render/views/page-view.d.ts +36 -15
  88. package/dist/report-render/views/page-view.js +45 -23
  89. package/dist/report-render/views/prose.d.ts +12 -2
  90. package/dist/report-render/views/prose.js +29 -7
  91. package/dist/report-render/views/references-view.d.ts +22 -8
  92. package/dist/report-render/views/references-view.js +101 -13
  93. package/dist/report-render/views/values.d.ts +128 -13
  94. package/dist/report-render/views/values.js +199 -18
  95. package/dist/runtime/assemble.d.ts +39 -5
  96. package/dist/runtime/assemble.js +47 -6
  97. package/dist/sandbox/client.d.ts +10 -1
  98. package/dist/sandbox/create-sandbox.d.ts +9 -4
  99. package/dist/sandbox/create-sandbox.js +22 -16
  100. package/dist/sandbox/docker-client.js +10 -7
  101. package/dist/sandbox/k8s-client.js +11 -18
  102. package/dist/sandbox/mount-plan.d.ts +45 -7
  103. package/dist/sandbox/mount-plan.js +43 -5
  104. package/dist/sandbox/types.d.ts +10 -6
  105. package/dist/state/init.js +38 -9
  106. package/dist/state/report-session-state.d.ts +42 -1
  107. package/dist/state/report-session-state.js +70 -2
  108. package/dist/state/report-versions.d.ts +22 -17
  109. package/dist/state/report-versions.js +48 -35
  110. package/dist/state/snapshot-parse.js +16 -1
  111. package/dist/tasks/data-profile.d.ts +7 -4
  112. package/dist/tasks/data-profile.js +9 -9
  113. package/dist/tools/define-tool.d.ts +32 -0
  114. package/dist/tools/define-tool.js +4 -0
  115. package/dist/tools/detail-resolver.d.ts +7 -2
  116. package/dist/tools/detail-resolver.js +7 -2
  117. package/dist/tools/iterate-report.d.ts +1 -1
  118. package/dist/tools/report/preview-snapshot.js +1 -0
  119. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  120. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  121. package/dist/tools/report-session/derive-table.d.ts +162 -0
  122. package/dist/tools/report-session/derive-table.js +459 -0
  123. package/dist/tools/report-session/examine-page.d.ts +48 -18
  124. package/dist/tools/report-session/examine-page.js +204 -24
  125. package/dist/tools/report-session/index.d.ts +2 -0
  126. package/dist/tools/report-session/index.js +2 -0
  127. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  128. package/dist/tools/report-session/list-artifacts.js +197 -0
  129. package/dist/tools/report-session/preview-report.d.ts +6 -0
  130. package/dist/tools/report-session/preview-report.js +180 -28
  131. package/dist/tools/report-session/record-version.d.ts +22 -10
  132. package/dist/tools/report-session/record-version.js +99 -14
  133. package/dist/tools/start-report-session.d.ts +115 -0
  134. package/dist/tools/start-report-session.js +179 -0
  135. package/dist/workflows/sandbox-step.d.ts +7 -4
  136. package/dist/workflows/sandbox-step.js +10 -12
  137. package/dist/workspace/paths.d.ts +50 -0
  138. package/dist/workspace/paths.js +63 -0
  139. package/package.json +2 -1
@@ -120,18 +120,23 @@ export interface CreateSandboxClientConfig {
120
120
  */
121
121
  export declare function composeAwaitOptions(base: AwaitExecOptions | undefined, transport: SandboxTransport, isAlive: NonNullable<AwaitExecOptions["isAlive"]>): AwaitExecOptions;
122
122
  /**
123
- * Backend-agnostic pre-creation of the writable step tree under the analysis's
123
+ * Backend-agnostic pre-creation of the writable tree under the analysis's
124
124
  * resolved workspace root. On Docker that root is the host dir the container
125
125
  * binds; on K8s it lives under the session PVC the sandbox pod mounts via
126
126
  * subPath. `mkdir(recursive)` is idempotent, so an existing tree (replay, retry)
127
- * is not an error. A read-only sandbox has no writable step mount, so there is
127
+ * is not an error. A read-only sandbox has no writable mount, so there is
128
128
  * nothing to pre-create.
129
129
  *
130
- * `stepTreeAccess: "world-writable"` chmods the step dir and each subdir so the
130
+ * The writable tree is the step directory with its five artifact subdirs, or the
131
+ * declared write tail as one directory. `sandboxWriteTail` decides which, so the
132
+ * directory the host makes and the directory the sandbox mounts are the same one,
133
+ * and the run path keeps the tree it always had.
134
+ *
135
+ * `stepTreeAccess: "world-writable"` chmods the directory and each subdir so the
131
136
  * uid-1000 sandbox workload can write them through engines that honor host bind
132
137
  * ownership. The chmod is explicit, not `mkdir`'s `mode` option: the process
133
138
  * umask masks `mkdir`'s mode, and on replay the dirs already exist so `mkdir`
134
- * would not touch them either way. Scoped to the step write tree — the
139
+ * would not touch them either way. Scoped to the write tree — the
135
140
  * read-only mount sources are never re-moded. Exported for tests.
136
141
  */
137
142
  export declare function precreateStepTree(deps: {
@@ -14,14 +14,14 @@
14
14
  import { chmod, mkdir } from "node:fs/promises";
15
15
  import { join } from "node:path";
16
16
  import { clampResources } from "../config/resource-limits.js";
17
- import { stepWritePrefix } from "../workspace/paths.js";
17
+ import { tailWritePrefix } from "../workspace/paths.js";
18
18
  import { tryMutation } from "../lib/db-result.js";
19
19
  import { unwrapOrThrow } from "../lib/result.js";
20
20
  import { clearSandboxRef, setSandboxRef } from "../state/index.js";
21
21
  import { awaitExec } from "./await-exec.js";
22
22
  import { createDockerSandboxOps } from "./docker-client.js";
23
23
  import { createK8sSandboxOps } from "./k8s-client.js";
24
- import { STEP_SUBDIRS } from "./mount-plan.js";
24
+ import { sandboxWriteTail } from "./mount-plan.js";
25
25
  import { submitExec } from "./submit-exec.js";
26
26
  import { toPersistedRef } from "./types.js";
27
27
  /**
@@ -35,33 +35,38 @@ export function composeAwaitOptions(base, transport, isAlive) {
35
35
  return { isAlive, ...base, transport };
36
36
  }
37
37
  /**
38
- * Backend-agnostic pre-creation of the writable step tree under the analysis's
38
+ * Backend-agnostic pre-creation of the writable tree under the analysis's
39
39
  * resolved workspace root. On Docker that root is the host dir the container
40
40
  * binds; on K8s it lives under the session PVC the sandbox pod mounts via
41
41
  * subPath. `mkdir(recursive)` is idempotent, so an existing tree (replay, retry)
42
- * is not an error. A read-only sandbox has no writable step mount, so there is
42
+ * is not an error. A read-only sandbox has no writable mount, so there is
43
43
  * nothing to pre-create.
44
44
  *
45
- * `stepTreeAccess: "world-writable"` chmods the step dir and each subdir so the
45
+ * The writable tree is the step directory with its five artifact subdirs, or the
46
+ * declared write tail as one directory. `sandboxWriteTail` decides which, so the
47
+ * directory the host makes and the directory the sandbox mounts are the same one,
48
+ * and the run path keeps the tree it always had.
49
+ *
50
+ * `stepTreeAccess: "world-writable"` chmods the directory and each subdir so the
46
51
  * uid-1000 sandbox workload can write them through engines that honor host bind
47
52
  * ownership. The chmod is explicit, not `mkdir`'s `mode` option: the process
48
53
  * umask masks `mkdir`'s mode, and on replay the dirs already exist so `mkdir`
49
- * would not touch them either way. Scoped to the step write tree — the
54
+ * would not touch them either way. Scoped to the write tree — the
50
55
  * read-only mount sources are never re-moded. Exported for tests.
51
56
  */
52
57
  export async function precreateStepTree(deps, meta) {
53
- if (meta.readOnly)
58
+ const write = sandboxWriteTail(meta);
59
+ if (write === undefined)
54
60
  return;
55
- const stepDir = stepWritePrefix({
56
- workspaceRoot: deps.resolveWorkspaceRoot(meta.analysisId),
57
- runId: meta.runId,
58
- stepId: meta.stepId,
59
- });
60
- await mkdir(stepDir, { recursive: true });
61
- await Promise.all(STEP_SUBDIRS.map((sub) => mkdir(join(stepDir, sub), { recursive: true })));
61
+ // `tailWritePrefix` (not a raw `join`) so every segment runs through the same
62
+ // id validation the mount builders apply — a crafted tail cannot escape the
63
+ // resolved root.
64
+ const writeDir = tailWritePrefix({ workspaceRoot: deps.resolveWorkspaceRoot(meta.analysisId), tail: write.tail });
65
+ await mkdir(writeDir, { recursive: true });
66
+ await Promise.all(write.subdirs.map((sub) => mkdir(join(writeDir, sub), { recursive: true })));
62
67
  if (deps.stepTreeAccess === "world-writable") {
63
- await chmod(stepDir, 0o777);
64
- await Promise.all(STEP_SUBDIRS.map((sub) => chmod(join(stepDir, sub), 0o777)));
68
+ await chmod(writeDir, 0o777);
69
+ await Promise.all(write.subdirs.map((sub) => chmod(join(writeDir, sub), 0o777)));
65
70
  }
66
71
  }
67
72
  export function createSandboxClient(config) {
@@ -122,6 +127,7 @@ export function createSandboxClient(config) {
122
127
  // poll loop's escalation probe are the same backend inspect.
123
128
  const isAlive = async (ref) => unwrapOrThrow(await ops.isAlive(ref));
124
129
  return {
130
+ transport,
125
131
  createSandbox: async (meta, identity) => {
126
132
  // The config states an engine fact (binds preserve host ownership);
127
133
  // which remediation that fact demands — today, a world-writable step
@@ -33,9 +33,9 @@ import { join } from "node:path";
33
33
  import Docker from "dockerode";
34
34
  import { ResultAsync, err, ok } from "neverthrow";
35
35
  import { createNoopLogger } from "../lib/console-logger.js";
36
- import { stepWritePrefix } from "../workspace/paths.js";
36
+ import { tailWritePrefix } from "../workspace/paths.js";
37
37
  import { trySandbox } from "./sandbox-error.js";
38
- import { buildMountPlan } from "./mount-plan.js";
38
+ import { buildMountPlan, sandboxWriteTail } from "./mount-plan.js";
39
39
  /** Read the originating HTTP status off any `SandboxError` variant that carries one. */
40
40
  function statusOf(e) {
41
41
  return "status" in e ? e.status : undefined;
@@ -223,13 +223,16 @@ export function createDockerSandboxOps(config) {
223
223
  refs: refsMounted,
224
224
  });
225
225
  const hostTreePath = config.resolveWorkspaceRoot(meta.analysisId);
226
- // `stepWritePrefix` (not a raw `join`) so the RW bind source runs through
227
- // the same id validation as the pre-created step tree — a crafted stepId
228
- // cannot escape the resolved root into the container.
229
- const hostStepPath = stepWritePrefix({ workspaceRoot: hostTreePath, runId: meta.runId, stepId: meta.stepId });
226
+ // `tailWritePrefix` (not a raw `join`) so the RW bind source runs through
227
+ // the same validation as the pre-created write tree — a crafted stepId or
228
+ // a crafted tail cannot escape the resolved root into the container. The
229
+ // tail is the step directory, or the one the caller declared.
230
+ const write = sandboxWriteTail(meta);
230
231
  const binds = [
231
232
  `${hostTreePath}:${plan.readonlyTreePath}:ro`,
232
- ...(plan.writableStepPath ? [`${hostStepPath}:${plan.writableStepPath}:rw`] : []),
233
+ ...(write && plan.writableStepPath
234
+ ? [`${tailWritePrefix({ workspaceRoot: hostTreePath, tail: write.tail })}:${plan.writableStepPath}:rw`]
235
+ : []),
233
236
  ...(libsMounted && config.libStorePath ? [`${config.libStorePath}:${plan.libsPath}:ro`] : []),
234
237
  ...(refsMounted && config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
235
238
  ];
@@ -32,10 +32,6 @@ const MANAGED_BY_VALUE = "cortex";
32
32
  const OWNER_WORKFLOW_LABEL = "cortex/owner-workflow-id";
33
33
  const RUN_ID_LABEL = "cortex/run-id";
34
34
  const STEP_ID_LABEL = "cortex/step-id";
35
- // OpenCost sanitizes `/` and `-` to `_`, so these arrive at the metering
36
- // reconciler as cortex_billing_context / cortex_user_id / cortex_analysis_id.
37
- const BILLING_CONTEXT_LABEL = "cortex/billing-context";
38
- const USER_ID_LABEL = "cortex/user-id";
39
35
  const ANALYSIS_ID_LABEL = "cortex/analysis-id";
40
36
  /**
41
37
  * Coerce an arbitrary identifier into a valid K8s label value:
@@ -219,17 +215,15 @@ function buildJobSpec(meta, config, identity) {
219
215
  podSpec.tolerations = config.tolerations;
220
216
  if (config.runtimeClassName)
221
217
  podSpec.runtimeClassName = config.runtimeClassName;
222
- // Pod-template placement is load-bearing: the OpenCost reconciler allocates
223
- // by pod labels and filters on a non-empty cortex_billing_context — a
224
- // Job-only label is invisible to compute metering.
225
- const billingLabels = meta.billing
226
- ? {
227
- [BILLING_CONTEXT_LABEL]: sanitizeLabelValue(meta.billing.billingContextId),
228
- [USER_ID_LABEL]: sanitizeLabelValue(meta.billing.userId),
229
- [ANALYSIS_ID_LABEL]: sanitizeLabelValue(meta.analysisId),
230
- [RUN_ID_LABEL]: sanitizeLabelValue(meta.runId),
231
- }
232
- : {};
218
+ // Pod-template placement is load-bearing: a cost reconciler allocates by pod
219
+ // labels, thus a Job-only label is invisible to it. The host labels stay
220
+ // opaque — every value passes through `sanitizeLabelValue`, because one
221
+ // malformed value makes the API server reject the whole Job at admission.
222
+ const attributionLabels = {
223
+ [ANALYSIS_ID_LABEL]: sanitizeLabelValue(meta.analysisId),
224
+ [RUN_ID_LABEL]: sanitizeLabelValue(meta.runId),
225
+ ...Object.fromEntries(Object.entries(meta.podLabels ?? {}).map(([key, value]) => [key, sanitizeLabelValue(value)])),
226
+ };
233
227
  return {
234
228
  apiVersion: "batch/v1",
235
229
  kind: "Job",
@@ -241,9 +235,8 @@ function buildJobSpec(meta, config, identity) {
241
235
  role: "sandbox",
242
236
  "cortex/sandbox-id": sandboxId,
243
237
  [OWNER_WORKFLOW_LABEL]: sanitizeLabelValue(meta.childWorkflowId),
244
- [RUN_ID_LABEL]: sanitizeLabelValue(meta.runId),
245
238
  [STEP_ID_LABEL]: sanitizeLabelValue(meta.stepId),
246
- ...billingLabels,
239
+ ...attributionLabels,
247
240
  },
248
241
  },
249
242
  spec: {
@@ -254,7 +247,7 @@ function buildJobSpec(meta, config, identity) {
254
247
  labels: {
255
248
  role: "sandbox",
256
249
  "cortex/sandbox-id": sandboxId,
257
- ...billingLabels,
250
+ ...attributionLabels,
258
251
  },
259
252
  },
260
253
  spec: podSpec,
@@ -10,6 +10,17 @@
10
10
  * artifacts, lib store read-only at `/mnt/libs`, ref store read-only at
11
11
  * `/mnt/refs`.
12
12
  *
13
+ * A caller can declare its own `writableTail` in place of the step tail — a
14
+ * workspace-relative path that becomes the one read-write mount, with no step
15
+ * subdirectories under it. The session derivation declares one; the run path
16
+ * declares none and keeps the step tail. Thus a sandbox writes into the step
17
+ * directory or into the one declared tail, and nowhere else.
18
+ *
19
+ * The precedence is a refusal, not an order: `readOnly` and `writableTail` state
20
+ * opposite things about the same mount, thus a coordinate set that carries both
21
+ * throws here — the same as a crafted id. No caller can hold both and get a
22
+ * silently chosen winner.
23
+ *
13
24
  * Container paths are a function of `resourceId` alone — they never carry the
14
25
  * host location of the tree. Where that tree physically lives is the embedder's
15
26
  * (`resolveWorkspaceRoot`); the two are reconciled per backend: Docker binds the
@@ -34,6 +45,14 @@ export interface MountPlanCoords {
34
45
  * a generic read-only agent that must not mutate analysis files.
35
46
  */
36
47
  readOnly?: boolean;
48
+ /**
49
+ * The declared write tail: a workspace-relative path that takes the place of the
50
+ * step tail as the one read-write mount, with no step subdirectories under it.
51
+ * Each segment passes the safe-id discipline of the step builder. Absent keeps
52
+ * the step tail, thus the run path is unchanged. A tail beside `readOnly` is a
53
+ * contradiction, and the builders refuse it.
54
+ */
55
+ writableTail?: string;
37
56
  }
38
57
  export interface MountPlanStores {
39
58
  /** Lib store is mounted at `/mnt/libs` and its env is emitted. */
@@ -45,20 +64,21 @@ export interface MountPlan {
45
64
  /** Flat read-only mount of the whole analysis tree. */
46
65
  readonlyTreePath: string;
47
66
  /**
48
- * Nested read-write mount for this step's artifacts, also the WorkingDir.
49
- * Undefined for a read-only sandbox — no writable mount exists; the
50
- * WorkingDir falls back to the read-only tree root.
67
+ * Nested read-write mount, also the WorkingDir: the step's artifact directory,
68
+ * or the declared write tail in its place. Undefined for a read-only sandbox —
69
+ * no writable mount exists; the WorkingDir falls back to the read-only tree
70
+ * root.
51
71
  */
52
72
  writableStepPath?: string;
53
- /** Container WorkingDir: the writable step path, or the RO tree root in
73
+ /** Container WorkingDir: the writable mount, or the RO tree root in
54
74
  * read-only mode. */
55
75
  workingDir: string;
56
76
  /** Container path of the lib store, present only when `libs`. */
57
77
  libsPath?: string;
58
78
  /** Container path of the ref store, present only when `refs`. */
59
79
  refsPath?: string;
60
- /** Pre-created subdirectories under the writable step path. Empty when
61
- * read-only (nothing to pre-create). */
80
+ /** Pre-created subdirectories under the writable mount. Empty when read-only
81
+ * (nothing to pre-create) and when a declared tail replaces the step tail. */
62
82
  stepSubdirs: readonly string[];
63
83
  /** Env merged into the sandbox container: provenance + lib-store vars. */
64
84
  env: Record<string, string>;
@@ -67,9 +87,27 @@ export interface MountPlan {
67
87
  export interface SessionSubPaths {
68
88
  /** Read-only mount of the whole analysis tree. */
69
89
  readonly ro: string;
70
- /** Read-write mount of the step's artifact dir; absent when read-only. */
90
+ /** Read-write mount of the step's artifact dir, or of the declared tail; absent when read-only. */
71
91
  readonly rw?: string;
72
92
  }
93
+ /** The one writable mount of a sandbox: its workspace-relative tail, and what the host pre-makes under it. */
94
+ export interface SandboxWriteTail {
95
+ /** The tail beneath the workspace root, in both container and PVC-relative space. */
96
+ readonly tail: string;
97
+ /** The subdirectories the host pre-creates under the tail. A declared tail is one directory, thus none. */
98
+ readonly subdirs: readonly string[];
99
+ }
100
+ /**
101
+ * The write tail of a sandbox, or `undefined` when the sandbox holds no write mount.
102
+ *
103
+ * The one owner of the rule, read by the container path, the PVC subPath, and the
104
+ * host-side preparation alike. Thus the directory the harness makes and the
105
+ * directory the sandbox mounts are provably the same one.
106
+ *
107
+ * `readOnly` beside a declared tail states two opposite things about one mount, thus
108
+ * it throws rather than picking a winner.
109
+ */
110
+ export declare function sandboxWriteTail(coords: MountPlanCoords): SandboxWriteTail | undefined;
73
111
  /**
74
112
  * K8s `subPath`s for the session-PVC mounts.
75
113
  *
@@ -10,13 +10,24 @@
10
10
  * artifacts, lib store read-only at `/mnt/libs`, ref store read-only at
11
11
  * `/mnt/refs`.
12
12
  *
13
+ * A caller can declare its own `writableTail` in place of the step tail — a
14
+ * workspace-relative path that becomes the one read-write mount, with no step
15
+ * subdirectories under it. The session derivation declares one; the run path
16
+ * declares none and keeps the step tail. Thus a sandbox writes into the step
17
+ * directory or into the one declared tail, and nowhere else.
18
+ *
19
+ * The precedence is a refusal, not an order: `readOnly` and `writableTail` state
20
+ * opposite things about the same mount, thus a coordinate set that carries both
21
+ * throws here — the same as a crafted id. No caller can hold both and get a
22
+ * silently chosen winner.
23
+ *
13
24
  * Container paths are a function of `resourceId` alone — they never carry the
14
25
  * host location of the tree. Where that tree physically lives is the embedder's
15
26
  * (`resolveWorkspaceRoot`); the two are reconciled per backend: Docker binds the
16
27
  * resolved root directly, K8s addresses it as a `subPath` into the session PVC
17
28
  * (see {@link buildSessionSubPaths}).
18
29
  */
19
- import { assertSafeId } from "../workspace/paths.js";
30
+ import { assertSafeId, assertSafeTail } from "../workspace/paths.js";
20
31
  export const STEP_SUBDIRS = ["output", "scripts", "figures", "logs", "notebooks"];
21
32
  /**
22
33
  * Container mountpoints of the two read-only stores. Exported because they are
@@ -37,6 +48,31 @@ function stepTail(runId, stepId) {
37
48
  assertSafeId(stepId, "stepId");
38
49
  return `runs/${runId}/${stepId}`;
39
50
  }
51
+ /**
52
+ * The write tail of a sandbox, or `undefined` when the sandbox holds no write mount.
53
+ *
54
+ * The one owner of the rule, read by the container path, the PVC subPath, and the
55
+ * host-side preparation alike. Thus the directory the harness makes and the
56
+ * directory the sandbox mounts are provably the same one.
57
+ *
58
+ * `readOnly` beside a declared tail states two opposite things about one mount, thus
59
+ * it throws rather than picking a winner.
60
+ */
61
+ export function sandboxWriteTail(coords) {
62
+ // The two ids reach the sandbox name, the ownership labels, and the registry row on every path, not the
63
+ // step tail alone. Thus they take their validation before any branch, and a crafted id is refused even
64
+ // where no step directory is built.
65
+ assertSafeId(coords.runId, "runId");
66
+ assertSafeId(coords.stepId, "stepId");
67
+ if (coords.writableTail === undefined) {
68
+ return coords.readOnly ? undefined : { tail: stepTail(coords.runId, coords.stepId), subdirs: STEP_SUBDIRS };
69
+ }
70
+ if (coords.readOnly) {
71
+ throw new Error(`sandbox mount: a read-only sandbox cannot declare a write tail (got ${coords.writableTail})`);
72
+ }
73
+ // The segments come back validated, thus the join below re-splits nothing.
74
+ return { tail: assertSafeTail(coords.writableTail).join("/"), subdirs: [] };
75
+ }
40
76
  /**
41
77
  * K8s `subPath`s for the session-PVC mounts.
42
78
  *
@@ -51,9 +87,10 @@ export function buildSessionSubPaths(coords, workspaceSubPath) {
51
87
  if (workspaceSubPath.length === 0 || workspaceSubPath.startsWith("/") || workspaceSubPath.split("/").includes("..")) {
52
88
  throw new Error(`buildSessionSubPaths: workspaceSubPath must be a non-empty PVC-root-relative path without '..' (got ${JSON.stringify(workspaceSubPath)})`);
53
89
  }
90
+ const write = sandboxWriteTail(coords);
54
91
  return {
55
92
  ro: workspaceSubPath,
56
- rw: coords.readOnly ? undefined : `${workspaceSubPath}/${stepTail(coords.runId, coords.stepId)}`,
93
+ ...(write === undefined ? {} : { rw: `${workspaceSubPath}/${write.tail}` }),
57
94
  };
58
95
  }
59
96
  /**
@@ -68,19 +105,20 @@ function libStoreEnv() {
68
105
  };
69
106
  }
70
107
  export function buildMountPlan(coords, stores) {
71
- const { analysisId, runId, stepId, readOnly } = coords;
108
+ const { analysisId } = coords;
72
109
  // `analysisId` becomes the RO mount point `/{analysisId}` even in read-only
73
110
  // mode (where `stepTail` — which validates runId/stepId — is not reached).
74
111
  assertSafeId(analysisId, "analysisId");
75
112
  const readonlyTreePath = `/${analysisId}`;
76
- const writableStepPath = readOnly ? undefined : `${readonlyTreePath}/${stepTail(runId, stepId)}`;
113
+ const write = sandboxWriteTail(coords);
114
+ const writableStepPath = write === undefined ? undefined : `${readonlyTreePath}/${write.tail}`;
77
115
  return {
78
116
  readonlyTreePath,
79
117
  writableStepPath,
80
118
  workingDir: writableStepPath ?? readonlyTreePath,
81
119
  libsPath: stores.libs ? LIBS_CONTAINER_PATH : undefined,
82
120
  refsPath: stores.refs ? REFS_CONTAINER_PATH : undefined,
83
- stepSubdirs: readOnly ? [] : STEP_SUBDIRS,
121
+ stepSubdirs: write?.subdirs ?? [],
84
122
  env: {
85
123
  PROVENANCE_WATCH_DIRS: readonlyTreePath,
86
124
  ...(stores.libs ? libStoreEnv() : {}),
@@ -243,12 +243,16 @@ export interface CreateSandboxMeta {
243
243
  /** Enforced read-only: provision with no read-write step mount, only the
244
244
  * read-only analysis tree for generic read-only agents. */
245
245
  readOnly?: boolean;
246
- /** Billing attribution stamped as pod labels for OpenCost compute metering.
247
- * Absent ⇒ the pod is invisible to the metering reconciler (filter-level). */
248
- billing?: {
249
- billingContextId: string;
250
- userId: string;
251
- };
246
+ /** Workspace-relative path that becomes the one read-write mount, in place of
247
+ * the step directory and with no step subdirectories under it. Each segment
248
+ * passes the safe-id discipline of the step builder. Absent keeps the step
249
+ * mount, thus a run provisions exactly as before. A tail beside `readOnly` is
250
+ * a contradiction, and the mount builders refuse it. */
251
+ writableTail?: string;
252
+ /** Host-supplied labels stamped onto the sandbox pod, verbatim. Opaque here:
253
+ * the harness reads no key and no value, it only sanitizes each value into a
254
+ * valid label. Absent ⇒ the pod carries the harness's own labels only. */
255
+ podLabels?: Record<string, string>;
252
256
  }
253
257
  /**
254
258
  * The identity minted for a sandbox machine *before* it is spawned — the
@@ -129,15 +129,15 @@ CREATE TABLE IF NOT EXISTS cortex_plans (
129
129
  CREATE INDEX IF NOT EXISTS idx_cortex_plans_analysis
130
130
  ON cortex_plans(analysis_id, created_at DESC);
131
131
 
132
- -- Report versions — one immutable row for each recorded report version. A row
133
- -- holds the block document, the pinned snapshot, and the anchor. A thread holds
134
- -- at most one version. The named unique constraint on thread_id enforces the
135
- -- rule, because the report policy binds one version to each report session. The
136
- -- row outlives its thread. Thus the table has no foreign key to
137
- -- cortex_analysis_threads, and the row carries the anchor columns (thread_id,
138
- -- parent_thread_id, parent_seq) directly. A purge of the analysis removes the row
139
- -- through the analysis_id cascade. The table is append-only, and a correction is
140
- -- a new version, never an update to a recorded row.
132
+ -- Report versions — one row for each recorded report version. A row holds the
133
+ -- block document, the pinned snapshot, and the anchor. A thread holds at most one
134
+ -- version. The named unique constraint on thread_id enforces the rule, because
135
+ -- the report policy binds one version to each report session. A record on a
136
+ -- thread that holds a version replaces that row whole, under the same version_id
137
+ -- and the same created_at. The row outlives its thread. Thus the table has no
138
+ -- foreign key to cortex_analysis_threads, and the row carries the anchor columns
139
+ -- (thread_id, parent_thread_id, parent_seq) directly. A purge of the analysis
140
+ -- removes the row through the analysis_id cascade.
141
141
  CREATE TABLE IF NOT EXISTS cortex_report_versions (
142
142
  version_id TEXT PRIMARY KEY,
143
143
  analysis_id TEXT NOT NULL
@@ -176,6 +176,13 @@ CREATE INDEX IF NOT EXISTS idx_cortex_report_versions_analysis
176
176
  -- holds the hash that the last eyes capture saw, copied from the rendered hash.
177
177
  -- Each is nullable, because a fresh session has run no preview and no eyes. The
178
178
  -- record refuses unless the seen hash equals the hash of the current draft.
179
+ --
180
+ -- The derivations column holds the derivation list of the session. One record
181
+ -- carries the derived output path, the output hash, the source paths with their
182
+ -- hashes, the script hash, and the script text. The column is nullable, because a
183
+ -- session that derived nothing holds no list, and the read gives an empty list for
184
+ -- such a row. The served membership merges the records onto the pin, and the pin
185
+ -- itself never changes.
179
186
  CREATE TABLE IF NOT EXISTS cortex_report_session_state (
180
187
  thread_id TEXT PRIMARY KEY,
181
188
  analysis_id TEXT NOT NULL
@@ -183,6 +190,7 @@ CREATE TABLE IF NOT EXISTS cortex_report_session_state (
183
190
  ON DELETE CASCADE,
184
191
  document JSONB,
185
192
  snapshot JSONB,
193
+ derivations JSONB,
186
194
  rendered_document_hash TEXT,
187
195
  seen_document_hash TEXT,
188
196
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -310,6 +318,17 @@ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
310
318
  -- forces JSON on the envelope is about model content that is re-sent to the
311
319
  -- provider. This record is harness-generated, has a fixed field set, and never
312
320
  -- reaches a prompt, so it does not share that requirement.
321
+ --
322
+ -- turn_duration_ms holds the time that a whole turn took, in milliseconds, on
323
+ -- the same row as reported_usage. It is a column of its own, and not a member of
324
+ -- the rollup. The rollup is absent when a provider reported no quantity, and the
325
+ -- host measures the duration apart from the token counts. Thus a member of the
326
+ -- rollup would disappear with it. BIGINT, as cortex_step_executions.duration_ms
327
+ -- is, because an int4 duration overflowed there.
328
+ --
329
+ -- Nullable with no default: absent means that nobody measured the turn, and a
330
+ -- zero would claim that a turn took no time. A row written before this column
331
+ -- existed is absent for the same reason, thus the migration needs no backfill.
313
332
  CREATE TABLE IF NOT EXISTS messages (
314
333
  thread_id TEXT NOT NULL,
315
334
  seq BIGINT NOT NULL,
@@ -321,6 +340,7 @@ CREATE TABLE IF NOT EXISTS messages (
321
340
  display_envelope JSONB,
322
341
  tokens INTEGER NOT NULL,
323
342
  reported_usage JSONB,
343
+ turn_duration_ms BIGINT,
324
344
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
325
345
  PRIMARY KEY (thread_id, seq)
326
346
  );
@@ -565,6 +585,10 @@ export async function initCortexState(pool, injected) {
565
585
  // which is the honest state, thus an existing row reads back as never-seen.
566
586
  "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS rendered_document_hash TEXT",
567
587
  "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS seen_document_hash TEXT",
588
+ // The derivation list of a report session. Nullable and unbackfilled: a
589
+ // session that derived nothing holds no list, and the read gives an empty
590
+ // list for such a row.
591
+ "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS derivations JSONB",
568
592
  // Databases created before `data_profile_status` became nullable still
569
593
  // carry the NOT NULL floor, which would reject a clear. Idempotent:
570
594
  // dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
@@ -575,6 +599,11 @@ export async function initCortexState(pool, injected) {
575
599
  // row and every pre-existing message reads back with no rollup — which
576
600
  // is the honest value, the figures having never been recorded.
577
601
  "ALTER TABLE messages ADD COLUMN IF NOT EXISTS reported_usage JSONB",
602
+ // The turn's measured duration (see the CREATE TABLE comment). Purely
603
+ // additive: nullable with no default, thus the column rewrites no row,
604
+ // and every earlier message reads back with no duration — which is the
605
+ // honest value, because nobody measured those turns.
606
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS turn_duration_ms BIGINT",
578
607
  // Demote an existing JSONB envelope column to JSON (see the CREATE
579
608
  // TABLE comment). Type-guarded because ALTER ... TYPE rewrites the
580
609
  // whole table and this runs at every boot; scoped to
@@ -21,6 +21,12 @@
21
21
  * document can be incomplete. A column that fails a parse reads as a typed error, and
22
22
  * it does not crash. An absent row reads as a normal absence.
23
23
  *
24
+ * The row also holds the derivation list of the session. One record chains a derived
25
+ * table to the evidence that made it: the output path, the output hash, the source
26
+ * paths with their hashes, the script hash, and the script text. A record is
27
+ * immutable, and the append refuses a repeated output path. A row that holds no list
28
+ * reads as an empty list.
29
+ *
24
30
  * A persist is a compare-and-swap. The load reads the prior document, and the persist
25
31
  * lands only when the row still holds it. Thus two concurrent turns cannot both land,
26
32
  * and the loser reads the state again.
@@ -34,6 +40,33 @@ import type { Logger } from "../lib/logger.js";
34
40
  import { type DraftDocument } from "../report-model/draft.js";
35
41
  import type { ReportSnapshot } from "../report-model/reference-resolver.js";
36
42
  import { type SchemaIssue } from "./snapshot-parse.js";
43
+ /** One source of a derivation: the pinned path that the script read, and the hash of that path. */
44
+ export interface DerivationSource {
45
+ readonly path: string;
46
+ readonly hash: string;
47
+ }
48
+ /**
49
+ * One derivation of a session.
50
+ *
51
+ * The record chains the derived table to the evidence that made it, thus a verifier mounts the same
52
+ * sources, runs the same script, and compares the output hash. The script text rides the record, thus the
53
+ * verifier needs no second store.
54
+ *
55
+ * `outputPath` is the workspace-root-relative path of the derived file, and it is the key that the served
56
+ * membership holds. A record is immutable, thus a new derivation takes a new output path.
57
+ */
58
+ export interface DerivationRecord {
59
+ readonly outputPath: string;
60
+ readonly outputHash: string;
61
+ readonly sources: readonly DerivationSource[];
62
+ readonly scriptHash: string;
63
+ readonly script: string;
64
+ }
65
+ /**
66
+ * The outcome of an append. `appended` landed the record. `duplicate` means that a record already holds the
67
+ * output path, thus the list stays as it is. `absent` means that no row holds the thread.
68
+ */
69
+ export type AppendDerivationOutcome = "appended" | "duplicate" | "absent";
37
70
  /** The state of one report session, as the store gives it back. */
38
71
  export interface ReportSessionState {
39
72
  readonly threadId: string;
@@ -42,6 +75,8 @@ export interface ReportSessionState {
42
75
  readonly document: DraftDocument | null;
43
76
  /** The pinned snapshot, or `null` before the pin writes it. */
44
77
  readonly snapshot: ReportSnapshot | null;
78
+ /** The derivations of the session, in the order that they landed. A row with no list reads as empty. */
79
+ readonly derivations: readonly DerivationRecord[];
45
80
  /** The hash of the draft that the last preview rendered, or `null` before the first preview. */
46
81
  readonly renderedDocumentHash: string | null;
47
82
  /** The hash that the last eyes capture saw, or `null` before the first look. */
@@ -92,7 +127,7 @@ export type SessionStateReadError = {
92
127
  readonly type: "corrupt_session_state";
93
128
  readonly op: string;
94
129
  readonly threadId: string;
95
- readonly part: "document" | "snapshot";
130
+ readonly part: "document" | "snapshot" | "derivations";
96
131
  readonly issues: readonly SchemaIssue[];
97
132
  };
98
133
  export interface ReportSessionStateStore {
@@ -127,6 +162,12 @@ export interface ReportSessionStateStore {
127
162
  * one. `absent` means that no row holds the thread.
128
163
  */
129
164
  stampSeen(threadId: string): ResultAsync<SeenStampOutcome, DbError>;
165
+ /**
166
+ * Append one derivation record to the list of a thread. The output path is unique across the list,
167
+ * thus a repeated path refuses with `duplicate` and the stored list stays as it is. `absent` means
168
+ * that no row holds the thread.
169
+ */
170
+ appendDerivation(threadId: string, record: DerivationRecord): ResultAsync<AppendDerivationOutcome, DbError>;
130
171
  }
131
172
  export interface ReportSessionStateStoreDeps {
132
173
  readonly pool: Pool;