@inflexa-ai/harness 0.34.0 → 0.35.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/sandbox/shared.d.ts +1 -7
- package/dist/agents/sandbox/shared.js +6 -3
- package/dist/config/environment-stores.d.ts +9 -6
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/prompts/sandbox-standards.d.ts +7 -7
- package/dist/prompts/sandbox-standards.js +6 -6
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +12 -0
- package/dist/sandbox/create-sandbox.js +45 -8
- package/dist/sandbox/docker-client.d.ts +11 -0
- package/dist/sandbox/docker-client.js +19 -5
- package/dist/sandbox/farm.d.ts +3 -1
- package/dist/sandbox/farm.js +3 -1
- package/dist/sandbox/image-packages.d.ts +107 -0
- package/dist/sandbox/image-packages.js +107 -0
- package/dist/sandbox/k8s-client.d.ts +11 -0
- package/dist/sandbox/k8s-client.js +20 -4
- package/dist/sandbox/sandbox-error.d.ts +40 -5
- package/dist/sandbox/sandbox-error.js +79 -5
- package/dist/tools/sandbox/list-available-packages.d.ts +18 -16
- package/dist/tools/sandbox/list-available-packages.js +90 -52
- package/package.json +1 -1
|
@@ -20,7 +20,7 @@ import type { Pool } from "pg";
|
|
|
20
20
|
import type { AgentDefinition } from "../../loop/types.js";
|
|
21
21
|
import type { ChatProvider, EmbeddingProvider } from "../../providers/types.js";
|
|
22
22
|
import type { SandboxClient } from "../../sandbox/client.js";
|
|
23
|
-
import type { ExtendAnalysisFarm, SandboxRef
|
|
23
|
+
import type { ExtendAnalysisFarm, SandboxRef } from "../../sandbox/types.js";
|
|
24
24
|
import type { WorkspaceFilesystem } from "../../workspace/filesystem.js";
|
|
25
25
|
import type { ProvenanceCollector as LineageCollector } from "../../provenance/collector.js";
|
|
26
26
|
import { type BioToolKeys } from "../../tools/bio/keys.js";
|
|
@@ -91,12 +91,6 @@ export interface SandboxAgentDeps extends EnvironmentStorePaths {
|
|
|
91
91
|
* and the package-link prompt layer appends. Without it, neither exists.
|
|
92
92
|
*/
|
|
93
93
|
readonly extendAnalysisFarm?: ExtendAnalysisFarm;
|
|
94
|
-
/**
|
|
95
|
-
* The declared toolchain owner. Keys the orient-core environment text.
|
|
96
|
-
* Absent keeps the legacy text, thus an old embedder keeps its cached
|
|
97
|
-
* prompt prefix.
|
|
98
|
-
*/
|
|
99
|
-
readonly toolchainSource?: ToolchainSource;
|
|
100
94
|
}
|
|
101
95
|
/** Per-agent override for the prompt composition and tool surface. */
|
|
102
96
|
export interface SandboxAgentPromptOptions {
|
|
@@ -183,10 +183,13 @@ function buildWorkspaceTools(deps, readOnly) {
|
|
|
183
183
|
export function createSandboxAgent(deps, meta, body, opts = {}) {
|
|
184
184
|
const appendStandards = opts.appendAnalysisStepStandards ?? true;
|
|
185
185
|
// The layer order is fixed by the harness-sandbox-agents spec: the agent
|
|
186
|
-
// body, the orient core keyed on the
|
|
187
|
-
// layer only when the seam is bound, then the step standards.
|
|
186
|
+
// body, the orient core keyed on the toolchain of the sandbox client, the
|
|
187
|
+
// package-link layer only when the seam is bound, then the step standards.
|
|
188
|
+
// The client is the one declaration: the environment it provisions and the
|
|
189
|
+
// text that describes it come from the same value, thus a bag cannot
|
|
190
|
+
// describe a toolchain the sandbox does not have.
|
|
188
191
|
const sandboxLayer = [
|
|
189
|
-
sandboxOrientCorePromptFor(deps.toolchainSource),
|
|
192
|
+
sandboxOrientCorePromptFor(deps.sandboxClient.toolchainSource),
|
|
190
193
|
...(deps.extendAnalysisFarm ? [sandboxPackageLinkPrompt] : []),
|
|
191
194
|
...(appendStandards ? [sandboxAnalysisStepStandardsPrompt] : []),
|
|
192
195
|
];
|
|
@@ -45,11 +45,14 @@ export interface EnvironmentStorePaths {
|
|
|
45
45
|
*/
|
|
46
46
|
readonly farmLockFile?: string;
|
|
47
47
|
/**
|
|
48
|
-
* Host path of the
|
|
49
|
-
*
|
|
50
|
-
* `list_available_packages` merges into its report. Omit when the
|
|
51
|
-
*
|
|
52
|
-
*
|
|
48
|
+
* Host path of the image inventory record, `image-packages.json` at the
|
|
49
|
+
* root of the package store — the image-owned tools and Node packages
|
|
50
|
+
* that `list_available_packages` merges into its report. Omit when the
|
|
51
|
+
* host's own process sees the store at the container mountpoint, which
|
|
52
|
+
* makes `/mnt/libs/image-packages.json` correct as-is; set it when the
|
|
53
|
+
* host reads the store somewhere else. An absent or invalid record
|
|
54
|
+
* merges nothing, and that is a normal state: a store packed before the
|
|
55
|
+
* record existed carries none, and the rest of the report stays whole.
|
|
53
56
|
*/
|
|
54
57
|
readonly imagePackagesFile?: string;
|
|
55
58
|
/**
|
|
@@ -81,7 +84,7 @@ export type PoolInventoryRead = {
|
|
|
81
84
|
/** One package entry of an inventory section, with the store identity where the source records it. */
|
|
82
85
|
export interface PoolInventoryPackage {
|
|
83
86
|
readonly name: string;
|
|
84
|
-
/** The pinned version, rendered as `name==version`. Absent
|
|
87
|
+
/** The pinned version, rendered as `name==version`. Absent where the source pins none. */
|
|
85
88
|
readonly version?: string;
|
|
86
89
|
/** The content-addressed store directory of the package. */
|
|
87
90
|
readonly storeDir?: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -134,9 +134,12 @@ export type { ResolveWorkspaceRoot } from "./workspace/paths.js";
|
|
|
134
134
|
export { createSandboxClient } from "./sandbox/create-sandbox.js";
|
|
135
135
|
export type { CreateSandboxClientConfig, SandboxBackendConfig } from "./sandbox/create-sandbox.js";
|
|
136
136
|
export type { SandboxClient } from "./sandbox/client.js";
|
|
137
|
+
export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
|
|
138
|
+
export type { SandboxError } from "./sandbox/sandbox-error.js";
|
|
137
139
|
export type { ExtendAnalysisFarm, FarmLocation, FarmResolution, FarmSource, PackageRequest, PackageRequestOutcome, ResolveAnalysisFarm, ToolchainSource, } from "./sandbox/types.js";
|
|
138
140
|
export { FARM_LOCK_FILE, FarmLockSchema, readFarmLock, readFarmLockFile } from "./sandbox/farm.js";
|
|
139
141
|
export type { FarmLock, FarmLockError } from "./sandbox/farm.js";
|
|
142
|
+
export { IMAGE_PACKAGES_FILE } from "./sandbox/image-packages.js";
|
|
140
143
|
export type { EnvironmentStorePaths, PoolInventoryPackage, PoolInventoryRead, PoolInventorySection } from "./config/environment-stores.js";
|
|
141
144
|
export { MachineBudgetSchema, ResourceLimitsSchema, ResourcePolicySchema, ResourceSpecSchema, parseResourcePolicy } from "./config/resource-limits.js";
|
|
142
145
|
export type { MachineBudget, ResourceLimits, ResourcePolicy, ResourceSpec } from "./config/resource-limits.js";
|
package/dist/index.js
CHANGED
|
@@ -237,9 +237,15 @@ export { createAnalysisPurge } from "./state/purge-analysis.js";
|
|
|
237
237
|
// the failure a fixed-width comparand exists to prevent.
|
|
238
238
|
export { computeInputSignature } from "./execution/input-signature.js";
|
|
239
239
|
export { createSandboxClient } from "./sandbox/create-sandbox.js";
|
|
240
|
+
// The throw of the client seam. An embedder matches the class and reads the
|
|
241
|
+
// typed variant on `error`, instead of a string match on the message.
|
|
242
|
+
export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
|
|
240
243
|
// The farm contract: one `inflexa.lock` per farm. An embedder that composes
|
|
241
244
|
// farms writes this shape, and the mount gate reads it — one schema, no copy.
|
|
242
245
|
export { FARM_LOCK_FILE, FarmLockSchema, readFarmLock, readFarmLockFile } from "./sandbox/farm.js";
|
|
246
|
+
// The image record: one file name at the root of the store. An embedder joins
|
|
247
|
+
// it onto its own store root for `imagePackagesFile`.
|
|
248
|
+
export { IMAGE_PACKAGES_FILE } from "./sandbox/image-packages.js";
|
|
243
249
|
export { MachineBudgetSchema, ResourceLimitsSchema, ResourcePolicySchema, ResourceSpecSchema, parseResourcePolicy } from "./config/resource-limits.js";
|
|
244
250
|
export { createWorkspaceFilesystem } from "./workspace/filesystem.js";
|
|
245
251
|
// Exec-callback ingress building blocks. Delivery goes through
|
|
@@ -22,20 +22,20 @@
|
|
|
22
22
|
* `prompts/briefing.ts`), which is composed per dispatch and where per-step
|
|
23
23
|
* content belongs.
|
|
24
24
|
*
|
|
25
|
-
* The orient-core text keys on the
|
|
26
|
-
* (`sandboxOrientCorePromptFor`).
|
|
27
|
-
* untouched, thus an
|
|
28
|
-
* Both variants are composition-time constants, thus
|
|
29
|
-
* byte-stable across its own steps.
|
|
25
|
+
* The orient-core text keys on the `toolchainSource` of the sandbox client
|
|
26
|
+
* (`sandboxOrientCorePromptFor`). `"store"` gives the legacy constant
|
|
27
|
+
* untouched, thus an embedder that declares no toolchain keeps its cached
|
|
28
|
+
* prefix byte-identical. Both variants are composition-time constants, thus
|
|
29
|
+
* each composition stays byte-stable across its own steps.
|
|
30
30
|
*/
|
|
31
31
|
import type { ToolchainSource } from "../sandbox/types.js";
|
|
32
32
|
export declare const sandboxOrientCorePrompt = "# Sandbox Orient-Core\n\nThese apply to every agent that talks to a sandbox.\n\n## Your Briefing Is Authoritative\n\nYour first message is your briefing, composed for this step at the instant it was\ndispatched \u2014 after every step it depends on had finished. It names your task, your\nworking directory and the read-only analysis root, what the input dataset is\n(domain, organism, design, quality concerns, files), and what each upstream step\nproduced and where it put it. Work from it.\n\nDo NOT re-derive what it already gives you: no filesystem hunt for your inputs, no\nre-reading an upstream step whose summary you were handed, no re-deriving the\norganism or the dimensions from raw bytes. Reach further only where the briefing is\ngenuinely thin for what you must do:\n\n- `inspect_data_profile` \u2014 the full dataset profile: `scope:'overview'` for the\n identity and the file census, `scope:'groups'` for the groups and the dimensions\n that vary across them, `scope:'files'` for the members the profiler annotated\n individually. It is the ONLY record of what the input data IS; no file on disk\n carries it. Use it when the briefing's orientation is not enough, or when it\n carried none.\n- `read_file` on an upstream step's summary \u2014 its path is in your briefing \u2014 when\n the excerpt you were handed is not enough to build on.\n- `workspace_search({ query })` \u2014 natural-language search for a file the briefing\n does not name. Optional `type` filter (`input-group` | `input-dimension` |\n `input` | `output` | `summary` | `synthesis`) and `limit` (1-50, default 8).\n Returns paths, descriptions, and metadata \u2014 not file contents. Most input files\n carry no entry of their own; reach them through their group and `list_files`.\n\n## Your Workspace\n\nYour **working directory** \u2014 writable, and your cwd \u2014 and the read-only\n**analysis root** are named in your briefing. Use those paths exactly; do not\ninvent or guess one.\n\n- **Relative paths resolve against your working directory** \u2014 in `read_file`,\n `list_files`, `file_stat`, `grep`, `write_file`, `edit_file`,\n `execute_command`, and any script you run. `output/de.csv` means\n `<working directory>/output/de.csv` everywhere \u2014 a file you write at a\n relative path is read back at the same relative path.\n- **You may write only inside your working directory.** A write outside it\n comes back as an `out_of_prefix` result (no I/O performed); fix the path.\n- The rest of the analysis is mounted **read-only** at the analysis root. Reach\n input data (under `data/inputs/`) and other steps' outputs (under `runs/`)\n with an **absolute** path beneath it.\n- **Absolute analysis-root paths are canonical** \u2014 use one whenever you\n reference a file you didn't create in this step.\n\n`write_file` creates parent directories as needed \u2014 no separate mkdir.\n\n## Scope \u2014 Analysis Starts at Processed Data\n\nThis environment does **tertiary analysis**: turning processed data into\nbiological findings. Read processing \u2014 alignment, quantification, variant\ncalling, peak calling \u2014 belongs upstream, wherever the instrument output was\nhandled. No tool for it is installed, and none can be.\n\nYour inputs are count and expression matrices, called variants, peak sets,\nsignal tracks, and the metadata describing them. When a request arrives as raw\nreads or unprocessed instrument output, name the processed form you need and\nstop. Do not improvise a pipeline, do not substitute a Python reimplementation\nof an aligner or peak caller, and do not treat a missing upstream tool as an\nobstacle to route around \u2014 it is the boundary of the product, not a gap.\n\nData that has already been processed can still be **read**: `samtools`,\n`bcftools`, `tabix`, `bedtools` and `vcftools` are installed so you can\ninspect, subset, and convert existing BAM/CRAM, VCF and BED. Reading evidence at\nsites you already have is in scope; producing the calls is not.\n\n## Environment \u2014 No Network, No Installs\n\n**There is no egress from this sandbox.** Outbound connections are blocked at the\nfirewall, not merely discouraged: every download, API call, package install, and\n`ExperimentHub`/`AnnotationHub`/`models.download_*` style fetch fails outright.\nThere is no proxy, no mirror, and no retry that succeeds. Packages and reference\ndata were staged onto read-only mounts by the host **before** this container\nstarted, and that is the whole of what exists. Work that genuinely requires the\ninternet \u2014 fetching a dataset, querying an external API, literature lookup \u2014 is\ndone **outside** the sandbox through the host's tools, not from your scripts.\n\nSo: check what you need when you need it \u2014 a targeted lookup, never a catalog dump\nup front.\n\n- **Packages** \u2014 before importing a package you are not certain is present, look it\n up with `list_available_packages`, narrowed to the packages you actually intend\n to import. Importing one that isn't installed fails the script; the lookup costs\n one call.\n- **Reference data** \u2014 `list_available_refs` resolves reference data into a real\n path against the store mounted read-only at `/mnt/refs`. **Search by what the\n data IS, not by where you expect it** (\"TF-target regulons\", \"hallmark gene\n sets\", \"mouse\"): the store's directory layout is an installer detail, it differs\n between hosts, and no path you have seen before is guaranteed to exist here.\n Catalogued entries come back with the organism, the format, and the file's\n internal shape, so read the entry and use the reader and species it names rather\n than assuming \u2014 formats vary per dataset, and there is no guarantee any given\n collection ships in the format your tool prefers.\n The store is OPTIONAL and may be absent or hold none of what you want \u2014 a normal\n state, not an error. Never assume a reference path exists and never hardcode one:\n if a reference you need is not in the inventory, say so plainly and proceed with\n what you do have (or state what must be provisioned) rather than inventing a path\n or substituting something else unannounced. Pass reference paths EXPLICITLY to the\n library \u2014 nothing in the image points a library at the store, so a library that\n resolves data by name from an env var (CellTypist reads `$CELLTYPIST_FOLDER`)\n will not find it unless you export that variable yourself, in the same command,\n using a path the inventory actually returned.\n- Do NOT call a library's built-in data fetcher \u2014 `dc.op.collectri()`,\n `dc.op.progeny()`, `dc.op.msigdb()` and every other `dc.op.*()`, CellTypist's\n `models.download_models()`, gseapy's Enrichr library names, `ExperimentHub`/\n `AnnotationHub` \u2014 they all reach for the network and will fail. Load from an\n inventory path instead. Do NOT download data or install packages at runtime.\n\n## CPU Budget and Parallelism\n\nYour briefing names this step's CPU quota and memory limit. The container's\ncore-count calls (`parallel::detectCores()`, `os.cpu_count()`) report the\nquota, so worker counts sized from them are safe.\n\nBLAS/OpenMP thread pools default to **1 thread** (`OMP_NUM_THREADS`,\n`OPENBLAS_NUM_THREADS`, `MKL_NUM_THREADS`, \u2026). A forked worker inherits its\nparent's pool size, so this default is what keeps `mclapply` /\n`multiprocessing` from running workers \u00D7 threads on a quota sized for one of\nthem. The rule: **workers \u00D7 threads-per-worker must not exceed the quota.** A\nmix is fine while the product stays inside it.\n\n- **Fork/worker parallelism** (`mclapply`, `BiocParallel`, `joblib`,\n `multiprocessing`): size the workers from the reported core count and leave\n the thread defaults alone \u2014 there is nothing to raise. `joblib` also\n divides its own thread budget across its workers; do not manage it.\n- **Thread parallelism** (one process: xgboost, WGCNA, data.table, polars, a\n single large BLAS fit): raise the thread variables for that command through\n `execute_command`'s `env`, e.g.\n `env: { \"OMP_NUM_THREADS\": \"4\", \"OPENBLAS_NUM_THREADS\": \"4\" }` on a 4-CPU\n quota.\n\nExceeding the quota does not kill the step \u2014 the cgroup throttles it \u2014 but an\noversubscribed step runs slower and can starve the exec server that runs your\ncommands.\n\n## Output Contract \u2014 Persisted Files Are the Deliverable\n\nThis is a hard requirement, not a convention. **Your deliverable is persisted\nfiles**, nothing else:\n\n- the **script** you wrote, in `scripts/`,\n- the **data it computed** (derived from the input data), in `output/`,\n- any **figures**, in `figures/`.\n\nConclusions are drawn from those computed output files \u2014 never narrated from\n`execute_command` stdout. stdout/stderr are ephemeral; they are gone the\nmoment the command returns. **A step that ends without persisted\nscripts + outputs has produced nothing**, even if its transcript reads as if\nwork was done. In particular: running the analysis as an inline `python -c` /\n`Rscript -e` one-liner and reporting the numbers it printed produces nothing \u2014\nwrite the script to `scripts/` and persist what it computes to `output/`.\n\nA genuine verdict / QC / decision step that has no tabular result may instead\nwrite a short Markdown memo to `output/` (e.g. `output/qc-verdict.md`) stating\nthe finding and the evidence it rests on \u2014 a real persisted artifact. Do not\nfabricate a CSV just to have one.\n\nIf you genuinely cannot fulfill the step \u2014 required input data is missing, a\ntool you need is unavailable, or the environment is broken \u2014 call\n`report_blocker({ reason })` with a clear, specific reason. Do NOT improvise an\ninline result, fabricate outputs, or end on a prose narrative pretending the\nwork was done. `report_blocker` is the honest exit; an empty step that claims\nsuccess is not.\n\n## Skills \u2014 Method Selection and API Details\n\nSkills hold the full decision trees, API references, contrast syntax, worked\nexamples, and domain anti-patterns that do not fit in prompts. Use them\nactively \u2014 they are your authoritative source on methods.\n\n- `skill_search(query)` \u2014 keyword search across the skills available to you.\n Start here when picking a method or verifying an API detail. Examples:\n `skill_search(\"PyDESeq2 contrast syntax\")`,\n `skill_search(\"Leiden clustering resolution\")`.\n- `skill_read(skill, path)` \u2014 read a file from one of your skills, e.g.\n `skill_read(\"bulk-transcriptomics\", \"SKILL.md\")` or\n `skill_read(\"bulk-transcriptomics\", \"references/pydeseq2-api.md\")`.\n\nThe skills you have access to are listed in your agent instructions.\n\n## Context7 \u2014 Documentation Lookup\n\nLook up current documentation via context7 before writing non-trivial code:\n1. `resolve_library_id` with the package name to get the library ID.\n2. `query_docs` with the library ID and the specific function or method.\n\nCritical for rapidly-evolving packages (scvi-tools, spatialdata, cellrank,\npertpy, muon) but applies broadly. Do NOT guess API details from memory \u2014\nverify with context7 or skill references first.\n\n## Command Execution\n\n`execute_command` runs analysis work \u2014 scripts, bioinformatics CLI tools,\nshell pipes, and anything the workspace tools don't express. It starts in your\nworking directory; a relative `cwd` argument is resolved against it.\n\nFor these tasks, prefer the dedicated workspace tools \u2014 they are faster than\nshelling out and don't waste a turn on path discovery:\n\n| Task | Use this tool | Instead of |\n|-|-|-|\n| List files in a directory | `list_files` | `ls` / `ls -la` / `find -name` |\n| Read a text/source/result file | `read_file` | `cat` / `less` |\n| Search file contents by pattern | `grep` | shell `grep` / `rg` |\n| Size / type of a path | `file_stat` | `stat` / `wc -c` |\n\n**Use `execute_command` for everything else**, including:\n\n- **Running scripts** \u2014 `python scripts/run.py`, `Rscript scripts/de.R`,\n `ruff check scripts/foo.py`.\n- **Previewing huge / binary files** \u2014 `wc -l file.tsv`, `zcat file.gz | head`.\n For text files, prefer `read_file` with `headLines` / `tailLines`.\n- **Shell pipes / chaining** \u2014 `sort | uniq -c | sort -rn | head`.\n- **`find` with non-name predicates** \u2014 `find . -mtime -1`, `find . -size +10M`.\n- **Real CLI tools** \u2014 `samtools`, `bcftools`, `tabix`, `bedtools`,\n `vcftools`, version probes like `python -c \"import x; print(x.__version__)\"`.\n\n**Shell behavior to know:**\n\n- `cd` does NOT persist across calls \u2014 each exec starts in your working\n directory. For cross-call state, pass `cwd` or chain with `&&` in one exec.\n- Quote paths with spaces: `head \"data/inputs/My Folder/file.csv\"`. In Python,\n use `pathlib.Path` or `os.path`.\n\n## Editing Files\n\n`edit_file` replaces `old_string` with `new_string`. Read the file first to\nget the exact text. When `replace_all` is false (default), `old_string` must\noccur exactly once \u2014 include surrounding context to make it unique.\n";
|
|
33
33
|
/**
|
|
34
34
|
* The orient-core layer for one declared toolchain. `"image"` states that an
|
|
35
35
|
* acquisition is a host action and directs the agent to report a missing
|
|
36
|
-
* package.
|
|
36
|
+
* package. `"store"` gives the legacy constant untouched.
|
|
37
37
|
*/
|
|
38
|
-
export declare function sandboxOrientCorePromptFor(toolchainSource: ToolchainSource
|
|
38
|
+
export declare function sandboxOrientCorePromptFor(toolchainSource: ToolchainSource): string;
|
|
39
39
|
/**
|
|
40
40
|
* The package-link layer — appended to the sandbox system prompt only when
|
|
41
41
|
* the embedder binds the `ExtendAnalysisFarm` seam. A composition-time
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
* `prompts/briefing.ts`), which is composed per dispatch and where per-step
|
|
23
23
|
* content belongs.
|
|
24
24
|
*
|
|
25
|
-
* The orient-core text keys on the
|
|
26
|
-
* (`sandboxOrientCorePromptFor`).
|
|
27
|
-
* untouched, thus an
|
|
28
|
-
* Both variants are composition-time constants, thus
|
|
29
|
-
* byte-stable across its own steps.
|
|
25
|
+
* The orient-core text keys on the `toolchainSource` of the sandbox client
|
|
26
|
+
* (`sandboxOrientCorePromptFor`). `"store"` gives the legacy constant
|
|
27
|
+
* untouched, thus an embedder that declares no toolchain keeps its cached
|
|
28
|
+
* prefix byte-identical. Both variants are composition-time constants, thus
|
|
29
|
+
* each composition stays byte-stable across its own steps.
|
|
30
30
|
*/
|
|
31
31
|
export const sandboxOrientCorePrompt = `# Sandbox Orient-Core
|
|
32
32
|
|
|
@@ -291,7 +291,7 @@ sandbox.`), `- **Packages** — before importing a package you are not certain i
|
|
|
291
291
|
/**
|
|
292
292
|
* The orient-core layer for one declared toolchain. `"image"` states that an
|
|
293
293
|
* acquisition is a host action and directs the agent to report a missing
|
|
294
|
-
* package.
|
|
294
|
+
* package. `"store"` gives the legacy constant untouched.
|
|
295
295
|
*/
|
|
296
296
|
export function sandboxOrientCorePromptFor(toolchainSource) {
|
|
297
297
|
return toolchainSource === "image" ? imageToolchainOrientCorePrompt : sandboxOrientCorePrompt;
|
package/dist/sandbox/client.d.ts
CHANGED
|
@@ -25,8 +25,17 @@
|
|
|
25
25
|
* `tasks/derive-table-exec.ts` are the two worked examples. No type carries this
|
|
26
26
|
* rule yet, thus a new caller must read this paragraph.
|
|
27
27
|
*/
|
|
28
|
-
import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody } from "./types.js";
|
|
28
|
+
import type { CreateSandboxMeta, ExecEmit, ExecResult, ManagedSandbox, SandboxIdentity, SandboxLiveness, SandboxRef, SubmitExecBody, ToolchainSource } from "./types.js";
|
|
29
29
|
export interface SandboxClient {
|
|
30
|
+
/**
|
|
31
|
+
* The declared owner of the sandbox toolchain, as the client was composed
|
|
32
|
+
* with it: `"image"` when the config declared it, `"store"` when the config
|
|
33
|
+
* declared nothing. The mount plan and the orient-core prompt both key on
|
|
34
|
+
* this one value, thus the environment a sandbox gets and the text that
|
|
35
|
+
* describes it cannot disagree. Required rather than optional, because an
|
|
36
|
+
* optional field would carry two meanings, undeclared and unknown.
|
|
37
|
+
*/
|
|
38
|
+
readonly toolchainSource: ToolchainSource;
|
|
30
39
|
/**
|
|
31
40
|
* DBOS step (`sandbox.create`) — the spawn half of the two-step create
|
|
32
41
|
* (see the harness-sandbox-exec spec). Launches the sandbox-base container/Job under the
|
|
@@ -80,6 +80,18 @@ export interface CreateSandboxClientConfig {
|
|
|
80
80
|
* this field existed.
|
|
81
81
|
*/
|
|
82
82
|
toolchainSource?: ToolchainSource;
|
|
83
|
+
/**
|
|
84
|
+
* The deployment cannot run an analysis without the package store. Under
|
|
85
|
+
* the fact, a farm that fails the `inflexa.lock` gate refuses the create
|
|
86
|
+
* with `farm_unusable`, and neither backend makes a machine. Absent keeps
|
|
87
|
+
* the degrade: the store mounts drop, a warning is logged, and the
|
|
88
|
+
* sandbox reports the store as unavailable. The field names the
|
|
89
|
+
* deployment fact rather than the remediation, so how the harness answers
|
|
90
|
+
* it can evolve without changing the embedder surface; a single-valued
|
|
91
|
+
* union (not a boolean) so future store classes extend it without a
|
|
92
|
+
* breaking change.
|
|
93
|
+
*/
|
|
94
|
+
packageStore?: "required";
|
|
83
95
|
/** Docker: host dir bind-mounted read-only at `/mnt/refs`. */
|
|
84
96
|
refStorePath?: string;
|
|
85
97
|
/** Docker: force the container platform (e.g. `linux/amd64`) so the sandbox matches the mounted lib store's arch. */
|
|
@@ -23,6 +23,7 @@ import { awaitExec } from "./await-exec.js";
|
|
|
23
23
|
import { createDockerSandboxOps } from "./docker-client.js";
|
|
24
24
|
import { createK8sSandboxOps } from "./k8s-client.js";
|
|
25
25
|
import { sandboxWriteTail } from "./mount-plan.js";
|
|
26
|
+
import { SandboxFailure } from "./sandbox-error.js";
|
|
26
27
|
import { submitExec } from "./submit-exec.js";
|
|
27
28
|
import { toPersistedRef } from "./types.js";
|
|
28
29
|
/**
|
|
@@ -73,6 +74,39 @@ export async function precreateStepTree(deps, meta) {
|
|
|
73
74
|
export function createSandboxClient(config) {
|
|
74
75
|
const backend = config.backend ?? config.env.backend;
|
|
75
76
|
const transport = config.transport ?? "poll";
|
|
77
|
+
// Normalized once, here, and handed to the backends and to the client
|
|
78
|
+
// alike: the mount plan and the orient-core prompt then key on one value,
|
|
79
|
+
// and an absent declaration means "store" on both sides.
|
|
80
|
+
const toolchainSource = config.toolchainSource ?? "store";
|
|
81
|
+
// The declared fact needs a gate that can prove it. Each combination below
|
|
82
|
+
// leaves the backend with no gate at all, thus the fact could never be
|
|
83
|
+
// proved and every create would pass an unproved store. The refusal is at
|
|
84
|
+
// composition, where the embedder states the fact — the same rule as the
|
|
85
|
+
// tail-beside-readOnly throw in `mount-plan.ts` — because a contradiction
|
|
86
|
+
// found at the first sandbox costs a whole run. A K8s `per-analysis` farm
|
|
87
|
+
// source with no `libStorePvcRoot` is NOT a contradiction: there the
|
|
88
|
+
// resolver owes the proof, and its refusal already stops the create.
|
|
89
|
+
if (config.packageStore === "required") {
|
|
90
|
+
if (backend === "docker" && !config.libStorePath) {
|
|
91
|
+
throw new Error("createSandboxClient: packageStore is required but no libStorePath is configured — the docker lock gate has no store to prove");
|
|
92
|
+
}
|
|
93
|
+
if (backend === "k8s" && !config.libStorePvc) {
|
|
94
|
+
throw new Error("createSandboxClient: packageStore is required but no libStorePvc is configured — the k8s lock gate has no store to prove");
|
|
95
|
+
}
|
|
96
|
+
if (backend === "k8s" && config.farmSource.kind === "fixed" && !config.libStorePvcRoot) {
|
|
97
|
+
throw new Error("createSandboxClient: packageStore is required but a fixed farm source has no libStorePvcRoot — no resolver and no host-side gate can prove the store");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* What the seam throws for a backend failure: the description of the
|
|
102
|
+
* variant as the message, and the variant itself on `cause`. The `mapErr`
|
|
103
|
+
* happens before the bridge because `toThrowable` passes an `Error`
|
|
104
|
+
* through untouched — thus `unwrapOrThrow` stays the single sanctioned
|
|
105
|
+
* Result→throw bridge, no second bridge is introduced, and the
|
|
106
|
+
* `must-use-result` lint patch keeps recognizing every site. The registry
|
|
107
|
+
* unwraps below stay on the plain bridge: they carry a `DbError`.
|
|
108
|
+
*/
|
|
109
|
+
const failing = (e) => new SandboxFailure(e);
|
|
76
110
|
const registerSandbox = async (meta, ref) => {
|
|
77
111
|
unwrapOrThrow(await setSandboxRef(config.pool, meta.runId, meta.stepId, toPersistedRef(ref), meta.execId ?? null));
|
|
78
112
|
};
|
|
@@ -88,7 +122,8 @@ export function createSandboxClient(config) {
|
|
|
88
122
|
libStorePvc: config.libStorePvc,
|
|
89
123
|
libStorePvcRoot: config.libStorePvcRoot,
|
|
90
124
|
farmSource: config.farmSource,
|
|
91
|
-
toolchainSource
|
|
125
|
+
toolchainSource,
|
|
126
|
+
packageStore: config.packageStore,
|
|
92
127
|
refStorePvc: config.refStorePvc,
|
|
93
128
|
nodeSelector: config.nodeSelector,
|
|
94
129
|
tolerations: config.tolerations,
|
|
@@ -103,7 +138,8 @@ export function createSandboxClient(config) {
|
|
|
103
138
|
resolveWorkspaceRoot: config.resolveWorkspaceRoot,
|
|
104
139
|
libStorePath: config.libStorePath,
|
|
105
140
|
farmSource: config.farmSource,
|
|
106
|
-
toolchainSource
|
|
141
|
+
toolchainSource,
|
|
142
|
+
packageStore: config.packageStore,
|
|
107
143
|
refStorePath: config.refStorePath,
|
|
108
144
|
platform: config.platform,
|
|
109
145
|
engineSocketPath: config.engineSocketPath,
|
|
@@ -115,7 +151,7 @@ export function createSandboxClient(config) {
|
|
|
115
151
|
// ref + step coordinates and the wrapper does the clear after the
|
|
116
152
|
// backend-level removal.
|
|
117
153
|
const teardown = async (ref) => {
|
|
118
|
-
unwrapOrThrow(await ops.teardown(ref));
|
|
154
|
+
unwrapOrThrow((await ops.teardown(ref)).mapErr(failing));
|
|
119
155
|
// Sandboxes are 1-per-step (see `sandbox-step.ts` body — each child
|
|
120
156
|
// workflow calls `createSandbox` once then `teardown` once), so the
|
|
121
157
|
// broad WHERE clause matches exactly one row. If that invariant ever
|
|
@@ -132,8 +168,9 @@ export function createSandboxClient(config) {
|
|
|
132
168
|
};
|
|
133
169
|
// One arbiter for both surfaces: the client's `isAlive` method and the
|
|
134
170
|
// poll loop's escalation probe are the same backend inspect.
|
|
135
|
-
const isAlive = async (ref) => unwrapOrThrow(await ops.isAlive(ref));
|
|
171
|
+
const isAlive = async (ref) => unwrapOrThrow((await ops.isAlive(ref)).mapErr(failing));
|
|
136
172
|
return {
|
|
173
|
+
toolchainSource,
|
|
137
174
|
createSandbox: async (meta, identity) => {
|
|
138
175
|
// The config states an engine fact (binds preserve host ownership);
|
|
139
176
|
// which remediation that fact demands — today, a world-writable step
|
|
@@ -150,7 +187,7 @@ export function createSandboxClient(config) {
|
|
|
150
187
|
}
|
|
151
188
|
// Clamp to cluster ceilings so the pod is always quota-admissible.
|
|
152
189
|
const resources = clampResources(meta.resources, config.resourceLimits);
|
|
153
|
-
return unwrapOrThrow(await ops.createSandbox({ ...meta, resources }, identity));
|
|
190
|
+
return unwrapOrThrow((await ops.createSandbox({ ...meta, resources }, identity)).mapErr(failing));
|
|
154
191
|
},
|
|
155
192
|
submitExec: async (ref, body) => submitExec(ref, body, {
|
|
156
193
|
...config.submitDeps,
|
|
@@ -163,10 +200,10 @@ export function createSandboxClient(config) {
|
|
|
163
200
|
// retention budget and still returns whole streams.
|
|
164
201
|
awaitExec: async (ref, execId, emit, deadline) => capExecStreams(await awaitExec(ref, execId, emit, deadline, composeAwaitOptions(config.awaitOptions, transport, isAlive)), config.execStreamByteCap ?? EXEC_STREAM_BYTE_CAP),
|
|
165
202
|
isAlive,
|
|
166
|
-
isAliveById: async (sandboxId) => unwrapOrThrow(await ops.isAliveById(sandboxId)),
|
|
203
|
+
isAliveById: async (sandboxId) => unwrapOrThrow((await ops.isAliveById(sandboxId)).mapErr(failing)),
|
|
167
204
|
teardown,
|
|
168
|
-
teardownById: async (sandboxId) => unwrapOrThrow(await ops.teardownById(sandboxId)),
|
|
169
|
-
listManagedSandboxes: async () => unwrapOrThrow(await ops.listManagedSandboxes()),
|
|
205
|
+
teardownById: async (sandboxId) => unwrapOrThrow((await ops.teardownById(sandboxId)).mapErr(failing)),
|
|
206
|
+
listManagedSandboxes: async () => unwrapOrThrow((await ops.listManagedSandboxes()).mapErr(failing)),
|
|
170
207
|
};
|
|
171
208
|
}
|
|
172
209
|
/** Re-exports so consumers import once from this module. */
|
|
@@ -63,6 +63,17 @@ export interface DockerClientConfig {
|
|
|
63
63
|
farmSource: FarmSource;
|
|
64
64
|
/** The declared toolchain owner. Absent means `"store"`, the legacy environment. */
|
|
65
65
|
toolchainSource?: ToolchainSource;
|
|
66
|
+
/**
|
|
67
|
+
* The deployment cannot run an analysis without the package store. Under
|
|
68
|
+
* the fact, a farm that fails the `inflexa.lock` gate refuses the create
|
|
69
|
+
* with `farm_unusable` and no container is made. Absent keeps the
|
|
70
|
+
* degrade: the store mounts drop, a warning is logged, and the container
|
|
71
|
+
* is still made. The field names the deployment fact rather than the
|
|
72
|
+
* remediation, so how the harness answers it can evolve without changing
|
|
73
|
+
* the embedder surface; a single-valued union (not a boolean) so future
|
|
74
|
+
* store classes extend it without a breaking change.
|
|
75
|
+
*/
|
|
76
|
+
packageStore?: "required";
|
|
66
77
|
/**
|
|
67
78
|
* Host ref store; bind-mounted read-only at `/mnt/refs`. Embedders pass the
|
|
68
79
|
* configured store location unconditionally — existence is (re-)checked at each
|
|
@@ -246,15 +246,29 @@ export function createDockerSandboxOps(config) {
|
|
|
246
246
|
farm = resolved.value;
|
|
247
247
|
}
|
|
248
248
|
// The `inflexa.lock` gate runs at each create: the store may have
|
|
249
|
-
// gone away, or the farm may be half-written.
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
249
|
+
// gone away, or the farm may be half-written. The failure has two
|
|
250
|
+
// outcomes. Under the declared fact the call refuses with
|
|
251
|
+
// `farm_unusable` before any container work, and it logs nothing:
|
|
252
|
+
// the returned value carries the reason, and the consumer logs the
|
|
253
|
+
// throw, thus a warning here would record the one fault twice.
|
|
254
|
+
// Without the fact it degrades — both store mounts drop, the
|
|
255
|
+
// container is still made, and the sandbox reports the store as
|
|
256
|
+
// unavailable. That drop is otherwise silent, thus it is warned.
|
|
254
257
|
let libsMounted = false;
|
|
255
258
|
if (config.libStorePath && farm) {
|
|
256
259
|
const lock = readFarmLock(farm.farmPath);
|
|
257
260
|
libsMounted = lock.isOk();
|
|
261
|
+
if (lock.isErr() && config.packageStore === "required") {
|
|
262
|
+
return err({
|
|
263
|
+
type: "farm_unusable",
|
|
264
|
+
op: "docker.createSandbox",
|
|
265
|
+
analysisId: meta.analysisId,
|
|
266
|
+
farmPath: farm.farmPath,
|
|
267
|
+
lockPath: lock.error.lockPath,
|
|
268
|
+
lockError: lock.error.type,
|
|
269
|
+
cause: lock.error.cause,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
258
272
|
if (lock.isErr()) {
|
|
259
273
|
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
274
|
}
|
package/dist/sandbox/farm.d.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - A resolver refusal (`unavailable`, or a throw) is a `farm_unavailable`
|
|
11
11
|
* `SandboxError` — the call refuses and no container is made.
|
|
12
12
|
* - A lock-gate failure degrades — the backend drops both store mounts,
|
|
13
|
-
* logs a warning, and still makes the container.
|
|
13
|
+
* logs a warning, and still makes the container. Under a config that
|
|
14
|
+
* declares `packageStore: "required"`, the same failure refuses with
|
|
15
|
+
* `farm_unusable`, and no container is made.
|
|
14
16
|
*/
|
|
15
17
|
import { type Result } from "neverthrow";
|
|
16
18
|
import { z } from "zod";
|
package/dist/sandbox/farm.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - A resolver refusal (`unavailable`, or a throw) is a `farm_unavailable`
|
|
11
11
|
* `SandboxError` — the call refuses and no container is made.
|
|
12
12
|
* - A lock-gate failure degrades — the backend drops both store mounts,
|
|
13
|
-
* logs a warning, and still makes the container.
|
|
13
|
+
* logs a warning, and still makes the container. Under a config that
|
|
14
|
+
* declares `packageStore: "required"`, the same failure refuses with
|
|
15
|
+
* `farm_unusable`, and no container is made.
|
|
14
16
|
*/
|
|
15
17
|
import { readFileSync } from "node:fs";
|
|
16
18
|
import { join } from "node:path";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `image-packages.json` — the self-description of the sandbox image.
|
|
3
|
+
*
|
|
4
|
+
* The image bakes two package tracks that the content-addressed store cannot
|
|
5
|
+
* hold: the conda tools at `/opt/conda` and the Node packages at `/opt/node`.
|
|
6
|
+
* The image writes one record of them at `/opt/inflexa/image-packages.json`,
|
|
7
|
+
* and the catalog build copies that record verbatim into the root of the
|
|
8
|
+
* package store it packed beside it. Thus every reader of a store learns the
|
|
9
|
+
* image inventory from the store alone — with no container engine, no
|
|
10
|
+
* registry client, and no path outside the store.
|
|
11
|
+
*
|
|
12
|
+
* The record is JSON with a `schema` number, the same convention as
|
|
13
|
+
* {@link FarmLockSchema}: an additive field passes through untouched, and a
|
|
14
|
+
* breaking change to the shape moves the number instead. A structured record
|
|
15
|
+
* is what lets a row carry its version, and what lets a later change compare
|
|
16
|
+
* the recorded image against the configured one.
|
|
17
|
+
*
|
|
18
|
+
* This schema is the ONE definition of the shape. The image writes the bytes
|
|
19
|
+
* and the harness validates them, thus a producer that drifts is refused here
|
|
20
|
+
* rather than half-read downstream.
|
|
21
|
+
*/
|
|
22
|
+
import { type Result } from "neverthrow";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
/**
|
|
25
|
+
* The file name of the record, at the root of the package store. Exported,
|
|
26
|
+
* as `FARM_LOCK_FILE` is, thus an embedder that joins the path onto its own
|
|
27
|
+
* store root never spells the name by hand.
|
|
28
|
+
*/
|
|
29
|
+
export declare const IMAGE_PACKAGES_FILE = "image-packages.json";
|
|
30
|
+
/** The image the record describes. The pair of tags shares one version string. */
|
|
31
|
+
export declare const ImageIdentitySchema: z.ZodObject<{
|
|
32
|
+
repository: z.ZodString;
|
|
33
|
+
version: z.ZodString;
|
|
34
|
+
arch: z.ZodEnum<{
|
|
35
|
+
amd64: "amd64";
|
|
36
|
+
arm64: "arm64";
|
|
37
|
+
}>;
|
|
38
|
+
}, z.core.$loose>;
|
|
39
|
+
/** The interpreter versions of the image, so a reader can compare them against the lock. */
|
|
40
|
+
export declare const ImageRuntimesSchema: z.ZodObject<{
|
|
41
|
+
python: z.ZodString;
|
|
42
|
+
r: z.ZodString;
|
|
43
|
+
node: z.ZodString;
|
|
44
|
+
}, z.core.$loose>;
|
|
45
|
+
/**
|
|
46
|
+
* One conda tool of the image. `executable` carries the binary name when it
|
|
47
|
+
* differs from the package name (the manifest `binaries:` map holds those
|
|
48
|
+
* exceptions), because an agent invokes the binary and not the package.
|
|
49
|
+
*/
|
|
50
|
+
export declare const ImageSystemToolSchema: z.ZodObject<{
|
|
51
|
+
name: z.ZodString;
|
|
52
|
+
version: z.ZodString;
|
|
53
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
54
|
+
}, z.core.$loose>;
|
|
55
|
+
/** One Node package of the image. */
|
|
56
|
+
export declare const ImageNodePackageSchema: z.ZodObject<{
|
|
57
|
+
name: z.ZodString;
|
|
58
|
+
version: z.ZodString;
|
|
59
|
+
}, z.core.$loose>;
|
|
60
|
+
/**
|
|
61
|
+
* `image-packages.json` at schema version 1. The keys `system_tools` and
|
|
62
|
+
* `node` are the keys of the image manifest, thus one name means one track
|
|
63
|
+
* across the build and the reader.
|
|
64
|
+
*/
|
|
65
|
+
export declare const ImagePackagesSchema: z.ZodObject<{
|
|
66
|
+
schema: z.ZodLiteral<1>;
|
|
67
|
+
image: z.ZodObject<{
|
|
68
|
+
repository: z.ZodString;
|
|
69
|
+
version: z.ZodString;
|
|
70
|
+
arch: z.ZodEnum<{
|
|
71
|
+
amd64: "amd64";
|
|
72
|
+
arm64: "arm64";
|
|
73
|
+
}>;
|
|
74
|
+
}, z.core.$loose>;
|
|
75
|
+
runtimes: z.ZodObject<{
|
|
76
|
+
python: z.ZodString;
|
|
77
|
+
r: z.ZodString;
|
|
78
|
+
node: z.ZodString;
|
|
79
|
+
}, z.core.$loose>;
|
|
80
|
+
system_tools: z.ZodArray<z.ZodObject<{
|
|
81
|
+
name: z.ZodString;
|
|
82
|
+
version: z.ZodString;
|
|
83
|
+
executable: z.ZodOptional<z.ZodString>;
|
|
84
|
+
}, z.core.$loose>>;
|
|
85
|
+
node: z.ZodArray<z.ZodObject<{
|
|
86
|
+
name: z.ZodString;
|
|
87
|
+
version: z.ZodString;
|
|
88
|
+
}, z.core.$loose>>;
|
|
89
|
+
}, z.core.$loose>;
|
|
90
|
+
export type ImagePackages = z.infer<typeof ImagePackagesSchema>;
|
|
91
|
+
export type ImagePackagesError = {
|
|
92
|
+
readonly type: "record_unreadable";
|
|
93
|
+
readonly recordPath: string;
|
|
94
|
+
readonly cause: unknown;
|
|
95
|
+
} | {
|
|
96
|
+
readonly type: "record_invalid";
|
|
97
|
+
readonly recordPath: string;
|
|
98
|
+
readonly cause: unknown;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Read and validate one `image-packages.json` by its file path. The read is
|
|
102
|
+
* synchronous, the same as {@link readFarmLockFile}, because the caller reads
|
|
103
|
+
* the record on each call and a store download can land while the process
|
|
104
|
+
* runs. Both errors are normal states of a host that reads a store from
|
|
105
|
+
* before this record existed.
|
|
106
|
+
*/
|
|
107
|
+
export declare function readImagePackagesFile(recordPath: string): Result<ImagePackages, ImagePackagesError>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `image-packages.json` — the self-description of the sandbox image.
|
|
3
|
+
*
|
|
4
|
+
* The image bakes two package tracks that the content-addressed store cannot
|
|
5
|
+
* hold: the conda tools at `/opt/conda` and the Node packages at `/opt/node`.
|
|
6
|
+
* The image writes one record of them at `/opt/inflexa/image-packages.json`,
|
|
7
|
+
* and the catalog build copies that record verbatim into the root of the
|
|
8
|
+
* package store it packed beside it. Thus every reader of a store learns the
|
|
9
|
+
* image inventory from the store alone — with no container engine, no
|
|
10
|
+
* registry client, and no path outside the store.
|
|
11
|
+
*
|
|
12
|
+
* The record is JSON with a `schema` number, the same convention as
|
|
13
|
+
* {@link FarmLockSchema}: an additive field passes through untouched, and a
|
|
14
|
+
* breaking change to the shape moves the number instead. A structured record
|
|
15
|
+
* is what lets a row carry its version, and what lets a later change compare
|
|
16
|
+
* the recorded image against the configured one.
|
|
17
|
+
*
|
|
18
|
+
* This schema is the ONE definition of the shape. The image writes the bytes
|
|
19
|
+
* and the harness validates them, thus a producer that drifts is refused here
|
|
20
|
+
* rather than half-read downstream.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { err, ok } from "neverthrow";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
/**
|
|
26
|
+
* The file name of the record, at the root of the package store. Exported,
|
|
27
|
+
* as `FARM_LOCK_FILE` is, thus an embedder that joins the path onto its own
|
|
28
|
+
* store root never spells the name by hand.
|
|
29
|
+
*/
|
|
30
|
+
export const IMAGE_PACKAGES_FILE = "image-packages.json";
|
|
31
|
+
/** The image the record describes. The pair of tags shares one version string. */
|
|
32
|
+
export const ImageIdentitySchema = z
|
|
33
|
+
.object({
|
|
34
|
+
repository: z.string(),
|
|
35
|
+
version: z.string(),
|
|
36
|
+
arch: z.enum(["amd64", "arm64"]),
|
|
37
|
+
})
|
|
38
|
+
.passthrough();
|
|
39
|
+
/** The interpreter versions of the image, so a reader can compare them against the lock. */
|
|
40
|
+
export const ImageRuntimesSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
python: z.string(),
|
|
43
|
+
r: z.string(),
|
|
44
|
+
node: z.string(),
|
|
45
|
+
})
|
|
46
|
+
.passthrough();
|
|
47
|
+
/**
|
|
48
|
+
* One conda tool of the image. `executable` carries the binary name when it
|
|
49
|
+
* differs from the package name (the manifest `binaries:` map holds those
|
|
50
|
+
* exceptions), because an agent invokes the binary and not the package.
|
|
51
|
+
*/
|
|
52
|
+
export const ImageSystemToolSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
name: z.string(),
|
|
55
|
+
version: z.string(),
|
|
56
|
+
executable: z.string().optional(),
|
|
57
|
+
})
|
|
58
|
+
.passthrough();
|
|
59
|
+
/** One Node package of the image. */
|
|
60
|
+
export const ImageNodePackageSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
name: z.string(),
|
|
63
|
+
version: z.string(),
|
|
64
|
+
})
|
|
65
|
+
.passthrough();
|
|
66
|
+
/**
|
|
67
|
+
* `image-packages.json` at schema version 1. The keys `system_tools` and
|
|
68
|
+
* `node` are the keys of the image manifest, thus one name means one track
|
|
69
|
+
* across the build and the reader.
|
|
70
|
+
*/
|
|
71
|
+
export const ImagePackagesSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
schema: z.literal(1),
|
|
74
|
+
image: ImageIdentitySchema,
|
|
75
|
+
runtimes: ImageRuntimesSchema,
|
|
76
|
+
system_tools: z.array(ImageSystemToolSchema),
|
|
77
|
+
node: z.array(ImageNodePackageSchema),
|
|
78
|
+
})
|
|
79
|
+
.passthrough();
|
|
80
|
+
/**
|
|
81
|
+
* Read and validate one `image-packages.json` by its file path. The read is
|
|
82
|
+
* synchronous, the same as {@link readFarmLockFile}, because the caller reads
|
|
83
|
+
* the record on each call and a store download can land while the process
|
|
84
|
+
* runs. Both errors are normal states of a host that reads a store from
|
|
85
|
+
* before this record existed.
|
|
86
|
+
*/
|
|
87
|
+
export function readImagePackagesFile(recordPath) {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = readFileSync(recordPath, "utf8");
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
return err({ type: "record_unreadable", recordPath, cause });
|
|
94
|
+
}
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
catch (cause) {
|
|
100
|
+
return err({ type: "record_invalid", recordPath, cause });
|
|
101
|
+
}
|
|
102
|
+
const record = ImagePackagesSchema.safeParse(parsed);
|
|
103
|
+
if (!record.success) {
|
|
104
|
+
return err({ type: "record_invalid", recordPath, cause: record.error });
|
|
105
|
+
}
|
|
106
|
+
return ok(record.data);
|
|
107
|
+
}
|
|
@@ -71,6 +71,17 @@ export interface K8sClientConfig {
|
|
|
71
71
|
farmSource: FarmSource;
|
|
72
72
|
/** The declared toolchain owner. Absent means `"store"`, the legacy environment. */
|
|
73
73
|
toolchainSource?: ToolchainSource;
|
|
74
|
+
/**
|
|
75
|
+
* The deployment cannot run an analysis without the package store. Under
|
|
76
|
+
* the fact, a farm that fails the `inflexa.lock` gate refuses the create
|
|
77
|
+
* with `farm_unusable` and no Job is made. Absent keeps the degrade: the
|
|
78
|
+
* store mounts drop, a warning is logged, and the Job is still made. The
|
|
79
|
+
* field names the deployment fact rather than the remediation, so how the
|
|
80
|
+
* harness answers it can evolve without changing the embedder surface; a
|
|
81
|
+
* single-valued union (not a boolean) so future store classes extend it
|
|
82
|
+
* without a breaking change.
|
|
83
|
+
*/
|
|
84
|
+
packageStore?: "required";
|
|
74
85
|
/** PVC claim mounted read-only at `/mnt/refs` when set. */
|
|
75
86
|
refStorePvc?: string;
|
|
76
87
|
/** Node selector pinning sandbox pods to the dedicated agent pool. Omit for default scheduling. */
|
|
@@ -465,14 +465,30 @@ export function createK8sSandboxOps(config) {
|
|
|
465
465
|
// The `inflexa.lock` gate, for a host that mounts the libs
|
|
466
466
|
// volume itself. The resolver owes the proof either way,
|
|
467
467
|
// but a farm half-written between the resolution and this
|
|
468
|
-
// create still mounts without a second look.
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
468
|
+
// create still mounts without a second look. The failure
|
|
469
|
+
// has the two outcomes of the Docker backend. Under the
|
|
470
|
+
// declared fact the call refuses with `farm_unusable`
|
|
471
|
+
// before any cluster work, and it logs nothing: the
|
|
472
|
+
// returned value carries the reason, and the consumer logs
|
|
473
|
+
// the throw, thus a warning here would record the one fault
|
|
474
|
+
// twice. Without the fact it degrades — no store mount, the
|
|
475
|
+
// Job is still made, and the sandbox reports the store as
|
|
476
|
+
// unavailable.
|
|
472
477
|
let libsMounted = !!config.libStorePvc;
|
|
473
478
|
if (config.libStorePvc && config.libStorePvcRoot && farm) {
|
|
474
479
|
const lock = readFarmLock(join(config.libStorePvcRoot, farm.farmPath));
|
|
475
480
|
libsMounted = lock.isOk();
|
|
481
|
+
if (lock.isErr() && config.packageStore === "required") {
|
|
482
|
+
return err({
|
|
483
|
+
type: "farm_unusable",
|
|
484
|
+
op: "k8s.createSandbox",
|
|
485
|
+
analysisId: meta.analysisId,
|
|
486
|
+
farmPath: farm.farmPath,
|
|
487
|
+
lockPath: lock.error.lockPath,
|
|
488
|
+
lockError: lock.error.type,
|
|
489
|
+
cause: lock.error.cause,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
476
492
|
if (lock.isErr()) {
|
|
477
493
|
(config.logger ?? createNoopLogger())
|
|
478
494
|
.named("k8s-client")
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
* ever wraps the single SDK/HTTP call handed to it.
|
|
27
27
|
*
|
|
28
28
|
* The public `SandboxClient` interface keeps `Promise<T>` method signatures.
|
|
29
|
-
* `create-sandbox.ts` is the composition seam: it builds the backend ops,
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
|
|
30
|
+
* each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
|
|
31
|
+
* seam throws the described failure with the variant on `.cause` and on
|
|
32
|
+
* `.error`. A `catch` never sees the raw variant. (`submitExec` is the
|
|
33
|
+
* exception — it is already its own DBOS step and throws directly, so the seam
|
|
34
|
+
* forwards its `Promise<void>` as-is.)
|
|
33
35
|
* Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
|
|
34
36
|
* `DBOS.runStep`, so the re-thrown error records the step as failed;
|
|
35
37
|
* non-workflow consumers (`reaper`, `watchdog`,
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
* untouched by this wave.
|
|
38
40
|
*/
|
|
39
41
|
import { ResultAsync } from "neverthrow";
|
|
42
|
+
import type { FarmLockError } from "./farm.js";
|
|
40
43
|
/**
|
|
41
44
|
* A sandbox backend failure. Absence / idempotent "already gone" is NOT
|
|
42
45
|
* modelled — those ride the OK channel (`ok(false)` for a dead/missing machine,
|
|
@@ -91,6 +94,22 @@ export type SandboxError = {
|
|
|
91
94
|
readonly analysisId: string;
|
|
92
95
|
readonly reason: string;
|
|
93
96
|
readonly cause?: unknown;
|
|
97
|
+
} | {
|
|
98
|
+
/**
|
|
99
|
+
* The gate proved the `inflexa.lock` at the resolved farm and the
|
|
100
|
+
* farm is not usable. Raised only when the config declares
|
|
101
|
+
* `packageStore: "required"`; without the fact the same gate
|
|
102
|
+
* failure degrades to no store mount. The sibling
|
|
103
|
+
* `farm_unavailable` is the resolver refusing to name a farm at
|
|
104
|
+
* all — here a farm was named, and its lock does not hold up.
|
|
105
|
+
*/
|
|
106
|
+
readonly type: "farm_unusable";
|
|
107
|
+
readonly op: string;
|
|
108
|
+
readonly analysisId: string;
|
|
109
|
+
readonly farmPath: string;
|
|
110
|
+
readonly lockPath: string;
|
|
111
|
+
readonly lockError: FarmLockError["type"];
|
|
112
|
+
readonly cause: unknown;
|
|
94
113
|
};
|
|
95
114
|
/**
|
|
96
115
|
* The HTTP/driver status a backend SDK error carries. dockerode exposes it on
|
|
@@ -100,8 +119,24 @@ export type SandboxError = {
|
|
|
100
119
|
* `typeof === "number"` to avoid returning it as a status.
|
|
101
120
|
*/
|
|
102
121
|
export declare function sandboxStatusOf(cause: unknown): number | undefined;
|
|
103
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* A one-line, user-facing description of a `SandboxError` for logs/error
|
|
124
|
+
* bodies. The variant gives the head. Where the cause names a reason, that
|
|
125
|
+
* bounded line closes the description, thus the engine or the parser says why
|
|
126
|
+
* on the operator surfaces.
|
|
127
|
+
*/
|
|
104
128
|
export declare function describeSandboxError(e: SandboxError): string;
|
|
129
|
+
/**
|
|
130
|
+
* The throw of the `SandboxClient` seam. The message is
|
|
131
|
+
* `describeSandboxError`, thus a surface that renders a message alone still
|
|
132
|
+
* names the fault and its reason. The variant rides two ways: on `cause`, so
|
|
133
|
+
* a cause-chain reader keeps working, and on `error`, so a consumer that
|
|
134
|
+
* matches the class reads the typed fields without a walk.
|
|
135
|
+
*/
|
|
136
|
+
export declare class SandboxFailure extends Error {
|
|
137
|
+
readonly error: SandboxError;
|
|
138
|
+
constructor(error: SandboxError);
|
|
139
|
+
}
|
|
105
140
|
/**
|
|
106
141
|
* Wrap a single sandbox backend call (Docker SDK op, K8s API op, or the
|
|
107
142
|
* `/exec` POST). A throw becomes `err(toError(status, cause))`; the caller
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
* ever wraps the single SDK/HTTP call handed to it.
|
|
27
27
|
*
|
|
28
28
|
* The public `SandboxClient` interface keeps `Promise<T>` method signatures.
|
|
29
|
-
* `create-sandbox.ts` is the composition seam: it builds the backend ops,
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* `create-sandbox.ts` is the composition seam: it builds the backend ops, maps
|
|
30
|
+
* each `err` of an op to a `SandboxFailure`, and `unwrapOrThrow`s it, so the
|
|
31
|
+
* seam throws the described failure with the variant on `.cause` and on
|
|
32
|
+
* `.error`. A `catch` never sees the raw variant. (`submitExec` is the
|
|
33
|
+
* exception — it is already its own DBOS step and throws directly, so the seam
|
|
34
|
+
* forwards its `Promise<void>` as-is.)
|
|
33
35
|
* Workflow consumers (`sandbox-step.ts`) already wrap each seam call in
|
|
34
36
|
* `DBOS.runStep`, so the re-thrown error records the step as failed;
|
|
35
37
|
* non-workflow consumers (`reaper`, `watchdog`,
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
* untouched by this wave.
|
|
38
40
|
*/
|
|
39
41
|
import { ResultAsync, err, ok } from "neverthrow";
|
|
42
|
+
import { ZodError } from "zod";
|
|
40
43
|
const _assertDomainError = true;
|
|
41
44
|
/**
|
|
42
45
|
* The HTTP/driver status a backend SDK error carries. dockerode exposes it on
|
|
@@ -51,8 +54,62 @@ export function sandboxStatusOf(cause) {
|
|
|
51
54
|
return e.code;
|
|
52
55
|
return e.statusCode ?? e.response?.statusCode;
|
|
53
56
|
}
|
|
54
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* The bound on the appended cause line — the same 200 characters the profile
|
|
59
|
+
* ledger keeps (`tasks/data-profile.ts`), because a description reaches that
|
|
60
|
+
* ledger and the run row. A stack trace and an engine response body must stay
|
|
61
|
+
* out of a row, so the line is cut and marked rather than carried whole.
|
|
62
|
+
*/
|
|
63
|
+
const CAUSE_LINE_MAX = 200;
|
|
64
|
+
/**
|
|
65
|
+
* The one bounded line that names the reason of a cause, or `undefined` when
|
|
66
|
+
* the cause carries no reason to name.
|
|
67
|
+
*
|
|
68
|
+
* A `ZodError` is special-cased: its `message` is multi-line JSON whose first
|
|
69
|
+
* line is `[`, which names nothing. Thus a zod cause renders as its first
|
|
70
|
+
* issue — the path and the message — and the reader sees which field broke.
|
|
71
|
+
* Any other cause gives the first line of its message.
|
|
72
|
+
*/
|
|
73
|
+
function causeLine(cause) {
|
|
74
|
+
let text;
|
|
75
|
+
if (cause instanceof ZodError) {
|
|
76
|
+
const issue = cause.issues[0];
|
|
77
|
+
if (!issue)
|
|
78
|
+
return undefined;
|
|
79
|
+
const path = issue.path.join(".");
|
|
80
|
+
text = path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
81
|
+
}
|
|
82
|
+
else if (cause instanceof Error) {
|
|
83
|
+
text = cause.message;
|
|
84
|
+
}
|
|
85
|
+
else if (typeof cause === "string") {
|
|
86
|
+
text = cause;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const first = text.split("\n", 1)[0].trim();
|
|
92
|
+
if (first.length === 0)
|
|
93
|
+
return undefined;
|
|
94
|
+
return first.length > CAUSE_LINE_MAX ? `${first.slice(0, CAUSE_LINE_MAX - 1)}…` : first;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A one-line, user-facing description of a `SandboxError` for logs/error
|
|
98
|
+
* bodies. The variant gives the head. Where the cause names a reason, that
|
|
99
|
+
* bounded line closes the description, thus the engine or the parser says why
|
|
100
|
+
* on the operator surfaces.
|
|
101
|
+
*/
|
|
55
102
|
export function describeSandboxError(e) {
|
|
103
|
+
// `farm_unavailable` is the one variant whose head already carries its
|
|
104
|
+
// reason, the text of the resolver. A cause line after it would put two
|
|
105
|
+
// dashes on one row, and the throw of a resolver is in the log with its
|
|
106
|
+
// stack. Every other variant names its reason only through the cause.
|
|
107
|
+
const line = e.type === "farm_unavailable" ? undefined : causeLine(e.cause);
|
|
108
|
+
const head = describeVariant(e);
|
|
109
|
+
return line === undefined ? head : `${head} — ${line}`;
|
|
110
|
+
}
|
|
111
|
+
/** The head of a description: what failed, at which op, on which subject. */
|
|
112
|
+
function describeVariant(e) {
|
|
56
113
|
switch (e.type) {
|
|
57
114
|
case "container_create_failed":
|
|
58
115
|
return `sandbox create failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
|
|
@@ -68,6 +125,23 @@ export function describeSandboxError(e) {
|
|
|
68
125
|
return `sandbox liveness probe failed (${e.op}${e.sandboxId ? `: ${e.sandboxId}` : ""})`;
|
|
69
126
|
case "farm_unavailable":
|
|
70
127
|
return `farm unavailable (${e.op}: ${e.analysisId}) — ${e.reason}`;
|
|
128
|
+
case "farm_unusable":
|
|
129
|
+
return `farm unusable (${e.op}: ${e.analysisId}, ${e.lockError} at ${e.lockPath})`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The throw of the `SandboxClient` seam. The message is
|
|
134
|
+
* `describeSandboxError`, thus a surface that renders a message alone still
|
|
135
|
+
* names the fault and its reason. The variant rides two ways: on `cause`, so
|
|
136
|
+
* a cause-chain reader keeps working, and on `error`, so a consumer that
|
|
137
|
+
* matches the class reads the typed fields without a walk.
|
|
138
|
+
*/
|
|
139
|
+
export class SandboxFailure extends Error {
|
|
140
|
+
error;
|
|
141
|
+
constructor(error) {
|
|
142
|
+
super(describeSandboxError(error), { cause: error });
|
|
143
|
+
this.error = error;
|
|
144
|
+
this.name = "SandboxFailure";
|
|
71
145
|
}
|
|
72
146
|
}
|
|
73
147
|
/**
|
|
@@ -9,12 +9,10 @@
|
|
|
9
9
|
* planning surface reads the pool-scope inventory (`readPoolInventory`) —
|
|
10
10
|
* everything the store holds, whether or not a farm links it yet — because
|
|
11
11
|
* the ask flow marks the packages the POOL does not hold, and the farm of a
|
|
12
|
-
* new analysis is empty. The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* ## System tools (CLI)
|
|
17
|
-
* samtools, bcftools, ...
|
|
12
|
+
* new analysis is empty. The image inventory record (`image-packages.json`,
|
|
13
|
+
* at the root of the package store) merges into either report when the host
|
|
14
|
+
* can read one: it carries the conda tools and the Node packages the image
|
|
15
|
+
* owns, which the content-addressed store cannot hold.
|
|
18
16
|
*
|
|
19
17
|
* Each entry renders as `name==version` where the source pins a version, and
|
|
20
18
|
* a targeted `names` lookup also carries the store directory and the full
|
|
@@ -23,6 +21,7 @@
|
|
|
23
21
|
*/
|
|
24
22
|
import type { EnvironmentStorePaths, PoolInventoryPackage, PoolInventorySection } from "../../config/environment-stores.js";
|
|
25
23
|
import { type FarmLock } from "../../sandbox/farm.js";
|
|
24
|
+
import { type ImagePackages } from "../../sandbox/image-packages.js";
|
|
26
25
|
/**
|
|
27
26
|
* Group the lock packages into sections, one per track, in a stable order:
|
|
28
27
|
* the known tracks first, then an unknown track at its first occurrence.
|
|
@@ -30,9 +29,19 @@ import { type FarmLock } from "../../sandbox/farm.js";
|
|
|
30
29
|
* targeted `names` path can report the exact store identity.
|
|
31
30
|
*/
|
|
32
31
|
export declare function lockSections(lock: FarmLock): Section[];
|
|
32
|
+
/**
|
|
33
|
+
* Group the image record into its two sections, in the order of the record.
|
|
34
|
+
* An empty track yields no section, so the report never carries a heading
|
|
35
|
+
* with nothing under it.
|
|
36
|
+
*
|
|
37
|
+
* A `system_tools` row renders its `executable` name where the record gives
|
|
38
|
+
* one, because an agent invokes the binary rather than the conda package
|
|
39
|
+
* (the manifest `binaries:` map holds the pairs that differ).
|
|
40
|
+
*/
|
|
41
|
+
export declare function imageSections(record: ImagePackages): Section[];
|
|
33
42
|
/** One package entry of a section, with the store identity where the source records it. */
|
|
34
43
|
export type SectionPackage = PoolInventoryPackage;
|
|
35
|
-
/** One language track of the store, in
|
|
44
|
+
/** One language track of the store, in the section order of its source. The seam shape and the render shape are one. */
|
|
36
45
|
export type Section = PoolInventorySection;
|
|
37
46
|
/** One `names` lookup: present + canonical spelling + track + store identity, or absent. */
|
|
38
47
|
export type CheckedPackage = {
|
|
@@ -65,13 +74,6 @@ export type PackagesResult = {
|
|
|
65
74
|
readonly hasMore: boolean;
|
|
66
75
|
readonly content: string;
|
|
67
76
|
};
|
|
68
|
-
/**
|
|
69
|
-
* Parse `packages.txt` into its sections. `#` lines are the advisory header,
|
|
70
|
-
* `## X` opens a section, and every other non-empty line contributes
|
|
71
|
-
* comma-separated package names to the open section. Unknown section headings
|
|
72
|
-
* are preserved as-is — a downstream image may add its own track.
|
|
73
|
-
*/
|
|
74
|
-
export declare function parsePackagesFile(content: string): Section[];
|
|
75
77
|
/** The tool's input, as parsed from `inputSchema`. */
|
|
76
78
|
export interface PackagesQuery {
|
|
77
79
|
readonly names?: readonly string[];
|
|
@@ -83,7 +85,7 @@ export interface PackagesQuery {
|
|
|
83
85
|
* Answer a packages query against a parsed catalog. Pure — the tool's `execute`
|
|
84
86
|
* is only the file read plus this call.
|
|
85
87
|
*/
|
|
86
|
-
export declare function queryPackages(
|
|
88
|
+
export declare function queryPackages(rawSections: readonly Section[], { names, query, language, limit }: PackagesQuery): PackagesResult;
|
|
87
89
|
export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFile" | "imagePackagesFile" | "readPoolInventory"> & {
|
|
88
90
|
/**
|
|
89
91
|
* True when the composition also attaches the `link_packages` tool. The
|
|
@@ -96,7 +98,7 @@ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFil
|
|
|
96
98
|
* Create the package inventory over the bound source: the pool-scope reader
|
|
97
99
|
* when the embedder binds one (a conversation or planning surface), the
|
|
98
100
|
* host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
|
|
99
|
-
*
|
|
101
|
+
* record merges into either report.
|
|
100
102
|
*/
|
|
101
103
|
export declare function createListAvailablePackagesTool(deps?: ListAvailablePackagesDeps): import("../define-tool.js").Tool<{
|
|
102
104
|
names?: string[] | undefined;
|
|
@@ -9,25 +9,23 @@
|
|
|
9
9
|
* planning surface reads the pool-scope inventory (`readPoolInventory`) —
|
|
10
10
|
* everything the store holds, whether or not a farm links it yet — because
|
|
11
11
|
* the ask flow marks the packages the POOL does not hold, and the farm of a
|
|
12
|
-
* new analysis is empty. The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* ## System tools (CLI)
|
|
17
|
-
* samtools, bcftools, ...
|
|
12
|
+
* new analysis is empty. The image inventory record (`image-packages.json`,
|
|
13
|
+
* at the root of the package store) merges into either report when the host
|
|
14
|
+
* can read one: it carries the conda tools and the Node packages the image
|
|
15
|
+
* owns, which the content-addressed store cannot hold.
|
|
18
16
|
*
|
|
19
17
|
* Each entry renders as `name==version` where the source pins a version, and
|
|
20
18
|
* a targeted `names` lookup also carries the store directory and the full
|
|
21
19
|
* content hash where the source records them. A full listing carries no
|
|
22
20
|
* hashes — a thousand rows of sha256 would bury the signal.
|
|
23
21
|
*/
|
|
24
|
-
import { readFile } from "node:fs/promises";
|
|
25
22
|
import { ok } from "neverthrow";
|
|
26
23
|
import { z } from "zod";
|
|
27
24
|
import { defineTool } from "../define-tool.js";
|
|
28
25
|
import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
|
|
29
26
|
import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
|
|
30
27
|
import { readFarmLockFile } from "../../sandbox/farm.js";
|
|
28
|
+
import { IMAGE_PACKAGES_FILE, readImagePackagesFile } from "../../sandbox/image-packages.js";
|
|
31
29
|
/**
|
|
32
30
|
* Where the lock lives when the host mounts the farm at the same path the
|
|
33
31
|
* sandbox does. Both farm container paths are tried, because the path keys on
|
|
@@ -36,15 +34,30 @@ import { readFarmLockFile } from "../../sandbox/farm.js";
|
|
|
36
34
|
* reads the farm somewhere else injects its own path instead.
|
|
37
35
|
*/
|
|
38
36
|
const DEFAULT_FARM_LOCK_FILES = [`${LIBS_CONTAINER_PATH}/farm/inflexa.lock`, `${LIBS_CONTAINER_PATH}/current/inflexa.lock`];
|
|
39
|
-
/**
|
|
40
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Where the image inventory record lives: at the root of the package store,
|
|
39
|
+
* which the catalog build copied it into. The default names the container
|
|
40
|
+
* mountpoint of that store, the same rule as {@link DEFAULT_FARM_LOCK_FILES}
|
|
41
|
+
* — a host whose own process sees the store the way a sandbox does needs no
|
|
42
|
+
* configuration. A host that reads the store somewhere else injects
|
|
43
|
+
* `imagePackagesFile`.
|
|
44
|
+
*/
|
|
45
|
+
const DEFAULT_IMAGE_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/${IMAGE_PACKAGES_FILE}`;
|
|
46
|
+
/**
|
|
47
|
+
* The two headings that a lock track and the image record can both carry.
|
|
48
|
+
* Declared once, because {@link LANGUAGE_MATCHERS} keys the `language` filter
|
|
49
|
+
* on the heading text, and {@link foldSections} merges two sources by it: a
|
|
50
|
+
* second spelling of either would split one track into two headings.
|
|
51
|
+
*/
|
|
52
|
+
const CLI_TITLE = "System tools (CLI)";
|
|
53
|
+
const NODE_TITLE = "Node (npm)";
|
|
41
54
|
/** The section title of each known lock track. An unknown track titles itself. */
|
|
42
55
|
const TRACK_TITLES = {
|
|
43
56
|
python: "Python (pip)",
|
|
44
57
|
cran: "R (CRAN)",
|
|
45
58
|
bioconductor: "R (Bioconductor)",
|
|
46
59
|
github: "R (GitHub)",
|
|
47
|
-
node:
|
|
60
|
+
node: NODE_TITLE,
|
|
48
61
|
};
|
|
49
62
|
/**
|
|
50
63
|
* Group the lock packages into sections, one per track, in a stable order:
|
|
@@ -66,6 +79,56 @@ export function lockSections(lock) {
|
|
|
66
79
|
}
|
|
67
80
|
return [...byTrack.entries()].filter(([, packages]) => packages.length > 0).map(([track, packages]) => ({ title: TRACK_TITLES[track] ?? track, packages }));
|
|
68
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Group the image record into its two sections, in the order of the record.
|
|
84
|
+
* An empty track yields no section, so the report never carries a heading
|
|
85
|
+
* with nothing under it.
|
|
86
|
+
*
|
|
87
|
+
* A `system_tools` row renders its `executable` name where the record gives
|
|
88
|
+
* one, because an agent invokes the binary rather than the conda package
|
|
89
|
+
* (the manifest `binaries:` map holds the pairs that differ).
|
|
90
|
+
*/
|
|
91
|
+
export function imageSections(record) {
|
|
92
|
+
const sections = [];
|
|
93
|
+
if (record.system_tools.length > 0) {
|
|
94
|
+
sections.push({
|
|
95
|
+
title: CLI_TITLE,
|
|
96
|
+
packages: record.system_tools.map((tool) => ({ name: tool.executable ?? tool.name, version: tool.version })),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (record.node.length > 0) {
|
|
100
|
+
sections.push({ title: NODE_TITLE, packages: record.node.map((pkg) => ({ name: pkg.name, version: pkg.version })) });
|
|
101
|
+
}
|
|
102
|
+
return sections;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Merge the sections that share a title, in first-occurrence order, and keep
|
|
106
|
+
* the first entry of each name inside a merged section.
|
|
107
|
+
*
|
|
108
|
+
* Two sources can carry one track: the `node` track of a farm lock and the
|
|
109
|
+
* `node` track of the image record. Rendered as they arrive, a listing would
|
|
110
|
+
* print one heading twice and read as two tracks. The fold keeps the first
|
|
111
|
+
* writer, which is the same rule the `names` index applies, thus the listing
|
|
112
|
+
* and the presence check agree on which entry a colliding name resolves to.
|
|
113
|
+
*/
|
|
114
|
+
function foldSections(sections) {
|
|
115
|
+
const byTitle = new Map();
|
|
116
|
+
for (const section of sections) {
|
|
117
|
+
let open = byTitle.get(section.title);
|
|
118
|
+
if (!open) {
|
|
119
|
+
open = { title: section.title, packages: [], seen: new Set() };
|
|
120
|
+
byTitle.set(section.title, open);
|
|
121
|
+
}
|
|
122
|
+
for (const pkg of section.packages) {
|
|
123
|
+
const key = pkg.name.toLowerCase();
|
|
124
|
+
if (open.seen.has(key))
|
|
125
|
+
continue;
|
|
126
|
+
open.seen.add(key);
|
|
127
|
+
open.packages.push(pkg);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...byTitle.values()].map(({ title, packages }) => ({ title, packages }));
|
|
131
|
+
}
|
|
69
132
|
/**
|
|
70
133
|
* Default cap on a listing — high enough that the real store is never truncated.
|
|
71
134
|
* The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so
|
|
@@ -98,39 +161,10 @@ const LANGUAGE_MATCHERS = {
|
|
|
98
161
|
// The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
|
|
99
162
|
r: (t) => /^r\b/i.test(t),
|
|
100
163
|
python: (t) => /^python\b/i.test(t),
|
|
101
|
-
// `
|
|
164
|
+
// `CLI_TITLE` — the conda-installed bioinformatics executables.
|
|
102
165
|
cli: (t) => /system tools|\bcli\b/i.test(t),
|
|
103
166
|
node: (t) => /^node\b/i.test(t),
|
|
104
167
|
};
|
|
105
|
-
/**
|
|
106
|
-
* Parse `packages.txt` into its sections. `#` lines are the advisory header,
|
|
107
|
-
* `## X` opens a section, and every other non-empty line contributes
|
|
108
|
-
* comma-separated package names to the open section. Unknown section headings
|
|
109
|
-
* are preserved as-is — a downstream image may add its own track.
|
|
110
|
-
*/
|
|
111
|
-
export function parsePackagesFile(content) {
|
|
112
|
-
const sections = [];
|
|
113
|
-
for (const line of content.split("\n")) {
|
|
114
|
-
const trimmed = line.trim();
|
|
115
|
-
if (!trimmed)
|
|
116
|
-
continue;
|
|
117
|
-
if (trimmed.startsWith("##")) {
|
|
118
|
-
sections.push({ title: trimmed.slice(2).trim(), packages: [] });
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
if (trimmed.startsWith("#"))
|
|
122
|
-
continue;
|
|
123
|
-
const open = sections.at(-1);
|
|
124
|
-
if (!open)
|
|
125
|
-
continue;
|
|
126
|
-
for (const name of trimmed.split(",")) {
|
|
127
|
-
const pkg = name.trim();
|
|
128
|
-
if (pkg)
|
|
129
|
-
open.packages.push({ name: pkg });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return sections;
|
|
133
|
-
}
|
|
134
168
|
/** One entry as the listing renders it: `name==version` where the source pins a version, the bare name otherwise. */
|
|
135
169
|
function renderPackage(entry) {
|
|
136
170
|
return entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
|
|
@@ -160,15 +194,16 @@ function renderListing(sections, limit) {
|
|
|
160
194
|
* Answer a packages query against a parsed catalog. Pure — the tool's `execute`
|
|
161
195
|
* is only the file read plus this call.
|
|
162
196
|
*/
|
|
163
|
-
export function queryPackages(
|
|
197
|
+
export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
198
|
+
const sections = foldSections(rawSections);
|
|
164
199
|
// Presence check — the cheap, targeted path. Answers "is X available, and in
|
|
165
200
|
// which track" without returning the catalog.
|
|
166
201
|
if (names && names.length > 0) {
|
|
167
202
|
const index = new Map();
|
|
168
203
|
for (const section of sections) {
|
|
169
204
|
for (const pkg of section.packages) {
|
|
170
|
-
// First writer wins:
|
|
171
|
-
// colliding across tracks resolves to the earliest one.
|
|
205
|
+
// First writer wins: the sections arrive in a fixed order, so a
|
|
206
|
+
// name colliding across tracks resolves to the earliest one.
|
|
172
207
|
if (!index.has(pkg.name.toLowerCase()))
|
|
173
208
|
index.set(pkg.name.toLowerCase(), { entry: pkg, section: section.title });
|
|
174
209
|
}
|
|
@@ -219,7 +254,7 @@ export function queryPackages(sections, { names, query, language, limit }) {
|
|
|
219
254
|
* Create the package inventory over the bound source: the pool-scope reader
|
|
220
255
|
* when the embedder binds one (a conversation or planning surface), the
|
|
221
256
|
* host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
|
|
222
|
-
*
|
|
257
|
+
* record merges into either report.
|
|
223
258
|
*/
|
|
224
259
|
export function createListAvailablePackagesTool(deps = {}) {
|
|
225
260
|
const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
|
|
@@ -284,12 +319,15 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
284
319
|
return "full package list";
|
|
285
320
|
},
|
|
286
321
|
execute: async (input) => {
|
|
287
|
-
// The
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
322
|
+
// The image record merges in when the host can read a valid one.
|
|
323
|
+
// An absent record and an invalid record both merge nothing, and
|
|
324
|
+
// neither is an error: the record is an enrichment, and the farm
|
|
325
|
+
// or pool inventory stays whole without it. A store packed before
|
|
326
|
+
// the record existed carries none. The tool holds no logger, thus
|
|
327
|
+
// an invalid record cannot be reported here — the schema of the
|
|
328
|
+
// harness refuses it, and the report degrades to the tracks it
|
|
329
|
+
// could read.
|
|
330
|
+
const recordSections = readImagePackagesFile(imagePackagesFile).map(imageSections).unwrapOr([]);
|
|
293
331
|
// An unreadable inventory is an expected environment state — model it as an
|
|
294
332
|
// `available: false` data variant telling the caller the set is UNKNOWN,
|
|
295
333
|
// WITH the reason: without it, a structural fault (a damaged dependency
|
|
@@ -301,7 +339,7 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
301
339
|
}));
|
|
302
340
|
if (pool.kind === "unavailable")
|
|
303
341
|
return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
|
|
304
|
-
return ok(queryPackages([...pool.sections, ...
|
|
342
|
+
return ok(queryPackages([...pool.sections, ...recordSections], input));
|
|
305
343
|
}
|
|
306
344
|
let lock = null;
|
|
307
345
|
for (const candidate of lockCandidates) {
|
|
@@ -312,7 +350,7 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
312
350
|
if (lock === null) {
|
|
313
351
|
return ok({ available: false, content: UNAVAILABLE_NOTE });
|
|
314
352
|
}
|
|
315
|
-
return ok(queryPackages([...lockSections(lock), ...
|
|
353
|
+
return ok(queryPackages([...lockSections(lock), ...recordSections], input));
|
|
316
354
|
},
|
|
317
355
|
});
|
|
318
356
|
}
|
package/package.json
CHANGED