@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
@@ -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. Container paths and lib-store env come
10
- * from the shared mount plan; the PVC `subPath`s are derived from the same
11
- * `resolveWorkspaceRoot` seam the harness pre-creates the step tree under, so
12
- * both sides address one directory by construction.
9
+ * / `/mnt/refs` when their PVCs are set. The farm of the analysis mounts as a
10
+ * read-only `subPath` of the libs PVC, after the store mount. Container paths
11
+ * and lib-store env come from the shared mount plan; the PVC `subPath`s are
12
+ * derived from the same `resolveWorkspaceRoot` seam the harness pre-creates
13
+ * the step tree under, so both sides address one directory by construction.
13
14
  */
14
- import { relative as relativePath, sep } from "node:path";
15
+ import { join, relative as relativePath, sep } from "node:path";
15
16
  import { BatchV1Api, CoreV1Api, KubeConfig, } from "@kubernetes/client-node";
16
17
  import { ResultAsync, err, ok } from "neverthrow";
17
18
  import { trySandbox } from "./sandbox-error.js";
19
+ import { readFarmLock, resolveFarmSource } from "./farm.js";
18
20
  import { buildMountPlan, buildSessionSubPaths } from "./mount-plan.js";
19
21
  import { threadLimitEnv } from "./thread-env.js";
20
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
- function buildJobSpec(meta, config, identity) {
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: !!config.libStorePvc,
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
- persistentVolumeClaim: { claimName: config.libStorePvc, readOnly: true },
195
+ // Volume-level readOnly only when nothing writes through the claim:
196
+ // the cache mount needs write access, and the per-mount flags keep
197
+ // the store and the farm read-only either way.
198
+ persistentVolumeClaim: { claimName: config.libStorePvc, ...(cacheSubPath === undefined ? { readOnly: true } : {}) },
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
- const job = buildJobSpec(meta, config, identity);
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);
@@ -27,6 +27,7 @@
27
27
  * resolved root directly, K8s addresses it as a `subPath` into the session PVC
28
28
  * (see {@link buildSessionSubPaths}).
29
29
  */
30
+ import type { ToolchainSource } from "./types.js";
30
31
  export declare const STEP_SUBDIRS: readonly ["output", "scripts", "figures", "logs", "notebooks"];
31
32
  /**
32
33
  * Container mountpoints of the two read-only stores. Exported because they are
@@ -35,6 +36,15 @@ export declare const STEP_SUBDIRS: readonly ["output", "scripts", "figures", "lo
35
36
  */
36
37
  export declare const LIBS_CONTAINER_PATH = "/mnt/libs";
37
38
  export declare const REFS_CONTAINER_PATH = "/mnt/refs";
