@inflexa-ai/harness 0.31.1 → 0.33.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 (96) hide show
  1. package/dist/agents/conversation-agent.d.ts +15 -0
  2. package/dist/agents/conversation-agent.js +16 -6
  3. package/dist/agents/report-session-agent.d.ts +9 -0
  4. package/dist/agents/report-session-agent.js +10 -2
  5. package/dist/agents/sandbox/shared.d.ts +13 -1
  6. package/dist/agents/sandbox/shared.js +22 -5
  7. package/dist/app/chat-turn.d.ts +11 -0
  8. package/dist/app/chat-turn.js +17 -1
  9. package/dist/app/spawn-report-session.d.ts +12 -0
  10. package/dist/app/spawn-report-session.js +21 -1
  11. package/dist/config/environment-stores.d.ts +53 -5
  12. package/dist/contracts/report-blocks.d.ts +8 -8
  13. package/dist/contracts/schemas/chat-parts.d.ts +6 -6
  14. package/dist/index.d.ts +8 -1
  15. package/dist/index.js +11 -0
  16. package/dist/prompts/briefing.d.ts +1 -1
  17. package/dist/prompts/briefing.js +4 -0
  18. package/dist/prompts/planner.js +13 -0
  19. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  20. package/dist/prompts/sandbox/multi-omics-integration-agent.js +5 -5
  21. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  22. package/dist/prompts/sandbox/network-agent.js +4 -3
  23. package/dist/prompts/sandbox-standards.d.ts +20 -0
  24. package/dist/prompts/sandbox-standards.js +65 -0
  25. package/dist/provenance/seam.d.ts +203 -0
  26. package/dist/provenance/seam.js +71 -0
  27. package/dist/reference-data/catalog.js +11 -9
  28. package/dist/report-model/authoring-grammar.d.ts +2 -2
  29. package/dist/report-render/assets.d.ts +10 -0
  30. package/dist/report-render/assets.js +14 -0
  31. package/dist/report-render/design.js +256 -0
  32. package/dist/report-render/page.d.ts +75 -0
  33. package/dist/report-render/page.js +793 -1
  34. package/dist/report-render/provenance-data.d.ts +42 -0
  35. package/dist/report-render/provenance-data.js +62 -0
  36. package/dist/report-render/render.d.ts +20 -1
  37. package/dist/report-render/render.js +23 -10
  38. package/dist/report-render/types.d.ts +3 -0
  39. package/dist/report-render/views/chart-view.d.ts +5 -1
  40. package/dist/report-render/views/chart-view.js +6 -2
  41. package/dist/report-render/views/lineage.d.ts +93 -0
  42. package/dist/report-render/views/lineage.js +86 -0
  43. package/dist/report-render/views/page-view.d.ts +22 -1
  44. package/dist/report-render/views/page-view.js +12 -3
  45. package/dist/report-render/views/prose.d.ts +7 -1
  46. package/dist/report-render/views/prose.js +10 -6
  47. package/dist/report-render/views/references-view.d.ts +17 -1
  48. package/dist/report-render/views/references-view.js +12 -4
  49. package/dist/report-render/views/values.d.ts +17 -4
  50. package/dist/report-render/views/values.js +21 -8
  51. package/dist/runtime/assemble.d.ts +22 -0
  52. package/dist/runtime/assemble.js +4 -0
  53. package/dist/sandbox/create-sandbox.d.ts +20 -4
  54. package/dist/sandbox/create-sandbox.js +4 -0
  55. package/dist/sandbox/docker-client.d.ts +16 -6
  56. package/dist/sandbox/docker-client.js +47 -31
  57. package/dist/sandbox/farm.d.ts +95 -0
  58. package/dist/sandbox/farm.js +127 -0
  59. package/dist/sandbox/k8s-client.d.ts +24 -10
  60. package/dist/sandbox/k8s-client.js +85 -122
  61. package/dist/sandbox/mount-plan.d.ts +23 -1
  62. package/dist/sandbox/mount-plan.js +44 -7
  63. package/dist/sandbox/sandbox-error.d.ts +12 -0
  64. package/dist/sandbox/sandbox-error.js +2 -0
  65. package/dist/sandbox/types.d.ts +118 -0
  66. package/dist/schemas/artifact-manifest.d.ts +4 -4
  67. package/dist/schemas/plan-schemas.d.ts +3 -0
  68. package/dist/schemas/plan-schemas.js +1 -1
  69. package/dist/schemas/validate-plan.js +20 -1
  70. package/dist/schemas/workflow-state.d.ts +4 -0
  71. package/dist/schemas/workflow-state.js +10 -0
  72. package/dist/tasks/data-profile.d.ts +15 -0
  73. package/dist/tasks/data-profile.js +36 -26
  74. package/dist/tools/display/show-user.d.ts +1 -1
  75. package/dist/tools/execute-analysis.d.ts +18 -0
  76. package/dist/tools/execute-analysis.js +87 -0
  77. package/dist/tools/report-authoring/authoring-tools.d.ts +16 -1
  78. package/dist/tools/report-authoring/authoring-tools.js +58 -16
  79. package/dist/tools/report-session/derive-table.d.ts +3 -0
  80. package/dist/tools/report-session/derive-table.js +15 -0
  81. package/dist/tools/report-session/preview-report.d.ts +13 -4
  82. package/dist/tools/report-session/preview-report.js +23 -6
  83. package/dist/tools/report-session/record-version.d.ts +3 -0
  84. package/dist/tools/report-session/record-version.js +7 -3
  85. package/dist/tools/research/generate-plan.js +12 -2
  86. package/dist/tools/sandbox/index.d.ts +1 -0
  87. package/dist/tools/sandbox/index.js +1 -0
  88. package/dist/tools/sandbox/link-packages.d.ts +27 -0
  89. package/dist/tools/sandbox/link-packages.js +52 -0
  90. package/dist/tools/sandbox/list-available-packages.d.ts +50 -30
  91. package/dist/tools/sandbox/list-available-packages.js +131 -40
  92. package/dist/tools/start-report-session.d.ts +7 -0
  93. package/dist/tools/start-report-session.js +1 -0
  94. package/dist/workflows/execute-analysis.d.ts +15 -69
  95. package/dist/workflows/execute-analysis.js +5 -4
  96. package/package.json +2 -1
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Farm-source resolution and the `inflexa.lock` farm contract.
3
+ *
4
+ * A farm carries exactly one metadata file, `inflexa.lock`, beside its link
5
+ * trees and its caches (the lib-store spec). The mount gate and the package
6
+ * inventory read this one file — `packages.txt`, `meta.json`, and
7
+ * `lock.json` are not part of the farm contract.
8
+ *
9
+ * The resolution and the gate refuse differently, on purpose:
10
+ * - A resolver refusal (`unavailable`, or a throw) is a `farm_unavailable`
11
+ * `SandboxError` — the call refuses and no container is made.
12
+ * - A lock-gate failure degrades — the backend drops both store mounts,
13
+ * logs a warning, and still makes the container.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { err, ok } from "neverthrow";
18
+ import { z } from "zod";
19
+ /** The one metadata file of a farm. */
20
+ export const FARM_LOCK_FILE = "inflexa.lock";
21
+ /** One linked distribution. `requested` obeys the PEP 376 meaning: true for a direct ask, false for a transitive dependency. */
22
+ export const FarmLockPackageSchema = z
23
+ .object({
24
+ name: z.string(),
25
+ version: z.string(),
26
+ /** The link subtree of the package (for example `python`, `cran`, `bioconductor`). */
27
+ track: z.string(),
28
+ store_dir: z.string(),
29
+ /** The full sha256 of the sorted store tree. */
30
+ hash: z.string(),
31
+ requested: z.boolean(),
32
+ })
33
+ .passthrough();
34
+ /**
35
+ * `inflexa.lock` at schema version 1. Additive fields pass through — a
36
+ * breaking change to the shape must move the schema number instead.
37
+ */
38
+ export const FarmLockSchema = z
39
+ .object({
40
+ schema: z.literal(1),
41
+ arch: z.enum(["amd64", "arm64"]),
42
+ packages: z.array(FarmLockPackageSchema),
43
+ /** Per-language provenance. Each language object owns its own fields. */
44
+ languages: z
45
+ .object({
46
+ python: z.object({ version: z.string(), index: z.string() }).passthrough().optional(),
47
+ r: z
48
+ .object({
49
+ version: z.string(),
50
+ bioc_releases: z.array(z.string()),
51
+ /** The embedded pak lock of the build, verbatim. */
52
+ pak_lock: z.unknown().optional(),
53
+ })
54
+ .passthrough()
55
+ .optional(),
56
+ })
57
+ .passthrough(),
58
+ /** The warm replay record. Present in the catalog farm only. */
59
+ warm: z
60
+ .record(z.string(), z
61
+ .object({
62
+ script_sha256: z.string(),
63
+ cache_entries: z.array(z.string()),
64
+ })
65
+ .passthrough())
66
+ .optional(),
67
+ /** The top-level entries that the link merge kept-first or skipped. */
68
+ merge_conflicts: z
69
+ .array(z
70
+ .object({
71
+ entry: z.string(),
72
+ action: z.enum(["kept-first", "skipped"]),
73
+ })
74
+ .passthrough())
75
+ .optional(),
76
+ })
77
+ .passthrough();
78
+ /**
79
+ * Read and validate the `inflexa.lock` of a farm directory. The mount gate
80
+ * accepts a farm only when this returns ok — a farm whose lock is absent,
81
+ * unparseable, or at an unknown schema version is not usable.
82
+ */
83
+ export function readFarmLock(farmPath) {
84
+ return readFarmLockFile(join(farmPath, FARM_LOCK_FILE));
85
+ }
86
+ /** Read and validate one `inflexa.lock` by its file path. The package inventory reads through this too. */
87
+ export function readFarmLockFile(lockPath) {
88
+ let raw;
89
+ try {
90
+ raw = readFileSync(lockPath, "utf8");
91
+ }
92
+ catch (cause) {
93
+ return err({ type: "lock_unreadable", lockPath, cause });
94
+ }
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(raw);
98
+ }
99
+ catch (cause) {
100
+ return err({ type: "lock_invalid", lockPath, cause });
101
+ }
102
+ const lock = FarmLockSchema.safeParse(parsed);
103
+ if (!lock.success) {
104
+ return err({ type: "lock_invalid", lockPath, cause: lock.error });
105
+ }
106
+ return ok(lock.data);
107
+ }
108
+ /**
109
+ * Resolve the farm source of one analysis. Runs before any container work.
110
+ * `fixed` answers at once. `per-analysis` calls the resolver of the
111
+ * embedder — an `unavailable` result and a throw both refuse the call with
112
+ * `farm_unavailable`, and the reason of the embedder rides in the error.
113
+ */
114
+ export async function resolveFarmSource(source, analysisId, op) {
115
+ if (source.kind === "fixed")
116
+ return ok(source.location);
117
+ try {
118
+ const resolution = await source.resolve(analysisId);
119
+ if (resolution.kind === "unavailable") {
120
+ return err({ type: "farm_unavailable", op, analysisId, reason: resolution.reason });
121
+ }
122
+ return ok(resolution.location);
123
+ }
124
+ catch (cause) {
125
+ return err({ type: "farm_unavailable", op, analysisId, reason: "the farm resolver threw", cause });
126
+ }
127
+ }
@@ -6,16 +6,17 @@
6
6
  * Storage is wired via the shared session PVC: a flat read-only `volumeMount`
