@inflexa-ai/harness 0.32.0 → 0.33.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/conversation-agent.d.ts +8 -0
  2. package/dist/agents/conversation-agent.js +15 -6
  3. package/dist/agents/sandbox/shared.d.ts +13 -1
  4. package/dist/agents/sandbox/shared.js +22 -5
  5. package/dist/config/environment-stores.d.ts +53 -5
  6. package/dist/index.d.ts +4 -0
  7. package/dist/index.js +3 -0
  8. package/dist/prompts/briefing.d.ts +1 -1
  9. package/dist/prompts/briefing.js +4 -0
  10. package/dist/prompts/planner.js +13 -0
  11. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  12. package/dist/prompts/sandbox/multi-omics-integration-agent.js +5 -5
  13. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  14. package/dist/prompts/sandbox/network-agent.js +4 -3
  15. package/dist/prompts/sandbox-standards.d.ts +20 -0
  16. package/dist/prompts/sandbox-standards.js +65 -0
  17. package/dist/reference-data/catalog.js +11 -9
  18. package/dist/sandbox/create-sandbox.d.ts +29 -4
  19. package/dist/sandbox/create-sandbox.js +6 -0
  20. package/dist/sandbox/docker-client.d.ts +16 -6
  21. package/dist/sandbox/docker-client.js +47 -31
  22. package/dist/sandbox/farm.d.ts +95 -0
  23. package/dist/sandbox/farm.js +127 -0
  24. package/dist/sandbox/k8s-client.d.ts +24 -5
  25. package/dist/sandbox/k8s-client.js +78 -9
  26. package/dist/sandbox/mount-plan.d.ts +23 -1
  27. package/dist/sandbox/mount-plan.js +44 -7
  28. package/dist/sandbox/sandbox-error.d.ts +12 -0
  29. package/dist/sandbox/sandbox-error.js +2 -0
  30. package/dist/sandbox/types.d.ts +118 -0
  31. package/dist/schemas/plan-schemas.d.ts +3 -0
  32. package/dist/schemas/plan-schemas.js +1 -1
  33. package/dist/schemas/validate-plan.js +20 -1
  34. package/dist/schemas/workflow-state.d.ts +4 -0
  35. package/dist/schemas/workflow-state.js +10 -0
  36. package/dist/tasks/data-profile.d.ts +15 -0
  37. package/dist/tasks/data-profile.js +36 -26
  38. package/dist/tools/execute-analysis.d.ts +18 -0
  39. package/dist/tools/execute-analysis.js +87 -0
  40. package/dist/tools/research/generate-plan.js +12 -2
  41. package/dist/tools/sandbox/index.d.ts +1 -0
  42. package/dist/tools/sandbox/index.js +1 -0
  43. package/dist/tools/sandbox/link-packages.d.ts +27 -0
  44. package/dist/tools/sandbox/link-packages.js +52 -0
  45. package/dist/tools/sandbox/list-available-packages.d.ts +50 -30
  46. package/dist/tools/sandbox/list-available-packages.js +131 -40
  47. package/package.json +2 -2
@@ -20,7 +20,7 @@ import { clearSandboxRef } from "../state/index.js";
20
20
  import { type AwaitExecOptions } from "./await-exec.js";
21
21
  import type { SandboxClient } from "./client.js";
22
22
  import { type SubmitExecDeps } from "./submit-exec.js";
23
- import { type CreateSandboxMeta, type SandboxTransport } from "./types.js";
23
+ import { type CreateSandboxMeta, type FarmSource, type SandboxTransport, type ToolchainSource } from "./types.js";
24
24
  /**
25
25
  * Narrow config slice the sandbox factory reads off `Env` — the backend
26
26
  * default and the K8s namespace fallback. Composition roots map their
@@ -65,6 +65,21 @@ export interface CreateSandboxClientConfig {
65
65
  resolveWorkspaceRoot: ResolveWorkspaceRoot;
66
66
  /** Docker: host dir bind-mounted read-only at `/mnt/libs`. */