39
+ /** Container path of the per-analysis read-write cache, when a farm resolution carries a cache location. */
40
+ export declare const FARM_CACHE_CONTAINER_PATH = "/mnt/libs/cache";
41
+ /**
42
+ * Container path of the mounted farm, keyed on the declared toolchain. With
43
+ * `"image"` the farm mounts at `/mnt/libs/farm`. With `"store"` (or absent)
44
+ * it stays `/mnt/libs/current`, because the baked resolvers of the old
45
+ * images name that path.
46
+ */
47
+ export declare function farmContainerPath(toolchainSource: ToolchainSource | undefined): string;
38
48
  export interface MountPlanCoords {
39
49
  analysisId: string;
40
50
  runId: string;
@@ -55,10 +65,18 @@ export interface MountPlanCoords {
55
65
  writableTail?: string;
56
66
  }
57
67
  export interface MountPlanStores {
58
- /** Lib store is mounted at `/mnt/libs` and its env is emitted. */
68
+ /** Lib store is mounted at `/mnt/libs`, with the farm nested inside it, and its env is emitted. */
59
69
  libs: boolean;
60
70
  /** Ref store is mounted at `/mnt/refs`. */
61
71
  refs: boolean;
72
+ /**
73
+ * The declared toolchain owner. Keys the farm container path and the
74
+ * resolver env. Absent means `"store"`: the legacy environment,
75
+ * byte-identical to the one before the field existed.
76
+ */
77
+ toolchainSource?: ToolchainSource;
78
+ /** The farm resolution carries a cache location, mounted read-write at `/mnt/libs/cache`. */
79
+ cache?: boolean;
62
80
  }
63
81
  export interface MountPlan {
64
82
  /** Flat read-only mount of the whole analysis tree. */
@@ -75,6 +93,10 @@ export interface MountPlan {
75
93
  workingDir: string;
76
94
  /** Container path of the lib store, present only when `libs`. */
77
95
  libsPath?: string;
96
+ /** Container path of the farm of the analysis, present only when `libs`. Keyed on the toolchain source. */
97
+ farmPath?: string;
98
+ /** Container path of the read-write cache, present only when `libs` and `cache`. */
99
+ cachePath?: string;
78
100
  /** Container path of the ref store, present only when `refs`. */
79
101
  refsPath?: string;
80
102
  /** Pre-created subdirectories under the writable mount. Empty when read-only
@@ -36,6 +36,17 @@ export const STEP_SUBDIRS = ["output", "scripts", "figures", "logs", "notebooks"
36
36
  */
37
37
  export const LIBS_CONTAINER_PATH = "/mnt/libs";
38
38
  export const REFS_CONTAINER_PATH = "/mnt/refs";
39
+ /** Container path of the per-analysis read-write cache, when a farm resolution carries a cache location. */
40
+ export const FARM_CACHE_CONTAINER_PATH = "/mnt/libs/cache";
41
+ /**
42
+ * Container path of the mounted farm, keyed on the declared toolchain. With
43
+ * `"image"` the farm mounts at `/mnt/libs/farm`. With `"store"` (or absent)
44
+ * it stays `/mnt/libs/current`, because the baked resolvers of the old
45
+ * images name that path.
46
+ */
47
+ export function farmContainerPath(toolchainSource) {
48
+ return toolchainSource === "image" ? `${LIBS_CONTAINER_PATH}/farm` : `${LIBS_CONTAINER_PATH}/current`;
49
+ }
39
50
  /**
40
51
  * The step tail beneath an analysis's workspace root, in both container and
41
52
  * PVC-relative space. The harness owns this layout (workspace-layout spec).
@@ -94,14 +105,37 @@ export function buildSessionSubPaths(coords, workspaceSubPath) {
94
105
  };
95
106
  }
96
107
  /**
97
- * Lib-store package-discovery env. PYTHONPATH is intentionally omitted —
98
- * system Python resolves via a `.pth` file in the lib store.
108
+ * Lib-store package-discovery env, keyed on the declared toolchain.
109
+ * PYTHONPATH is intentionally omitted — system Python resolves through a
110
+ * `.pth` file in the lib store.
111
+ *
112
+ * With `"store"` (or absent) the env is byte-identical to the legacy one:
113
+ * the conda `bin` under `/mnt/libs/current`, thus an old embedder keeps its
114
+ * exact environment. With `"image"` the image owns the toolchain: `PATH`
115
+ * holds `/opt/conda/bin` before the farm `python/bin` at the end, thus a
116
+ * farm script never shadows an image tool. The cache env points into the
117
+ * read-write cache mount only when it is present — the entrypoint fallback
118
+ * covers the caches otherwise.
99
119
  */
100
- function libStoreEnv() {
120
+ function libStoreEnv(toolchainSource, cache) {
121
+ if (toolchainSource !== "image") {
122
+ return {
123
+ R_LIBS_SITE: "/mnt/libs/current/r/github:/mnt/libs/current/r/bioconductor:/mnt/libs/current/r/cran",
124
+ NODE_PATH: "/mnt/libs/current/node/node_modules",
125
+ PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/mnt/libs/current/conda/bin",
126
+ };
127
+ }
128
+ const farm = farmContainerPath(toolchainSource);
101
129
  return {
102
- R_LIBS_SITE: "/mnt/libs/current/r/github:/mnt/libs/current/r/bioconductor:/mnt/libs/current/r/cran",
103
- NODE_PATH: "/mnt/libs/current/node/node_modules",
104
- PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/mnt/libs/current/conda/bin",
130
+ R_LIBS_SITE: `${farm}/r/github:${farm}/r/bioconductor:${farm}/r/cran`,
131
+ NODE_PATH: "/opt/node/node_modules",
132
+ PATH: `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/conda/bin:${farm}/python/bin`,
133
+ ...(cache
134
+ ? {
135
+ NUMBA_CACHE_DIR: `${FARM_CACHE_CONTAINER_PATH}/numba-cache`,
136
+ MPLCONFIGDIR: `${FARM_CACHE_CONTAINER_PATH}/matplotlib_config`,
137
+ }
138
+ : {}),
105
139
  };
106
140
  }
107
141
  export function buildMountPlan(coords, stores) {
@@ -112,16 +146,19 @@ export function buildMountPlan(coords, stores) {
112
146
  const readonlyTreePath = `/${analysisId}`;
113
147
  const write = sandboxWriteTail(coords);
114
148
  const writableStepPath = write === undefined ? undefined : `${readonlyTreePath}/${write.tail}`;
149
+ const cache = stores.libs && stores.cache === true;
115
150
  return {
116
151
  readonlyTreePath,
117
152
  writableStepPath,
118
153
  workingDir: writableStepPath ?? readonlyTreePath,
119
154
  libsPath: stores.libs ? LIBS_CONTAINER_PATH : undefined,
155
+ farmPath: stores.libs ? farmContainerPath(stores.toolchainSource) : undefined,
156
+ cachePath: cache ? FARM_CACHE_CONTAINER_PATH : undefined,
120
157
  refsPath: stores.refs ? REFS_CONTAINER_PATH : undefined,
121
158
  stepSubdirs: write?.subdirs ?? [],
122
159
  env: {
123
160
  PROVENANCE_WATCH_DIRS: readonlyTreePath,
124
- ...(stores.libs ? libStoreEnv() : {}),
161
+ ...(stores.libs ? libStoreEnv(stores.toolchainSource, cache) : {}),
125
162
  },
126
163
  };
127
164
  }
@@ -79,6 +79,18 @@ export type SandboxError = {
79
79
  readonly sandboxId?: string;
80
80
  readonly status?: number;
81
81
  readonly cause: unknown;
82
+ } | {
83
+ /**
84
+ * The farm source refused the analysis: the resolver returned
85
+ * `unavailable`, or it threw. `reason` carries the text of the
86
+ * embedder verbatim, thus the surface can show why the farm is
87
+ * not usable yet.
88
+ */
89
+ readonly type: "farm_unavailable";
90
+ readonly op: string;
91
+ readonly analysisId: string;
92
+ readonly reason: string;
93
+ readonly cause?: unknown;
82
94
  };
83
95
  /**
84
96
  * The HTTP/driver status a backend SDK error carries. dockerode exposes it on
@@ -66,6 +66,8 @@ export function describeSandboxError(e) {
66
66
  return `sandbox teardown failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
67
67
  case "liveness_failed":
68
68
  return `sandbox liveness probe failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
69
+ case "farm_unavailable":
70
+ return `farm unavailable (${e.op}: ${e.analysisId}) — ${e.reason}`;
69
71
  }
70
72
  }
71
73
  /**
@@ -30,6 +30,124 @@ export declare const SandboxTransportSchema: z.ZodEnum<{
30
30
  callback: "callback";
31
31
  }>;
32
32
  export type SandboxTransport = z.infer<typeof SandboxTransportSchema>;
33
+ /**
34
+ * The declared owner of the sandbox toolchain. `"image"` states that the
35
+ * image owns the interpreters, conda, and Node. `"store"` states that the
36
+ * mounted store owns them. An absent config field means `"store"`, thus an
37
+ * old embedder keeps its environment and its prompt prefix. The harness keys
38
+ * the resolver env and the orient-core prompt text on this declared value
39
+ * only, and it never infers its host.
40
+ */
41
+ export type ToolchainSource = "image" | "store";
42
+ /**
43
+ * A resolved farm. `farmPath` is a host directory for the Docker backend,
44
+ * and a PVC-relative path for the K8s backend. `cachePath` is the optional
45
+ * per-analysis read-write cache. When it is present, the backend mounts it
46
+ * at `/mnt/libs/cache`.
47
+ */
48
+ export interface FarmLocation {
49
+ readonly farmPath: string;
50
+ readonly cachePath?: string;
51
+ }
52
+ /** The answer of a farm resolver: a usable farm, or a refusal that carries the reason of the embedder. */
53
+ export type FarmResolution = {
54
+ readonly kind: "available";
55
+ readonly location: FarmLocation;
56
+ } | {
57
+ readonly kind: "unavailable";
58
+ readonly reason: string;
59
+ };
60
+ /**
61
+ * Resolve the farm of one analysis. A backend calls this at each
62
+ * `createSandbox`, thus a new farm reaches the next sandbox with no restart.
63
+ *
64
+ * The resolver carries the mount gate when the backend cannot. On a
65
+ * volume-backed farm (K8s) the host cannot stat `farmPath`, thus the
66
+ * resolver MUST answer `unavailable` when the `inflexa.lock` of the farm
67
+ * does not parse. A backend with a host-readable path (Docker) proves the
68
+ * lock itself, after the resolution.
69
+ */
70
+ export type ResolveAnalysisFarm = (analysisId: string) => Promise<FarmResolution>;
71
+ /**
72
+ * Where the farm of an analysis comes from. A required field of each sandbox
73
+ * backend config — the harness never invents a farm location, and it reads
74
+ * no store-root `current` pointer. `fixed` names one farm for every
75
+ * analysis. `per-analysis` supplies a resolver.
76
+ */
77
+ export type FarmSource = {
78
+ readonly kind: "fixed";
79
+ readonly location: FarmLocation;
80
+ } | {
81
+ readonly kind: "per-analysis";
82
+ readonly resolve: ResolveAnalysisFarm;
83
+ };
84
+ /**
85
+ * One package ask of the farm-extension seam. `version` pins one exact
86
+ * version. `ecosystem` qualifies a name that both tracks can hold. Without
87
+ * it, a name that both tracks hold comes back as a `collision` outcome.
88
+ */
89
+ export interface PackageRequest {
90
+ readonly name: string;
91
+ readonly version?: string;
92
+ readonly ecosystem?: "python" | "r";
93
+ }
94
+ /**
95
+ * One outcome per request, index-aligned with the request array.
96
+ *
97
+ * `name` echoes the REQUESTED spelling verbatim, never a normalized form. A
98
+ * caller quotes it into a remedy (`inflexa store add <name>`), and an R name
99
+ * is case-sensitive with dots, thus a normalized echo would name a package
100
+ * that no repository holds.
101
+ *
102
+ * - `linked` — the pool held the package, and this call linked it.
103
+ * - `present` — the farm linked it already.
104
+ * - `absent` — the pool does not hold it. `acquisitionPossible` states that
105
+ * the host can acquire that ecosystem, or that it cannot. `detail`, when
106
+ * the realization gives one, classifies the miss in host terms — in
107
+ * flight, failed with a recorded reason, or never requested — and the
108
+ * launch refusal renders it beside the name.
109
+ * - `collision` — the request resolves to two store directories: two
110
+ * versions of one distribution, or one name that both tracks hold. The
111
+ * outcome is terminal for the request. `detail`, when the realization
112
+ * gives one, names the two pins and the packages that need each side —
113
+ * without it, a caller must guess which package pulls each pin, and a
114
+ * wrong guess sends it into store surgery.
115
+ * - `unavailable` — the link pass itself could not answer: an unreadable
116
+ * dependency graph, a locked farm. The reason says why. It says NOTHING
117
+ * about the presence of the package, and it must never render as an
118
+ * absence — a false absence sends a caller chasing packages the pool
119
+ * holds.
120
+ */
121
+ export type PackageRequestOutcome = {
122
+ readonly kind: "linked";
123
+ readonly name: string;
124
+ readonly version: string;
125
+ } | {
126
+ readonly kind: "present";
127
+ readonly name: string;
128
+ readonly version: string;
129
+ } | {
130
+ readonly kind: "absent";
131
+ readonly name: string;
132
+ readonly acquisitionPossible: boolean;
133
+ readonly detail?: string;
134
+ } | {
135
+ readonly kind: "collision";
136
+ readonly name: string;
137
+ readonly storeDirs: readonly [string, string];
138
+ readonly detail?: string;
139
+ } | {
140
+ readonly kind: "unavailable";
141
+ readonly name: string;
142
+ readonly reason: string;
143
+ };
144
+ /**
145
+ * The farm-extension seam. The realization of the embedder links host-staged
146
+ * packages into the farm of the analysis. It never installs, downloads, or
147
+ * acquires. A link is live in a sandbox that already runs, because the farm
148
+ * rides a bind mount.
149
+ */
150
+ export type ExtendAnalysisFarm = (analysisId: string, requests: readonly PackageRequest[]) => Promise<readonly PackageRequestOutcome[]>;
33
151
  /**
34
152
  * Per-sandbox-machine liveness verdict. `oomKilled` is meaningful only when
35
153
  * `alive` is false: true when the backend reports the machine was killed for
@@ -17,6 +17,7 @@ export declare const PlanStepSchema: z.ZodObject<{
17
17
  constraints: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
18
  acceptance_criteria: z.ZodArray<z.ZodString>;
19
19
  caveats: z.ZodOptional<z.ZodArray<z.ZodString>>;
20
+ packages: z.ZodNonOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
20
21
  depends_on: z.ZodArray<z.ZodString>;
21
22
  resources: z.ZodNonOptional<z.ZodOptional<z.ZodObject<{
22
23
  cpu: z.ZodNumber;
@@ -49,6 +50,7 @@ export declare const PlannerPlanSchema: z.ZodObject<{
49
50
  constraints: z.ZodOptional<z.ZodArray<z.ZodString>>;
50
51
  acceptance_criteria: z.ZodArray<z.ZodString>;
51
52
  caveats: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
+ packages: z.ZodNonOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
52
54
  depends_on: z.ZodArray<z.ZodString>;
53
55
  resources: z.ZodNonOptional<z.ZodOptional<z.ZodObject<{
54
56
  cpu: z.ZodNumber;
@@ -90,6 +92,7 @@ export declare const PlanningAgentOutputSchema: z.ZodObject<{
90
92
  constraints: z.ZodOptional<z.ZodArray<z.ZodString>>;
91
93
  acceptance_criteria: z.ZodArray<z.ZodString>;
92
94
  caveats: z.ZodOptional<z.ZodArray<z.ZodString>>;
95
+ packages: z.ZodNonOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
93
96
  depends_on: z.ZodArray<z.ZodString>;
94
97
  resources: z.ZodNonOptional<z.ZodOptional<z.ZodObject<{
95
98
  cpu: z.ZodNumber;
@@ -17,7 +17,7 @@ export const PlanStepSchema = AnalysisStepSchema.omit({
17
17
  error: true,
18
18
  maxSteps: true,
19
19
  })
20
- .required({ agent: true, resources: true })
20
+ .required({ agent: true, resources: true, packages: true })
21
21
  .extend({
22
22
  agent: z.enum(plannableAgentIds).describe("Sandbox agent ID, routed by the step's primary data object. Must be one of the available agents."),
23
23
  });
@@ -109,7 +109,26 @@ export function validatePlan(plan, options) {
109
109
  `step-directory convention; "${SYNTHESIS_STEP_ID}" is the run-level synthesis phase)`);
110
110
  }
111
111
  }
112
- // 6. Step-id path safety. The id becomes the `runs/{runId}/{stepId}` directory
112
+ // 6. Package entries are requirements, never locations. A path, a URL, or
113
+ // a store directory in a plan would publish an installer detail as an
114
+ // interface — the link pass resolves names against the pool. An absent
115
+ // array passes, because stored plans from before the field carry none.
116
+ // The specifier check exists because the link pass splits on `==` only:
117
+ // an unrefused `numpy>=1.26` becomes a package NAME, and the pool then
118
+ // refuses a package it holds.
119
+ for (const step of plan.steps) {
120
+ for (const entry of step.packages ?? []) {
121
+ if (/^[.~]|[/\\]|:\/\//.test(entry)) {
122
+ errors.push(`Step "${step.id}" names a package location "${entry}" — name each package as a requirement ` +
123
+ `(a bare name, or name==version), never a path, a URL, or a store directory`);
124
+ }
125
+ else if (/[<>!~]/.test(entry) || (entry.includes("=") && !entry.includes("=="))) {
126
+ errors.push(`Step "${step.id}" pins "${entry}" with a specifier the link pass cannot honor — ` +
127
+ `use a bare name, or name==version with one exact version`);
128
+ }
129
+ }
130
+ }
131
+ // 7. Step-id path safety. The id becomes the `runs/{runId}/{stepId}` directory
113
132
  // segment and a `/{analysisId}/…` container mount path; an unsafe segment
114
133
  // (a slash, or `.`/`..`) could traverse or widen the mount. The sandbox
115
134
  // mount boundary also rejects these (assertSafeId), but catching it here
@@ -21,6 +21,7 @@ export declare const AnalysisStepSchema: z.ZodObject<{
21
21
  constraints: z.ZodOptional<z.ZodArray<z.ZodString>>;
22
22
  acceptance_criteria: z.ZodArray<z.ZodString>;
23
23
  caveats: z.ZodOptional<z.ZodArray<z.ZodString>>;
24
+ packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
24
25
  depends_on: z.ZodArray<z.ZodString>;
25
26
  status: z.ZodDefault<z.ZodEnum<{
26
27
  pending: "pending";
@@ -57,6 +58,7 @@ export declare const AnalysisPlanSchema: z.ZodObject<{
57
58
  constraints: z.ZodOptional<z.ZodArray<z.ZodString>>;
58
59
  acceptance_criteria: z.ZodArray<z.ZodString>;
59
60
  caveats: z.ZodOptional<z.ZodArray<z.ZodString>>;
61
+ packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
60
62
  depends_on: z.ZodArray<z.ZodString>;
61
63
  status: z.ZodDefault<z.ZodEnum<{
62
64
  pending: "pending";
@@ -128,6 +130,7 @@ export declare const ContentBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
128
130
  constraints: z.ZodOptional<z.ZodArray<z.ZodString>>;
129
131
  acceptance_criteria: z.ZodArray<z.ZodString>;
130
132
  caveats: z.ZodOptional<z.ZodArray<z.ZodString>>;
133
+ packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
131
134
  depends_on: z.ZodArray<z.ZodString>;
132
135
  status: z.ZodDefault<z.ZodEnum<{
133
136
  pending: "pending";
@@ -247,6 +250,7 @@ export declare const SuspensionPayloadSchema: z.ZodObject<{
247
250
  constraints: z.ZodOptional<z.ZodArray<z.ZodString>>;
248
251
  acceptance_criteria: z.ZodArray<z.ZodString>;
249
252
  caveats: z.ZodOptional<z.ZodArray<z.ZodString>>;
253
+ packages: z.ZodOptional<z.ZodArray<z.ZodString>>;
250
254
  depends_on: z.ZodArray<z.ZodString>;
251
255
  status: z.ZodDefault<z.ZodEnum<{
252
256
  pending: "pending";
@@ -24,6 +24,16 @@ export const AnalysisStepSchema = z.object({
24
24
  constraints: z.array(z.string()).optional().describe("Requirements the sandbox agent must follow exactly — not suggestions."),
25
25
  acceptance_criteria: z.array(z.string()).describe("What the result must satisfy for the step to count as successful."),
26
26
  caveats: z.array(z.string()).optional().describe("Pitfalls the sandbox agent should watch for."),
27
+ // Optional on the persistence schema so historical plans (pre-packages)
28
+ // still parse on the read side. `PlanStepSchema` re-requires it for
29
+ // planner output. The briefing withholds it from the rendered task — the
30
+ // pre-launch link pass consumes it, and a step agent must not re-litigate it.
31
+ packages: z
32
+ .array(z.string())
33
+ .optional()
34
+ .describe('The packages this step imports, each as a requirement: a bare name ("scanpy") or a name with one exact version ("numpy==1.26.4"). ' +
35
+ "Never a path, a URL, or a store directory. An empty array means the step needs nothing beyond the baked toolchain. " +
36
+ "The set is not a promise of completeness — the execution agent can still link a missing package."),
27
37
  depends_on: z
28
38
  .array(z.string())
29
39
  .describe("IDs of steps in this plan that must complete before this one runs. Empty when the step has no prerequisites. The graph must be acyclic."),
@@ -20,8 +20,10 @@
20
20
  import type { Pool } from "pg";
21
21
  import { type AuthContext, type RunSession } from "../auth/types.js";
22
22
  import type { EnvironmentStorePaths } from "../config/environment-stores.js";
23
+ import type { ExtendAnalysisFarm } from "../sandbox/types.js";
23
24
  import type { RunAuthorization, RunAuthorizer } from "../execution/run-authorizer.js";
24
25
  import type { StagedInput } from "../execution/staged-input.js";
26
+ import type { SandboxAgentDeps } from "../agents/sandbox/shared.js";
25
27
  import type { BioToolKeys } from "../tools/bio/keys.js";
26
28
  import type { Logger } from "../lib/logger.js";
27
29
  import type { UsageRecorder } from "../billing/usage-recorder.js";
@@ -65,6 +67,13 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
65
67
  * the pod then carries the harness's own labels only.
66
68
  */
67
69
  readonly resolvePodLabels?: (session: RunSession) => Promise<Record<string, string>>;
70
+ /**
71
+ * The farm-extension seam of the embedder. Bound, it rides into the
72
+ * profiler agent deps, and the substrate attaches `link_packages`. Thus
73
+ * the profiler links a reader by need before the first plan exists.
74
+ * Unbound keeps the current shape: no link tool, and no error.
75
+ */
76
+ readonly extendAnalysisFarm?: ExtendAnalysisFarm;
68
77
  }
69
78
  /**
70
79
  * Workflow input. JSON-serialisable — DBOS persists it as the workflow's
@@ -125,6 +134,12 @@ export declare function buildAbsorbedResult(prior: DataProfileResult, resolution
125
134
  * Register the data-profile workflow with DBOS. Returns the registered
126
135
  * callable so `triggerDataProfile` can dispatch via `DBOS.startWorkflow`.
127
136
  */
137
+ /**
138
+ * Project the workflow deps onto the profiler agent bag. The farm-extension
139
+ * seam rides only when the embedder bound one, and the substrate then gates
140
+ * `link_packages` on its presence (the data-profile-init spec).
141
+ */
142
+ export declare function profilerSandboxAgentDeps(deps: DataProfileDeps, step: SandboxAgentDeps["step"]): SandboxAgentDeps;
128
143
  export declare function registerDataProfileWorkflow(deps: DataProfileDeps): (input: DataProfileWorkflowInput) => Promise<void>;
129
144
  /**
130
145
  * Body extracted so tests can drive it without registering a workflow.
@@ -247,6 +247,28 @@ function logProfileMonitoring(logger, args) {
247
247
  * Register the data-profile workflow with DBOS. Returns the registered
248
248
  * callable so `triggerDataProfile` can dispatch via `DBOS.startWorkflow`.
249
249
  */
250
+ /**
251
+ * Project the workflow deps onto the profiler agent bag. The farm-extension
252
+ * seam rides only when the embedder bound one, and the substrate then gates
253
+ * `link_packages` on its presence (the data-profile-init spec).
254
+ */
255
+ export function profilerSandboxAgentDeps(deps, step) {
256
+ return {
257
+ provider: deps.provider,
258
+ pool: deps.pool,
259
+ sandboxClient: deps.sandboxClient,
260
+ workspaceFs: deps.workspaceFs,
261
+ embedding: deps.embedding,
262
+ model: deps.model,
263
+ skillsDir: deps.skillsDir,
264
+ ...(deps.farmLockFile ? { farmLockFile: deps.farmLockFile } : {}),
265
+ ...(deps.imagePackagesFile ? { imagePackagesFile: deps.imagePackagesFile } : {}),
266
+ ...(deps.refStorePath ? { refStorePath: deps.refStorePath } : {}),
267
+ ...(deps.extendAnalysisFarm ? { extendAnalysisFarm: deps.extendAnalysisFarm } : {}),
268
+ bioKeys: deps.bioKeys,
269
+ step,
270
+ };
271
+ }
250
272
  export function registerDataProfileWorkflow(deps) {
251
273
  return DBOS.registerWorkflow((input) => runDataProfileBody(input, deps), { name: "data-profile" });
252
274
  }
@@ -454,32 +476,20 @@ export async function runDataProfileBody(input, deps) {
454
476
  `description needs content the scan did not capture, then call \`submit_profile\` once.`,
455
477
  ]),
456
478
  ].join("\n");
457
- const sandboxAgentDeps = {
458
- provider: deps.provider,
459
- pool: deps.pool,
460
- sandboxClient: deps.sandboxClient,
461
- workspaceFs: deps.workspaceFs,
462
- embedding: deps.embedding,
463
- model: deps.model,
464
- skillsDir: deps.skillsDir,
465
- ...(deps.packagesFile ? { packagesFile: deps.packagesFile } : {}),
466
- ...(deps.refStorePath ? { refStorePath: deps.refStorePath } : {}),
467
- bioKeys: deps.bioKeys,
468
- step: {
469
- sandbox,
470
- workspaceRoot,
471
- analysisId,
472
- runId: DATA_PROFILE_RUN_LITERAL,
473
- stepId: DATA_PROFILE_STEP_LITERAL,
474
- workflowId,
475
- // The profiler writes Python scripts and intermediate artifacts under
476
- // the synthetic step path; the post-agent `rm -rf runs/data-profile/`
477
- // cleanup wipes them.
478
- allowedWritePrefix: profileWritePrefix,
479
- nextFunctionId,
480
- deadlineMs: () => deadlineAbs,
481
- },
482
- };
479
+ const sandboxAgentDeps = profilerSandboxAgentDeps(deps, {
480
+ sandbox,
481
+ workspaceRoot,
482
+ analysisId,
483
+ runId: DATA_PROFILE_RUN_LITERAL,
484
+ stepId: DATA_PROFILE_STEP_LITERAL,
485
+ workflowId,
486
+ // The profiler writes Python scripts and intermediate artifacts under
487
+ // the synthetic step path; the post-agent `rm -rf runs/data-profile/`
488
+ // cleanup wipes them.
489
+ allowedWritePrefix: profileWritePrefix,
490
+ nextFunctionId,
491
+ deadlineMs: () => deadlineAbs,
492
+ });
483
493
  let accepted = null;
484
494
  let rounds = 0;
485
495
  const submitProfileTool = defineTool({