7
7
  * of the analysis tree at `/{resourceId}` plus a nested read-write mount of the
8
8
  * step's artifact dir, with the lib/ref stores mounted read-only at `/mnt/libs`
9
- * / `/mnt/refs` when their PVCs are set. Container paths and lib-store env come
10
- * from the shared mount plan; the PVC `subPath`s are derived from the same
11
- * `resolveWorkspaceRoot` seam the harness pre-creates the step tree under, so
12
- * both sides address one directory by construction.
9
+ * / `/mnt/refs` when their PVCs are set. The farm of the analysis mounts as a
10
+ * read-only `subPath` of the libs PVC, after the store mount. Container paths
11
+ * and lib-store env come from the shared mount plan; the PVC `subPath`s are
12
+ * derived from the same `resolveWorkspaceRoot` seam the harness pre-creates
13
+ * the step tree under, so both sides address one directory by construction.
13
14
  */
14
15
  import { BatchV1Api, CoreV1Api, type V1Toleration } from "@kubernetes/client-node";
15
16
  import { ResultAsync } from "neverthrow";
16
17
  import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
17
18
  import { type SandboxError } from "./sandbox-error.js";
18
- import type { CreateSandboxMeta, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport } from "./types.js";
19
+ import type { CreateSandboxMeta, FarmSource, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport, ToolchainSource } from "./types.js";
19
20
  import type { Logger } from "../lib/logger.js";