67
67
  libStorePath?: string;
68
+ /**
69
+ * Where the farm of an analysis comes from. Required of every embedder —
70
+ * the harness never invents a farm location, and it reads no store-root
71
+ * `current` pointer. On Docker, `FarmLocation.farmPath` is a host
72
+ * directory. On K8s, it is a `subPath` into `libStorePvc`. Consulted only
73
+ * when a store is configured.
74
+ */
75
+ farmSource: FarmSource;
76
+ /**
77
+ * The declared toolchain owner. `"image"` keys the resolver env and the
78
+ * orient-core prompt text onto the image-owned toolchain. Absent means
79
+ * `"store"`: the legacy environment, byte-identical to the one before
80
+ * this field existed.
81
+ */
82
+ toolchainSource?: ToolchainSource;
68
83
  /** Docker: host dir bind-mounted read-only at `/mnt/refs`. */
69
84
  refStorePath?: string;
70
85
  /** Docker: force the container platform (e.g. `linux/amd64`) so the sandbox matches the mounted lib store's arch. */
@@ -98,6 +113,15 @@ export interface CreateSandboxClientConfig {
98
113
  sessionPvcRoot?: string;
99
114
  /** K8s: PVC claim mounted read-only at `/mnt/libs`. */
100
115
  libStorePvc?: string;
116
+ /**
117
+ * K8s: absolute mountpoint of `libStorePvc` on this process's filesystem,
118
+ * for a host that mounts the same volume. With it set, the backend proves
119
+ * the `inflexa.lock` of the resolved farm before the mount. Under a
120
+ * `fixed` farm source no resolver exists, thus this root carries the only
121
+ * lock gate that shape can have. Without it, the proof duty stays whole
122
+ * on the farm resolver.
123
+ */
124
+ libStorePvcRoot?: string;
101
125
  /** K8s: PVC claim mounted read-only at `/mnt/refs`. */
102
126
  refStorePvc?: string;
103
127
  /** K8s: node selector pinning sandbox pods to the dedicated agent pool. */
@@ -109,9 +133,10 @@ export interface CreateSandboxClientConfig {
109
133
  /** Override the backend selection — defaults to `env.SANDBOX_BACKEND`. */
110
134
  backend?: "docker" | "k8s";
111
135
  /**
112
- * Optional logger forwarded to the Docker backend so a lib-store degradation
113
- * (a configured store whose `current` vanished/went incomplete mid-session) is
114
- * observable rather than a silent mount drop. No-op when unset.
136
+ * Optional logger forwarded to the Docker backend so a store degradation
137
+ * (a resolved farm whose `inflexa.lock` vanished or went invalid
138
+ * mid-session) is observable rather than a silent mount drop. No-op when
139
+ * unset.
115
140
  */
116
141
  logger?: Logger;
117
142
  /** Dependency seams (fetch, durable step/sleep, clock, recv, warn sink) forwarded to submit/await. */
@@ -86,10 +86,14 @@ export function createSandboxClient(config) {
86
86
  sessionPvcRoot: config.sessionPvcRoot,
87
87
  resolveWorkspaceRoot: config.resolveWorkspaceRoot,
88
88
  libStorePvc: config.libStorePvc,
89
+ libStorePvcRoot: config.libStorePvcRoot,
90
+ farmSource: config.farmSource,
91
+ toolchainSource: config.toolchainSource,
89
92
  refStorePvc: config.refStorePvc,
90
93
  nodeSelector: config.nodeSelector,
91
94
  tolerations: config.tolerations,
92
95
  runtimeClassName: config.runtimeClassName,
96
+ logger: config.logger,
93
97
  registerSandbox,
94
98
  })
95
99
  : createDockerSandboxOps({
@@ -98,6 +102,8 @@ export function createSandboxClient(config) {
98
102
  transport,
99
103
  resolveWorkspaceRoot: config.resolveWorkspaceRoot,
100
104
  libStorePath: config.libStorePath,
105
+ farmSource: config.farmSource,
106
+ toolchainSource: config.toolchainSource,
101
107
  refStorePath: config.refStorePath,
102
108
  platform: config.platform,
103
109
  engineSocketPath: config.engineSocketPath,
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Thin wrapper around `dockerode`. Storage is wired via `HostConfig.Binds`:
5
5
  * a flat read-only mount of the analysis tree at `/{resourceId}`, a nested
6
- * read-write mount of the step's artifact dir, and the lib/ref stores at
7
- * `/mnt/libs` / `/mnt/refs` when their host paths are configured. Container
8
- * paths and lib-store env come from the shared mount plan (`mount-plan.ts`).
6
+ * read-write mount of the step's artifact dir, the package store at
7
+ * `/mnt/libs` with the resolved farm nested inside it, and the ref store at
8
+ * `/mnt/refs` when its host path is configured. Container paths and
9
+ * lib-store env come from the shared mount plan (`mount-plan.ts`).
9
10
  *
10
11
  * ## The cpu files
11
12
  *
@@ -42,7 +43,7 @@ import { ResultAsync } from "neverthrow";
42
43
  import type { Logger } from "../lib/logger.js";
43
44
  import { type ResolveWorkspaceRoot } from "../workspace/paths.js";
44
45
  import { type SandboxError } from "./sandbox-error.js";
45
- import type { CreateSandboxMeta, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport } from "./types.js";
46
+ import type { CreateSandboxMeta, FarmSource, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SandboxTransport, ToolchainSource } from "./types.js";
46
47
  export interface DockerClientConfig {
47
48
  image: string;
48
49
  /** Cortex base URL injected into the sandbox env in callback mode so callbacks land here. Unused in poll mode. */
@@ -53,6 +54,15 @@ export interface DockerClientConfig {
53
54
  resolveWorkspaceRoot: ResolveWorkspaceRoot;
54
55
  /** Host lib store; bind-mounted read-only at `/mnt/libs` when set. */
55
56
  libStorePath?: string;
57
+ /**
58
+ * Where the farm of an analysis comes from. Required — the harness never
59
+ * invents a farm location. `FarmLocation.farmPath` is a host directory
60
+ * here, bind-mounted read-only at the farm container path. Consulted only
61
+ * when `libStorePath` is set.
62
+ */
63
+ farmSource: FarmSource;
64
+ /** The declared toolchain owner. Absent means `"store"`, the legacy environment. */
65
+ toolchainSource?: ToolchainSource;
56
66
  /**
57
67
  * Host ref store; bind-mounted read-only at `/mnt/refs`. Embedders pass the
58
68
  * configured store location unconditionally — existence is (re-)checked at each
@@ -85,8 +95,8 @@ export interface DockerClientConfig {
85
95
  */
86
96
  readHostCpuinfo?: () => Promise<string | undefined>;
87
97
  /**
88
- * Optional logger so a lib-store degradation (a configured store whose `current`
89
- * vanished or went incomplete by sandbox-create time) is observable instead of a
98
+ * Optional logger so a store degradation (a resolved farm whose `inflexa.lock`
99
+ * is absent or invalid by sandbox-create time) is observable instead of a
90
100
  * silent libs-mount drop. Matches the `reaper`/`watchdog` logger seam.
91
101
  */
92
102
  logger?: Logger;
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Thin wrapper around `dockerode`. Storage is wired via `HostConfig.Binds`:
5
5
  * a flat read-only mount of the analysis tree at `/{resourceId}`, a nested
6
- * read-write mount of the step's artifact dir, and the lib/ref stores at
7
- * `/mnt/libs` / `/mnt/refs` when their host paths are configured. Container
8
- * paths and lib-store env come from the shared mount plan (`mount-plan.ts`).
6
+ * read-write mount of the step's artifact dir, the package store at
7
+ * `/mnt/libs` with the resolved farm nested inside it, and the ref store at
8
+ * `/mnt/refs` when its host path is configured. Container paths and
9
+ * lib-store env come from the shared mount plan (`mount-plan.ts`).
9
10
  *
10
11
  * ## The cpu files
11
12
  *
@@ -37,7 +38,7 @@
37
38
  * Either way the workload ends as uid 1000 with `no-new-privileges` and no
38
39
  * effective capabilities.
39
40
  */
40
- import { existsSync, lstatSync, statSync } from "node:fs";
41
+ import { lstatSync } from "node:fs";
41
42
  import { mkdir, rm, writeFile } from "node:fs/promises";
42
43
  import { dirname, join } from "node:path";
43
44
  import Docker from "dockerode";
@@ -45,6 +46,7 @@ import { ResultAsync, err, ok } from "neverthrow";
45
46
  import { createNoopLogger } from "../lib/console-logger.js";
46
47
  import { tailWritePrefix } from "../workspace/paths.js";
47
48
  import { trySandbox } from "./sandbox-error.js";
49
+ import { readFarmLock, resolveFarmSource } from "./farm.js";
48
50
  import { buildMountPlan, sandboxWriteTail } from "./mount-plan.js";
49
51
  import { CPU_ONLINE_PATH, CPUINFO_PATH, cpuFiles, readHostCpuinfoOnce } from "./cpu-files.js";
50
52
  import { threadLimitEnv } from "./thread-env.js";
@@ -74,26 +76,6 @@ const RUN_ID_LABEL = "cortex/run-id";
74
76
  const STEP_ID_LABEL = "cortex/step-id";
75
77
  /** The directory next to the workspace root that holds the cpu files of each sandbox. */
76
78
  const CPU_FILES_DIR = ".cpu";
77
- /**
78
- * Whether the lib store's `current` resolves to a COMPLETE, usable version, not merely a
79
- * present symlink. By sandbox-create time `current` may be gone (concurrent prune/`rm`), a
80
- * dangling symlink (its target pruned), or an incomplete tree. Binding a missing source
81
- * makes Docker auto-create a root-owned dir (bricking a later store refresh); binding a broken
82
- * one mounts broken content. Require a resolved directory carrying both completeness
83
- * markers `activate` writes before it flips the pointer.
84
- */
85
- function libStoreUsable(libStorePath) {
86
- const current = join(libStorePath, "current");
87
- try {
88
- // statSync FOLLOWS the symlink, so a dangling `current` throws here and is rejected.
89
- if (!statSync(current).isDirectory())
90
- return false;
91
- }
92
- catch {
93
- return false;
94
- }
95
- return existsSync(join(current, "packages.txt")) && existsSync(join(current, "meta.json"));
96
- }
97
79
  /**
98
80
  * Whether `refStorePath` is, right now, a real directory fit to be a bind authority.
99
81
  * Deliberately shallow — it checks existence-as-a-directory only, never the store's
@@ -253,13 +235,41 @@ export function createDockerSandboxOps(config) {
253
235
  createSandbox(meta, identity) {
254
236
  return new ResultAsync((async () => {
255
237
  const { sandboxId, callbackSecret } = identity;
256
- // Re-check AT sandbox-creation time: `config.libStorePath` was fixed at CLI
257
- // boot and the store may have gone away since (see libStoreUsable). Not
258
- // usable → skip the mount (sandbox degrades to available:false) and log it,
259
- // since an otherwise-silent libs-mount drop is invisible to operators.
260
- const libsMounted = !!config.libStorePath && libStoreUsable(config.libStorePath);
261
- if (config.libStorePath && !libsMounted) {
262
- logger.warn("lib store configured but `current` is missing or incomplete at sandbox creation — mounting no library store (sandbox degrades to available:false)", { libStorePath: config.libStorePath, sandboxId });
238
+ // Farm resolution comes before any container work: a resolver
239
+ // refusal and a resolver throw refuse the whole call, and no
240
+ // container is made. The reason of the embedder rides in the error.
241
+ let farm;
242
+ if (config.libStorePath) {
243
+ const resolved = await resolveFarmSource(config.farmSource, meta.analysisId, "docker.createSandbox");
244
+ if (resolved.isErr())
245
+ return err(resolved.error);
246
+ farm = resolved.value;
247
+ }
248
+ // The `inflexa.lock` gate runs at each create: the store may have
249
+ // gone away, or the farm may be half-written. A gate failure
250
+ // degrades — both store mounts drop, the container is still made,
251
+ // and the sandbox reports the store as unavailable. The warning is
252
+ // logged because an otherwise-silent mount drop is invisible to
253
+ // operators.
254
+ let libsMounted = false;
255
+ if (config.libStorePath && farm) {
256
+ const lock = readFarmLock(farm.farmPath);
257
+ libsMounted = lock.isOk();
258
+ if (lock.isErr()) {
259
+ logger.warn("the farm of the analysis has no usable inflexa.lock at sandbox creation — mounting no package store (sandbox degrades to available:false)", { libStorePath: config.libStorePath, farmPath: farm.farmPath, lockError: lock.error.type, sandboxId });
260
+ }
261
+ }
262
+ // The cache path is the one farm value with no gate of its
263
+ // own: the lock gate proves `farmPath`, and this bind string
264
+ // would carry a malformed cache path verbatim. An unusable
265
+ // value degrades to no cache mount — the rule of a missing
266
+ // one — because the entrypoint fallback covers the caches.
267
+ const cacheBindable = farm?.cachePath !== undefined && farm.cachePath.startsWith("/") && !farm.cachePath.includes(":");
268
+ if (farm?.cachePath !== undefined && !cacheBindable) {
269
+ logger.warn("the farm cache path cannot ride a docker bind — mounting no cache", {
270
+ cachePath: farm.cachePath,
271
+ sandboxId,
272
+ });
263
273
  }
264
274
  // Re-checked here too, so a ref store installed after boot is mounted into
265
275
  // the next sandbox. Unlike libs, a missing ref store is NOT warned: a missing
@@ -271,6 +281,8 @@ export function createDockerSandboxOps(config) {
271
281
  const plan = buildMountPlan(meta, {
272
282
  libs: libsMounted,
273
283
  refs: refsMounted,
284
+ toolchainSource: config.toolchainSource,
285
+ cache: cacheBindable,
274
286
  });
275
287
  const hostTreePath = config.resolveWorkspaceRoot(meta.analysisId);
276
288
  // `tailWritePrefix` (not a raw `join`) so the RW bind source runs through
@@ -290,12 +302,16 @@ export function createDockerSandboxOps(config) {
290
302
  const cpuBinds = await writeCpuFiles(cpuDir, cpuFiles(threads, cpuinfo), logger, sandboxId);
291
303
  if (cpuBinds.length > 0)
292
304
  cpuDirs.set(sandboxId, cpuDir);
305
+ // The farm bind comes after the store bind, thus the nesting is
306
+ // stable; the cache bind comes after the two store binds.
293
307
  const binds = [
294
308
  `${hostTreePath}:${plan.readonlyTreePath}:ro`,
295
309
  ...(write && plan.writableStepPath
296
310
  ? [`${tailWritePrefix({ workspaceRoot: hostTreePath, tail: write.tail })}:${plan.writableStepPath}:rw`]
297
311
  : []),
298
312
  ...(libsMounted && config.libStorePath ? [`${config.libStorePath}:${plan.libsPath}:ro`] : []),
313
+ ...(libsMounted && farm && plan.farmPath ? [`${farm.farmPath}:${plan.farmPath}:ro`] : []),
314
+ ...(libsMounted && cacheBindable && farm?.cachePath && plan.cachePath ? [`${farm.cachePath}:${plan.cachePath}:rw`] : []),
299
315
  ...(refsMounted && config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
300
316
  ...cpuBinds,
301
317
  ];
@@ -0,0 +1,95 @@
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 { type Result } from "neverthrow";
16
+ import { z } from "zod";
17
+ import type { SandboxError } from "./sandbox-error.js";
18
+ import type { FarmLocation, FarmSource } from "./types.js";
19
+ /** The one metadata file of a farm. */
20
+ export declare 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 declare const FarmLockPackageSchema: z.ZodObject<{
23
+ name: z.ZodString;
24
+ version: z.ZodString;
25
+ track: z.ZodString;
26
+ store_dir: z.ZodString;
27
+ hash: z.ZodString;
28
+ requested: z.ZodBoolean;
29
+ }, z.core.$loose>;
30
+ /**
31
+ * `inflexa.lock` at schema version 1. Additive fields pass through — a
32
+ * breaking change to the shape must move the schema number instead.
33
+ */
34
+ export declare const FarmLockSchema: z.ZodObject<{
35
+ schema: z.ZodLiteral<1>;
36
+ arch: z.ZodEnum<{
37
+ amd64: "amd64";
38
+ arm64: "arm64";
39
+ }>;
40
+ packages: z.ZodArray<z.ZodObject<{
41
+ name: z.ZodString;
42
+ version: z.ZodString;
43
+ track: z.ZodString;
44
+ store_dir: z.ZodString;
45
+ hash: z.ZodString;
46
+ requested: z.ZodBoolean;
47
+ }, z.core.$loose>>;
48
+ languages: z.ZodObject<{
49
+ python: z.ZodOptional<z.ZodObject<{
50
+ version: z.ZodString;
51
+ index: z.ZodString;
52
+ }, z.core.$loose>>;
53
+ r: z.ZodOptional<z.ZodObject<{
54
+ version: z.ZodString;
55
+ bioc_releases: z.ZodArray<z.ZodString>;
56
+ pak_lock: z.ZodOptional<z.ZodUnknown>;
57
+ }, z.core.$loose>>;
58
+ }, z.core.$loose>;
59
+ warm: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
60
+ script_sha256: z.ZodString;
61
+ cache_entries: z.ZodArray<z.ZodString>;
62
+ }, z.core.$loose>>>;
63
+ merge_conflicts: z.ZodOptional<z.ZodArray<z.ZodObject<{
64
+ entry: z.ZodString;
65
+ action: z.ZodEnum<{
66
+ skipped: "skipped";
67
+ "kept-first": "kept-first";
68
+ }>;
69
+ }, z.core.$loose>>>;
70
+ }, z.core.$loose>;
71
+ export type FarmLock = z.infer<typeof FarmLockSchema>;
72
+ export type FarmLockError = {
73
+ readonly type: "lock_unreadable";
74
+ readonly lockPath: string;
75
+ readonly cause: unknown;
76
+ } | {
77
+ readonly type: "lock_invalid";
78
+ readonly lockPath: string;
79
+ readonly cause: unknown;
80
+ };
81
+ /**
82
+ * Read and validate the `inflexa.lock` of a farm directory. The mount gate
83
+ * accepts a farm only when this returns ok — a farm whose lock is absent,
84
+ * unparseable, or at an unknown schema version is not usable.
85
+ */
86
+ export declare function readFarmLock(farmPath: string): Result<FarmLock, FarmLockError>;
87
+ /** Read and validate one `inflexa.lock` by its file path. The package inventory reads through this too. */
88
+ export declare function readFarmLockFile(lockPath: string): Result<FarmLock, FarmLockError>;
89
+ /**
90
+ * Resolve the farm source of one analysis. Runs before any container work.
91
+ * `fixed` answers at once. `per-analysis` calls the resolver of the
92
+ * embedder — an `unavailable` result and a throw both refuse the call with
93
+ * `farm_unavailable`, and the reason of the embedder rides in the error.
94
+ */
95
+ export declare function resolveFarmSource(source: FarmSource, analysisId: string, op: string): Promise<Result<FarmLocation, SandboxError>>;
@@ -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. */