@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.
- package/dist/agents/conversation-agent.d.ts +15 -0
- package/dist/agents/conversation-agent.js +16 -6
- package/dist/agents/report-session-agent.d.ts +9 -0
- package/dist/agents/report-session-agent.js +10 -2
- package/dist/agents/sandbox/shared.d.ts +13 -1
- package/dist/agents/sandbox/shared.js +22 -5
- package/dist/app/chat-turn.d.ts +11 -0
- package/dist/app/chat-turn.js +17 -1
- package/dist/app/spawn-report-session.d.ts +12 -0
- package/dist/app/spawn-report-session.js +21 -1
- package/dist/config/environment-stores.d.ts +53 -5
- package/dist/contracts/report-blocks.d.ts +8 -8
- package/dist/contracts/schemas/chat-parts.d.ts +6 -6
- package/dist/index.d.ts +8 -1
- package/dist/index.js +11 -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/provenance/seam.d.ts +203 -0
- package/dist/provenance/seam.js +71 -0
- package/dist/reference-data/catalog.js +11 -9
- package/dist/report-model/authoring-grammar.d.ts +2 -2
- package/dist/report-render/assets.d.ts +10 -0
- package/dist/report-render/assets.js +14 -0
- package/dist/report-render/design.js +256 -0
- package/dist/report-render/page.d.ts +75 -0
- package/dist/report-render/page.js +793 -1
- package/dist/report-render/provenance-data.d.ts +42 -0
- package/dist/report-render/provenance-data.js +62 -0
- package/dist/report-render/render.d.ts +20 -1
- package/dist/report-render/render.js +23 -10
- package/dist/report-render/types.d.ts +3 -0
- package/dist/report-render/views/chart-view.d.ts +5 -1
- package/dist/report-render/views/chart-view.js +6 -2
- package/dist/report-render/views/lineage.d.ts +93 -0
- package/dist/report-render/views/lineage.js +86 -0
- package/dist/report-render/views/page-view.d.ts +22 -1
- package/dist/report-render/views/page-view.js +12 -3
- package/dist/report-render/views/prose.d.ts +7 -1
- package/dist/report-render/views/prose.js +10 -6
- package/dist/report-render/views/references-view.d.ts +17 -1
- package/dist/report-render/views/references-view.js +12 -4
- package/dist/report-render/views/values.d.ts +17 -4
- package/dist/report-render/views/values.js +21 -8
- package/dist/runtime/assemble.d.ts +22 -0
- package/dist/runtime/assemble.js +4 -0
- 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 -10
- package/dist/sandbox/k8s-client.js +85 -122
- 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/artifact-manifest.d.ts +4 -4
- 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/display/show-user.d.ts +1 -1
- package/dist/tools/execute-analysis.d.ts +18 -0
- package/dist/tools/execute-analysis.js +87 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +16 -1
- package/dist/tools/report-authoring/authoring-tools.js +58 -16
- package/dist/tools/report-session/derive-table.d.ts +3 -0
- package/dist/tools/report-session/derive-table.js +15 -0
- package/dist/tools/report-session/preview-report.d.ts +13 -4
- package/dist/tools/report-session/preview-report.js +23 -6
- package/dist/tools/report-session/record-version.d.ts +3 -0
- package/dist/tools/report-session/record-version.js +7 -3
- 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/dist/tools/start-report-session.d.ts +7 -0
- package/dist/tools/start-report-session.js +1 -0
- package/dist/workflows/execute-analysis.d.ts +15 -69
- package/dist/workflows/execute-analysis.js +5 -4
- 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.
|
|
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. */
|
|
@@ -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.
|
|
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
|
-
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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,
|
|
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,
|
|
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)
|
|
519
|
+
return deleteJobIgnoreMissing(batchApi, config.namespace, ref.sandboxId);
|
|
553
520
|
},
|
|
554
521
|
teardownById(sandboxId) {
|
|
555
|
-
return deleteJobIgnoreMissing(batchApi, 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,
|
|
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
|
|
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
|
|
98
|
-
*
|
|
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:
|
|
103
|
-
NODE_PATH: "/
|
|
104
|
-
PATH:
|
|
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
|
}
|