20
21
  /**
21
22
  * Coerce an arbitrary identifier into a valid K8s label value:
@@ -52,6 +53,24 @@ export interface K8sClientConfig {
52
53
  resolveWorkspaceRoot: ResolveWorkspaceRoot;
53
54
  /** PVC claim mounted read-only at `/mnt/libs` when set. */
54
55
  libStorePvc?: string;
56
+ /**
57
+ * Absolute mountpoint of `libStorePvc` on THIS process's filesystem, for
58
+ * a host that mounts the same volume (cortex mounts it at `/mnt/libs`).
59
+ * With the root set, the backend proves the `inflexa.lock` of the
60
+ * resolved farm before the mount — the same gate, and the same degrade,
61
+ * as the Docker backend. Without it, the proof duty stays whole on the
62
+ * farm resolver — see {@link ResolveAnalysisFarm}.
63
+ */
64
+ libStorePvcRoot?: string;
65
+ /**
66
+ * Where the farm of an analysis comes from. Required — the harness never
67
+ * invents a farm location. The farm is a `subPath` into `libStorePvc`,
68
+ * thus `FarmLocation.farmPath` is PVC-relative here. Consulted only when
69
+ * `libStorePvc` is set.
70
+ */
71
+ farmSource: FarmSource;
72
+ /** The declared toolchain owner. Absent means `"store"`, the legacy environment. */
73
+ toolchainSource?: ToolchainSource;
55
74
  /** PVC claim mounted read-only at `/mnt/refs` when set. */
56
75
  refStorePvc?: string;
57
76
  /** Node selector pinning sandbox pods to the dedicated agent pool. Omit for default scheduling. */
