@inflexa-ai/harness 0.32.0 → 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.
- package/dist/agents/conversation-agent.d.ts +8 -0
- package/dist/agents/conversation-agent.js +15 -6
- package/dist/agents/sandbox/shared.d.ts +13 -1
- package/dist/agents/sandbox/shared.js +22 -5
- package/dist/config/environment-stores.d.ts +53 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/prompts/briefing.d.ts +1 -1
- package/dist/prompts/briefing.js +4 -0
- package/dist/prompts/planner.js +13 -0
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +5 -5
- package/dist/prompts/sandbox/network-agent.d.ts +1 -1
- package/dist/prompts/sandbox/network-agent.js +4 -3
- package/dist/prompts/sandbox-standards.d.ts +20 -0
- package/dist/prompts/sandbox-standards.js +65 -0
- package/dist/reference-data/catalog.js +11 -9
- package/dist/sandbox/create-sandbox.d.ts +20 -4
- package/dist/sandbox/create-sandbox.js +4 -0
- package/dist/sandbox/docker-client.d.ts +16 -6
- package/dist/sandbox/docker-client.js +47 -31
- package/dist/sandbox/farm.d.ts +95 -0
- package/dist/sandbox/farm.js +127 -0
- package/dist/sandbox/k8s-client.d.ts +24 -5
- package/dist/sandbox/k8s-client.js +78 -9
- package/dist/sandbox/mount-plan.d.ts +23 -1
- package/dist/sandbox/mount-plan.js +44 -7
- package/dist/sandbox/sandbox-error.d.ts +12 -0
- package/dist/sandbox/sandbox-error.js +2 -0
- package/dist/sandbox/types.d.ts +118 -0
- package/dist/schemas/plan-schemas.d.ts +3 -0
- package/dist/schemas/plan-schemas.js +1 -1
- package/dist/schemas/validate-plan.js +20 -1
- package/dist/schemas/workflow-state.d.ts +4 -0
- package/dist/schemas/workflow-state.js +10 -0
- package/dist/tasks/data-profile.d.ts +15 -0
- package/dist/tasks/data-profile.js +36 -26
- package/dist/tools/execute-analysis.d.ts +18 -0
- package/dist/tools/execute-analysis.js +87 -0
- package/dist/tools/research/generate-plan.js +12 -2
- package/dist/tools/sandbox/index.d.ts +1 -0
- package/dist/tools/sandbox/index.js +1 -0
- package/dist/tools/sandbox/link-packages.d.ts +27 -0
- package/dist/tools/sandbox/link-packages.js +52 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +50 -30
- package/dist/tools/sandbox/list-available-packages.js +131 -40
- package/package.json +1 -1
|
@@ -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. */
|
|
@@ -109,9 +124,10 @@ export interface CreateSandboxClientConfig {
|
|
|
109
124
|
/** Override the backend selection — defaults to `env.SANDBOX_BACKEND`. */
|
|
110
125
|
backend?: "docker" | "k8s";
|
|
111
126
|
/**
|
|
112
|
-
* Optional logger forwarded to the Docker backend so a
|
|
113
|
-
* (a
|
|
114
|
-
* observable rather than a silent mount drop. No-op when
|
|
127
|
+
* Optional logger forwarded to the Docker backend so a store degradation
|
|
128
|
+
* (a resolved farm whose `inflexa.lock` vanished or went invalid
|
|
129
|
+
* mid-session) is observable rather than a silent mount drop. No-op when
|
|
130
|
+
* unset.
|
|
115
131
|
*/
|
|
116
132
|
logger?: Logger;
|
|
117
133
|
/** Dependency seams (fetch, durable step/sleep, clock, recv, warn sink) forwarded to submit/await. */
|
|
@@ -86,6 +86,8 @@ export function createSandboxClient(config) {
|
|
|
86
86
|
sessionPvcRoot: config.sessionPvcRoot,
|
|
87
87
|
resolveWorkspaceRoot: config.resolveWorkspaceRoot,
|
|
88
88
|
libStorePvc: config.libStorePvc,
|
|
89
|
+
farmSource: config.farmSource,
|
|
90
|
+
toolchainSource: config.toolchainSource,
|
|
89
91
|
refStorePvc: config.refStorePvc,
|
|
90
92
|
nodeSelector: config.nodeSelector,
|
|
91
93
|
tolerations: config.tolerations,
|
|
@@ -98,6 +100,8 @@ export function createSandboxClient(config) {
|
|
|
98
100
|
transport,
|
|
99
101
|
resolveWorkspaceRoot: config.resolveWorkspaceRoot,
|
|
100
102
|
libStorePath: config.libStorePath,
|
|
103
|
+
farmSource: config.farmSource,
|
|
104
|
+
toolchainSource: config.toolchainSource,
|
|
101
105
|
refStorePath: config.refStorePath,
|
|
102
106
|
platform: config.platform,
|
|
103
107
|
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,
|
|
7
|
-
* `/mnt/libs`
|
|
8
|
-
*
|
|
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
|
|
89
|
-
*
|
|
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,
|
|
7
|
-
* `/mnt/libs`
|
|
8
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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. */
|
|
@@ -6,15 +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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
22
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
@@ -108,11 +110,30 @@ function workspaceSubPathFor(config, analysisId) {
|
|
|
108
110
|
}
|
|
109
111
|
return posix;
|
|
110
112
|
}
|
|
111
|
-
|
|
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) {
|
|
112
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}.
|
|
113
132
|
const plan = buildMountPlan(meta, {
|
|
114
|
-
libs:
|
|
133
|
+
libs: libsMounted,
|
|
115
134
|
refs: !!config.refStorePvc,
|
|
135
|
+
toolchainSource: config.toolchainSource,
|
|
136
|
+
cache: libsMounted && farm?.cachePath !== undefined,
|
|
116
137
|
});
|
|
117
138
|
const transport = config.transport ?? "poll";
|
|
118
139
|
const spec = meta.resources;
|
|
@@ -168,15 +189,36 @@ function buildJobSpec(meta, config, identity) {
|
|
|
168
189
|
}
|
|
169
190
|
}
|
|
170
191
|
if (config.libStorePvc && plan.libsPath) {
|
|
192
|
+
const cacheSubPath = farm?.cachePath !== undefined && plan.cachePath ? pvcSubPath(farm.cachePath, "the farm cache") : undefined;
|
|
171
193
|
volumes.push({
|
|
172
194
|
name: LIBS_VOLUME_NAME,
|
|
173
|
-
|
|
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 } : {}) },
|
|
174
199
|
});
|
|
175
200
|
volumeMounts.push({
|
|
176
201
|
name: LIBS_VOLUME_NAME,
|
|
177
202
|
mountPath: plan.libsPath,
|
|
178
203
|
readOnly: true,
|
|
179
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
|
+
}
|
|
180
222
|
}
|
|
181
223
|
if (config.refStorePvc && plan.refsPath) {
|
|
182
224
|
volumes.push({
|
|
@@ -410,7 +452,34 @@ export function createK8sSandboxOps(config) {
|
|
|
410
452
|
createSandbox(meta, identity) {
|
|
411
453
|
return new ResultAsync((async () => {
|
|
412
454
|
const { sandboxId } = identity;
|
|
413
|
-
|
|
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);
|
|
414
483
|
const adopted = await createOrAdoptJob(batchApi, coreApi, config.namespace, sandboxId, meta.childWorkflowId, job);
|
|
415
484
|
if (adopted.isErr())
|
|
416
485
|
return err(adopted.error);
|