@@ -60,11 +79,6 @@ export interface K8sClientConfig {
60
79
  tolerations?: V1Toleration[];
61
80
  /** RuntimeClass for runtime isolation (e.g. `gvisor`). Omit for the default runtime. */
62
81
  runtimeClassName?: string;
63
- /**
64
- * The `/proc/cpuinfo` text that the cpu ConfigMap of each Job is cut from.
65
- * Defaults to the file of the host of this process, read once. Injected for tests.
66
- */
67
- readHostCpuinfo?: () => Promise<string | undefined>;
68
82
  /** Injected for tests. */
69
83
  batchApi?: BatchV1Api;
70
84
  coreApi?: CoreV1Api;
@@ -6,18 +6,19 @@
6
6
  * Storage is wired via the shared session PVC: a flat read-only `volumeMount`
7
7
  * of the analysis tree at `/{resourceId}` plus a nested read-write mount of the
8
8
  * step's artifact dir, with the lib/ref stores mounted read-only at `/mnt/libs`
9
- * / `/mnt/refs` when their PVCs are set. Container paths and lib-store env come
10
- * from the shared mount plan; the PVC `subPath`s are derived from the same
11
- * `resolveWorkspaceRoot` seam the harness pre-creates the step tree under, so
12
- * both sides address one directory by construction.
9
+ * / `/mnt/refs` when their PVCs are set. The farm of the analysis mounts as a
10
+ * read-only `subPath` of the libs PVC, after the store mount. Container paths
11
+ * and lib-store env come from the shared mount plan; the PVC `subPath`s are
12
+ * derived from the same `resolveWorkspaceRoot` seam the harness pre-creates
13
+ * the step tree under, so both sides address one directory by construction.
13
14
  */
14
- import { relative as relativePath, sep } from "node:path";
15
+ import { join, relative as relativePath, sep } from "node:path";
15
16
  import { BatchV1Api, CoreV1Api, KubeConfig, } from "@kubernetes/client-node";
16
17
  import { ResultAsync, err, ok } from "neverthrow";
17
18
  import { trySandbox } from "./sandbox-error.js";
19
+ import { readFarmLock, resolveFarmSource } from "./farm.js";
18
20
  import { buildMountPlan, buildSessionSubPaths } from "./mount-plan.js";
19
21
  import { threadLimitEnv } from "./thread-env.js";
20
- import { CPU_ONLINE_PATH, CPUINFO_PATH, cpuFiles, readHostCpuinfoOnce } from "./cpu-files.js";
21
22
  import { createNoopLogger } from "../lib/console-logger.js";
22
23
  /** Read the originating HTTP status off any `SandboxError` variant that carries one. */
23
24
  function statusOf(e) {
@@ -26,8 +27,6 @@ function statusOf(e) {
26
27
  const SESSION_VOLUME_NAME = "session";
27
28
  const LIBS_VOLUME_NAME = "libs";
28
29
  const REFS_VOLUME_NAME = "refs";
29
- /** The ConfigMap volume that holds the two cpu files of the sandbox (`cpu-files.ts`). */
30
- const CPU_VOLUME_NAME = "cpu";
31
30
  const SANDBOX_SERVER_PORT = 8765;
32
31
  const POD_READY_TIMEOUT_MS = 5 * 60_000;
33
32
  const POD_POLL_INTERVAL_MS = 1_000;
@@ -63,19 +62,6 @@ export function sanitizeLabelValue(value) {
63
62
  function readOwnerWorkflowId(metadata) {
64
63
  return metadata?.annotations?.[OWNER_WORKFLOW_ANNOTATION] ?? null;
65
64
  }
66
- function deleteConfigMapIgnoreMissing(coreApi, namespace, name) {
67
- return trySandbox(async () => {
68
- await coreApi.deleteNamespacedConfigMap({ namespace, name });
69
- }, (status, cause) => ({
70
- type: "teardown_failed",
71
- op: "k8s.teardown",
72
- sandboxId: name,
73
- status,
74
- cause,
75
- })).orElse((e) =>
76
- // A 404 means the owner reference removed it with the Job already.
77
- statusOf(e) === 404 ? ok(undefined) : err(e));
78
- }
79
65
  function deleteJobIgnoreMissing(batchApi, namespace, name) {
80
66
  return trySandbox(async () => {
81
67
  await batchApi.deleteNamespacedJob({
@@ -124,11 +110,30 @@ function workspaceSubPathFor(config, analysisId) {
124
110
  }
125
111
  return posix;
126
112
  }
127
- function buildJobSpec(meta, config, identity, cpu) {
113
+ /**
114
+ * A farm location on K8s is a `subPath` into the libs PVC. A subPath must be
115
+ * a relative path with no `..` — an absolute or escaping value would mount a
116
+ * directory nothing resolved. `createSandbox` runs inside a DBOS workflow
117
+ * body, where a throw is the durable failure signal.
118
+ */
119
+ function pvcSubPath(value, what) {
120
+ if (value.length === 0 || value.startsWith("/") || value.split("/").includes("..")) {
121
+ throw new Error(`k8s sandbox: ${what} must be a non-empty PVC-relative path without '..' (got ${JSON.stringify(value)})`);
122
+ }
123
+ return value;
124
+ }
125
+ function buildJobSpec(meta, config, identity, farm, libsMounted) {
128
126
  const { sandboxId } = identity;
127
+ // The lock gate of the package-store spec: `farmPath` is PVC-relative,
128
+ // thus this host can prove the lock only through `libStorePvcRoot`. The
129
+ // caller runs that proof and hands the verdict in `libsMounted`. Without
130
+ // the root, the proof duty stays whole on the farm resolver — see
131
+ // {@link ResolveAnalysisFarm}.
129
132
  const plan = buildMountPlan(meta, {
130
- libs: !!config.libStorePvc,
133
+ libs: libsMounted,
131
134
  refs: !!config.refStorePvc,
135
+ toolchainSource: config.toolchainSource,
136
+ cache: libsMounted && farm?.cachePath !== undefined,
132
137
  });
133
138
  const transport = config.transport ?? "poll";
134
139
  const spec = meta.resources;
@@ -184,15 +189,36 @@ function buildJobSpec(meta, config, identity, cpu) {
184
189
  }
185
190
  }
186
191
  if (config.libStorePvc && plan.libsPath) {
192
+ const cacheSubPath = farm?.cachePath !== undefined && plan.cachePath ? pvcSubPath(farm.cachePath, "the farm cache") : undefined;
187
193
  volumes.push({
188
194
  name: LIBS_VOLUME_NAME,
189
- persistentVolumeClaim: { claimName: config.libStorePvc, readOnly: true },
195
+ // Volume-level readOnly only when nothing writes through the claim:
196
+ // the cache mount needs write access, and the per-mount flags keep
197
+ // the store and the farm read-only either way.
198
+ persistentVolumeClaim: { claimName: config.libStorePvc, ...(cacheSubPath === undefined ? { readOnly: true } : {}) },
190
199
  });
191
200
  volumeMounts.push({
192
201
  name: LIBS_VOLUME_NAME,
193
202
  mountPath: plan.libsPath,
194
203
  readOnly: true,
195
204
  });
205
+ // The farm mount comes after the store mount, thus the nesting is stable.
206
+ if (farm && plan.farmPath) {
207
+ volumeMounts.push({
208
+ name: LIBS_VOLUME_NAME,
209
+ mountPath: plan.farmPath,
210
+ subPath: pvcSubPath(farm.farmPath, "the farm"),
211
+ readOnly: true,
212
+ });
213
+ }
214
+ if (cacheSubPath !== undefined && plan.cachePath) {
215
+ volumeMounts.push({
216
+ name: LIBS_VOLUME_NAME,
217
+ mountPath: plan.cachePath,
218
+ subPath: cacheSubPath,
219
+ readOnly: false,
220
+ });
221
+ }
196
222
  }
197
223
  if (config.refStorePvc && plan.refsPath) {
198
224
  volumes.push({
@@ -205,16 +231,6 @@ function buildJobSpec(meta, config, identity, cpu) {
205
231
  readOnly: true,
206
232
  });
207
233
  }
208
- // The cpu files ride in a ConfigMap named after the Job (`ensureCpuConfigMap`).
209
- // A `subPath` mount of one key over one kernel path is accepted by the API
210
- // server, the kubelet, and gVisor. Under gVisor the sandbox sees no cgroup
211
- // at all, thus these two files are the only source of the quota for
212
- // `parallel::detectCores()` and `os.cpu_count()`.
213
- volumes.push({ name: CPU_VOLUME_NAME, configMap: { name: sandboxId } });
214
- volumeMounts.push({ name: CPU_VOLUME_NAME, mountPath: CPU_ONLINE_PATH, subPath: "online", readOnly: true });
215
- if (cpu.hasCpuinfo) {
216
- volumeMounts.push({ name: CPU_VOLUME_NAME, mountPath: CPUINFO_PATH, subPath: "cpuinfo", readOnly: true });
217
- }
218
234
  const podSpec = {
219
235
  automountServiceAccountToken: false,
220
236
  restartPolicy: "Never",
@@ -385,9 +401,6 @@ function waitForJobGone(batchApi, namespace, name) {
385
401
  * means an (astronomically rare) name collision with a *different* step —
386
402
  * adopting its pod would HMAC-fail every callback, and deleting its Job would
387
403
  * kill a live sibling. Refuse loudly instead.
388
- *
389
- * The result is the uid of the Job that the pod belongs to, for the owner
390
- * reference of the cpu ConfigMap.
391
404
  */
392
405
  function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflowId, job) {
393
406
  const createFailed = (status, cause) => ({
@@ -400,7 +413,7 @@ function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflow
400
413
  return new ResultAsync((async () => {
401
414
  const created = await trySandbox(() => batchApi.createNamespacedJob({ namespace, body: job }), createFailed);
402
415
  if (created.isOk())
403
- return ok(created.value?.metadata?.uid);
416
+ return ok(undefined);
404
417
  if (statusOf(created.error) !== 409)
405
418
  return err(created.error);
406
419
  const existingRead = await trySandbox(() => batchApi.readNamespacedJob({ namespace, name: sandboxId }), createFailed);
@@ -428,92 +441,48 @@ function createOrAdoptJob(batchApi, coreApi, namespace, sandboxId, ownerWorkflow
428
441
  const recreated = await trySandbox(() => batchApi.createNamespacedJob({ namespace, body: job }), createFailed);
429
442
  if (recreated.isErr())
430
443
  return err(recreated.error);
431
- return ok(recreated.value?.metadata?.uid);
432
- }
433
- return ok(existingRead.value.metadata?.uid);
434
- })());
435
- }
436
- /**
437
- * Make the cpu ConfigMap of a Job, or bring an existing one up to date.
438
- *
439
- * The ConfigMap comes after the Job, because the Job is its owner. Thus the
440
- * TTL of the Job and a Foreground delete both remove the ConfigMap with it.
441
- * The pod waits at its mount until the ConfigMap exists, and that wait is
442
- * shorter than the schedule of the pod.
443
- *
444
- * A `409 AlreadyExists` is the recovery re-run of the spawn step, or a
445
- * ConfigMap whose prior owner Job is not yet collected. Both cases get the
446
- * same data and the current owner through a replace.
447
- */
448
- function ensureCpuConfigMap(coreApi, namespace, sandboxId, jobUid, files) {
449
- const createFailed = (status, cause) => ({
450
- type: "container_create_failed",
451
- op: "k8s.createNamespacedConfigMap",
452
- sandboxId,
453
- status,
454
- cause,
455
- });
456
- const body = {
457
- apiVersion: "v1",
458
- kind: "ConfigMap",
459
- metadata: {
460
- name: sandboxId,
461
- namespace,
462
- labels: {
463
- [MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
464
- role: "sandbox",
465
- "cortex/sandbox-id": sandboxId,
466
- },
467
- ...(jobUid !== undefined && {
468
- ownerReferences: [{ apiVersion: "batch/v1", kind: "Job", name: sandboxId, uid: jobUid }],
469
- }),
470
- },
471
- data: {
472
- online: files.online,
473
- ...(files.cpuinfo !== undefined && { cpuinfo: files.cpuinfo }),
474
- },
475
- };
476
- return new ResultAsync((async () => {
477
- const created = await trySandbox(() => coreApi.createNamespacedConfigMap({ namespace, body }), createFailed);
478
- if (created.isOk())
479
444
  return ok(undefined);
480
- if (statusOf(created.error) !== 409)
481
- return err(created.error);
482
- const existing = await trySandbox(() => coreApi.readNamespacedConfigMap({ namespace, name: sandboxId }), createFailed);
483
- if (existing.isErr())
484
- return err(existing.error);
485
- const replaced = await trySandbox(() => coreApi.replaceNamespacedConfigMap({
486
- namespace,
487
- name: sandboxId,
488
- body: { ...body, metadata: { ...body.metadata, resourceVersion: existing.value.metadata?.resourceVersion } },
489
- }), createFailed);
490
- if (replaced.isErr())
491
- return err(replaced.error);
445
+ }
492
446
  return ok(undefined);
493
447
  })());
494
448
  }
495
449
  export function createK8sSandboxOps(config) {
496
450
  const { batchApi, coreApi } = config.batchApi && config.coreApi ? { batchApi: config.batchApi, coreApi: config.coreApi } : buildKubeApi();
497
- const readHostCpuinfo = config.readHostCpuinfo ?? readHostCpuinfoOnce;
498
451
  return {
499
452
  createSandbox(meta, identity) {
500
453
  return new ResultAsync((async () => {
501
454
  const { sandboxId } = identity;
502
- // The same floor as `threadLimitEnv`: a fraction of a core still
503
- // keeps one thread busy, and a count of zero is not representable.
504
- const threads = Math.max(1, Math.floor(meta.resources.cpu));
505
- const files = cpuFiles(threads, await readHostCpuinfo());
506
- const job = buildJobSpec(meta, config, identity, { hasCpuinfo: files.cpuinfo !== undefined });
455
+ // Farm resolution comes before any cluster work: a resolver
456
+ // refusal and a resolver throw refuse the whole call, and no
457
+ // Job is made.
458
+ let farm;
459
+ if (config.libStorePvc) {
460
+ const resolved = await resolveFarmSource(config.farmSource, meta.analysisId, "k8s.createSandbox");
461
+ if (resolved.isErr())
462
+ return err(resolved.error);
463
+ farm = resolved.value;
464
+ }
465
+ // The `inflexa.lock` gate, for a host that mounts the libs
466
+ // volume itself. The resolver owes the proof either way,
467
+ // but a farm half-written between the resolution and this
468
+ // create still mounts without a second look. A gate failure
469
+ // degrades exactly as the Docker backend does: no store
470
+ // mount, the Job is still made, and the sandbox reports the
471
+ // store as unavailable.
472
+ let libsMounted = !!config.libStorePvc;
473
+ if (config.libStorePvc && config.libStorePvcRoot && farm) {
474
+ const lock = readFarmLock(join(config.libStorePvcRoot, farm.farmPath));
475
+ libsMounted = lock.isOk();
476
+ if (lock.isErr()) {
477
+ (config.logger ?? createNoopLogger())
478
+ .named("k8s-client")
479
+ .warn("the farm of the analysis has no usable inflexa.lock at sandbox creation — mounting no package store (sandbox degrades to available:false)", { libStorePvc: config.libStorePvc, farmPath: farm.farmPath, lockError: lock.error.type, sandboxId });
480
+ }
481
+ }
482
+ const job = buildJobSpec(meta, config, identity, farm, libsMounted);
507
483
  const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, meta.childWorkflowId, job);
508
484
  if (adopted.isErr())
509
485
  return err(adopted.error);
510
- const cpuMap = await ensureCpuConfigMap(coreApi, config.namespace, sandboxId, adopted.value, files);
511
- if (cpuMap.isErr()) {
512
- // A pod whose ConfigMap never comes stays at ContainerCreating
513
- // until the ready wait expires. Fail now, with the real cause.
514
- await cleanupFailedJob(batchApi, coreApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
515
- return err(cpuMap.error);
516
- }
517
486
  // A Job whose pod never schedules (quota rejection, no nodes, spot
518
487
  // reclaim) is reaped by neither backoffLimit (an admission rejection
519
488
  // creates no pod to count) nor ttlSecondsAfterFinished (the Job never
@@ -537,22 +506,20 @@ export function createK8sSandboxOps(config) {
537
506
  cause,
538
507
  }));
539
508
  if (registered.isErr()) {
540
- await cleanupFailedJob(batchApi, coreApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
509
+ await cleanupFailedJob(batchApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
541
510
  return err(registered.error);
542
511
  }
543
512
  return ok(ref);
544
513
  }
545
- await cleanupFailedJob(batchApi, coreApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
514
+ await cleanupFailedJob(batchApi, config.namespace, sandboxId, config.logger ?? createNoopLogger());
546
515
  return err(ready.error);
547
516
  })());
548
517
  },
549
- // The owner reference removes the cpu ConfigMap with the Job. The explicit
550
- // delete covers a ConfigMap that got no owner, and it is a no-op on 404.
551
518
  teardown(ref) {
552
- return deleteJobIgnoreMissing(batchApi, config.namespace, ref.sandboxId).andThen(() => deleteConfigMapIgnoreMissing(coreApi, config.namespace, ref.sandboxId));
519
+ return deleteJobIgnoreMissing(batchApi, config.namespace, ref.sandboxId);
553
520
  },
554
521
  teardownById(sandboxId) {
555
- return deleteJobIgnoreMissing(batchApi, config.namespace, sandboxId).andThen(() => deleteConfigMapIgnoreMissing(coreApi, config.namespace, sandboxId));
522
+ return deleteJobIgnoreMissing(batchApi, config.namespace, sandboxId);
556
523
  },
557
524
  listManagedSandboxes() {
558
525
  return trySandbox(() => batchApi.listNamespacedJob({
@@ -613,13 +580,9 @@ export function createK8sSandboxOps(config) {
613
580
  * matters; a delete error here is logged and swallowed so it never masks the
614
581
  * create error returned to the caller.
615
582
  */
616
- async function cleanupFailedJob(batchApi, coreApi, namespace, sandboxId, logger) {
583
+ async function cleanupFailedJob(batchApi, namespace, sandboxId, logger) {
617
584
  const deleted = await deleteJobIgnoreMissing(batchApi, namespace, sandboxId);
618
585
  if (deleted.isErr()) {
619
586
  logger.named("k8s-client").error("failed to delete Job after startup failure", { sandboxId, err: deleted.error });
620
587
  }
621
- const deletedMap = await deleteConfigMapIgnoreMissing(coreApi, namespace, sandboxId);
622
- if (deletedMap.isErr()) {
623
- logger.named("k8s-client").error("failed to delete cpu ConfigMap after startup failure", { sandboxId, err: deletedMap.error });
624
- }
625
588
  }
@@ -27,6 +27,7 @@
27
27
  * resolved root directly, K8s addresses it as a `subPath` into the session PVC
28
28
  * (see {@link buildSessionSubPaths}).
29
29
  */
30
+ import type { ToolchainSource } from "./types.js";
30
31
  export declare const STEP_SUBDIRS: readonly ["output", "scripts", "figures", "logs", "notebooks"];
31
32
  /**
32
33
  * Container mountpoints of the two read-only stores. Exported because they are
@@ -35,6 +36,15 @@ export declare const STEP_SUBDIRS: readonly ["output", "scripts", "figures", "lo
35
36
  */
36
37
  export declare const LIBS_CONTAINER_PATH = "/mnt/libs";
37
38
  export declare const REFS_CONTAINER_PATH = "/mnt/refs";
39
+ /** Container path of the per-analysis read-write cache, when a farm resolution carries a cache location. */
40
+ export declare const FARM_CACHE_CONTAINER_PATH = "/mnt/libs/cache";
41
+ /**
42
+ * Container path of the mounted farm, keyed on the declared toolchain. With
43
+ * `"image"` the farm mounts at `/mnt/libs/farm`. With `"store"` (or absent)
44
+ * it stays `/mnt/libs/current`, because the baked resolvers of the old
45
+ * images name that path.
46
+ */
47
+ export declare function farmContainerPath(toolchainSource: ToolchainSource | undefined): string;
38
48
  export interface MountPlanCoords {
39
49
  analysisId: string;
40
50
  runId: string;
@@ -55,10 +65,18 @@ export interface MountPlanCoords {
55
65
  writableTail?: string;
56
66
  }
57
67
  export interface MountPlanStores {
58
- /** Lib store is mounted at `/mnt/libs` and its env is emitted. */
68
+ /** Lib store is mounted at `/mnt/libs`, with the farm nested inside it, and its env is emitted. */
59
69
  libs: boolean;
60
70
  /** Ref store is mounted at `/mnt/refs`. */
61
71
  refs: boolean;
72
+ /**
73
+ * The declared toolchain owner. Keys the farm container path and the
74
+ * resolver env. Absent means `"store"`: the legacy environment,
75
+ * byte-identical to the one before the field existed.
76
+ */
77
+ toolchainSource?: ToolchainSource;
78
+ /** The farm resolution carries a cache location, mounted read-write at `/mnt/libs/cache`. */
79
+ cache?: boolean;
62
80
  }
63
81
  export interface MountPlan {
64
82
  /** Flat read-only mount of the whole analysis tree. */
@@ -75,6 +93,10 @@ export interface MountPlan {
75
93
  workingDir: string;
76
94
  /** Container path of the lib store, present only when `libs`. */
77
95
  libsPath?: string;
96
+ /** Container path of the farm of the analysis, present only when `libs`. Keyed on the toolchain source. */
97
+ farmPath?: string;
98
+ /** Container path of the read-write cache, present only when `libs` and `cache`. */
99
+ cachePath?: string;
78
100
  /** Container path of the ref store, present only when `refs`. */
79
101
  refsPath?: string;
80
102
  /** Pre-created subdirectories under the writable mount. Empty when read-only
@@ -36,6 +36,17 @@ export const STEP_SUBDIRS = ["output", "scripts", "figures", "logs", "notebooks"
36
36
  */
37
37
  export const LIBS_CONTAINER_PATH = "/mnt/libs";
38
38
  export const REFS_CONTAINER_PATH = "/mnt/refs";
39
+ /** Container path of the per-analysis read-write cache, when a farm resolution carries a cache location. */
40
+ export const FARM_CACHE_CONTAINER_PATH = "/mnt/libs/cache";
41
+ /**
42
+ * Container path of the mounted farm, keyed on the declared toolchain. With
43
+ * `"image"` the farm mounts at `/mnt/libs/farm`. With `"store"` (or absent)
44
+ * it stays `/mnt/libs/current`, because the baked resolvers of the old
45
+ * images name that path.
46
+ */
47
+ export function farmContainerPath(toolchainSource) {
48
+ return toolchainSource === "image" ? `${LIBS_CONTAINER_PATH}/farm` : `${LIBS_CONTAINER_PATH}/current`;
49
+ }
39
50
  /**
40
51
  * The step tail beneath an analysis's workspace root, in both container and
41
52
  * PVC-relative space. The harness owns this layout (workspace-layout spec).
@@ -94,14 +105,37 @@ export function buildSessionSubPaths(coords, workspaceSubPath) {
94
105
  };
95
106
  }
96
107
  /**
97
- * Lib-store package-discovery env. PYTHONPATH is intentionally omitted —
98
- * system Python resolves via a `.pth` file in the lib store.
108
+ * Lib-store package-discovery env, keyed on the declared toolchain.
109
+ * PYTHONPATH is intentionally omitted — system Python resolves through a
110
+ * `.pth` file in the lib store.
111
+ *
112
+ * With `"store"` (or absent) the env is byte-identical to the legacy one:
113
+ * the conda `bin` under `/mnt/libs/current`, thus an old embedder keeps its
114
+ * exact environment. With `"image"` the image owns the toolchain: `PATH`
115
+ * holds `/opt/conda/bin` before the farm `python/bin` at the end, thus a
116
+ * farm script never shadows an image tool. The cache env points into the
117
+ * read-write cache mount only when it is present — the entrypoint fallback
118
+ * covers the caches otherwise.
99
119
  */
100
- function libStoreEnv() {
120
+ function libStoreEnv(toolchainSource, cache) {
121
+ if (toolchainSource !== "image") {
122
+ return {
123
+ R_LIBS_SITE: "/mnt/libs/current/r/github:/mnt/libs/current/r/bioconductor:/mnt/libs/current/r/cran",
124
+ NODE_PATH: "/mnt/libs/current/node/node_modules",
125
+ PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/mnt/libs/current/conda/bin",
126
+ };
127
+ }
128
+ const farm = farmContainerPath(toolchainSource);
101
129
  return {
102
- R_LIBS_SITE: "/mnt/libs/current/r/github:/mnt/libs/current/r/bioconductor:/mnt/libs/current/r/cran",
103
- NODE_PATH: "/mnt/libs/current/node/node_modules",
104
- PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/mnt/libs/current/conda/bin",
130
+ R_LIBS_SITE: `${farm}/r/github:${farm}/r/bioconductor:${farm}/r/cran`,
131
+ NODE_PATH: "/opt/node/node_modules",
132
+ PATH: `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/conda/bin:${farm}/python/bin`,
133
+ ...(cache
134
+ ? {
135
+ NUMBA_CACHE_DIR: `${FARM_CACHE_CONTAINER_PATH}/numba-cache`,
136
+ MPLCONFIGDIR: `${FARM_CACHE_CONTAINER_PATH}/matplotlib_config`,
137
+ }
138
+ : {}),
105
139
  };
106
140
  }
107
141
  export function buildMountPlan(coords, stores) {
@@ -112,16 +146,19 @@ export function buildMountPlan(coords, stores) {
112
146
  const readonlyTreePath = `/${analysisId}`;
113
147
  const write = sandboxWriteTail(coords);
114
148
  const writableStepPath = write === undefined ? undefined : `${readonlyTreePath}/${write.tail}`;
149
+ const cache = stores.libs && stores.cache === true;
115
150
  return {
116
151
  readonlyTreePath,
117
152
  writableStepPath,
118
153
  workingDir: writableStepPath ?? readonlyTreePath,
119
154
  libsPath: stores.libs ? LIBS_CONTAINER_PATH : undefined,
155
+ farmPath: stores.libs ? farmContainerPath(stores.toolchainSource) : undefined,
156
+ cachePath: cache ? FARM_CACHE_CONTAINER_PATH : undefined,
120
157
  refsPath: stores.refs ? REFS_CONTAINER_PATH : undefined,
121
158
  stepSubdirs: write?.subdirs ?? [],
122
159
  env: {
123
160
  PROVENANCE_WATCH_DIRS: readonlyTreePath,
124
- ...(stores.libs ? libStoreEnv() : {}),
161
+ ...(stores.libs ? libStoreEnv(stores.toolchainSource, cache) : {}),
125
162
  },
126
163
  };
127
164
  }