@inflexa-ai/harness 0.6.0 → 0.7.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.
@@ -1,239 +1,47 @@
1
1
  /**
2
- * Reference-store discovery — two coordinated surfaces over the read-only
3
- * store at /mnt/refs:
2
+ * Reference-store discovery: `createListAvailableRefsTool`, a bounded, no-follow
3
+ * inventory of what is ACTUALLY provisioned in the reference store (managed
4
+ * installs and user-added files alike). Narrow with `path`/`category`, filter
5
+ * with `query`, cap with `limit`; every response carries `returned`/`total`/
6
+ * `hasMore` and the `categories` present, so a listing is never an unbounded
7
+ * dump. Symlinks are never followed. This tool only ever READS the store: the
8
+ * analysis environment cannot fetch reference data, and staging more is the
9
+ * host's job — so an absent dataset is a fact about this moment, not a verdict
10
+ * that it is unobtainable.
4
11
  *
5
- * - `createListAvailableRefsTool` — the wired sandbox tool. A bounded, no-follow
6
- * inventory of what is ACTUALLY mounted (managed installs, user-added files),
7
- * scanned live inside the sandbox. Narrow with `path`/`category`, filter with
8
- * `query`, cap with `limit`; every response carries `returned`/`total`/
9
- * `hasMore` and the `categories` present, so a listing is never an unbounded
10
- * dump. Symlinks are never followed; nothing can be downloaded at runtime.
11
- * - `queryRefs` — a pure query over a parsed `/mnt/refs/registry.json` manifest
12
- * (`files.by_category`), filtering on fields the registry carries: `local_path`,
13
- * `category`, `subtype`, `organism`, `tax_id`, `dataset`, `rows`.
12
+ * The store is read from the host filesystem at the embedder-supplied
13
+ * `refStorePath` and reported at `/mnt/refs`, the container path sandboxes mount
14
+ * it at — read where it lives, render where the caller will open it. Reading it
15
+ * host-side is what lets a planner ask what reference data exists before any
16
+ * sandbox is created; a step agent asking mid-run sees the same store, because
17
+ * the mount and this path are the same bytes.
18
+ *
19
+ * The scan reports paths; meaning is joined on afterwards from the install
20
+ * receipt and the catalog it names (organism, format, and each file's internal
21
+ * shape). Callers search by what the data IS, so the store's directory layout
22
+ * stays an installer detail rather than an interface anything encodes. Metadata
23
+ * only ever ENRICHES an entry — a file with no receipt and no catalog match is
24
+ * still returned, because a user-provided store is as valid as a managed one.
14
25
  */
15
- import { basename, join, posix as posixPath } from "node:path";
26
+ import { lstat, readFile, readdir } from "node:fs/promises";
27
+ import { join as hostJoin, posix as posixPath } from "node:path";
16
28
  import { ok } from "neverthrow";
17
29
  import { z } from "zod";
18
30
  import { REFERENCE_DATA_CATALOG } from "../../reference-data/catalog.js";
19
31
  import { parseReferenceInstallReceipt } from "../../reference-data/receipt.js";
20
32
  import { defineTool } from "../define-tool.js";
21
- import { runSandboxExec } from "../workspace/run-exec.js";
33
+ /** Container path the ref store is mounted at; every reported path is rendered under it. */
22
34
  const REFS_ROOT = "/mnt/refs";
23
35
  const MAX_ENTRIES = 200;
24
36
  const MAX_SCANNED_ENTRIES = 2_000;
25
37
  const MAX_PATH_BYTES = 40_000;
26
38
  const MAX_ENVELOPE_BYTES = 64_000;
27
39
  const MAX_INPUT_PATH_BYTES = 4_096;
28
- /** Default number of file entries a registry listing (`queryRefs`) returns. */
29
- const DEFAULT_LIMIT = 40;
30
- const SCAN_SCRIPT = String.raw `
31
- import json, os, sys
32
-
33
- ROOT = sys.argv[1]
34
- RELATIVE = sys.argv[2]
35
- TARGET = ROOT if RELATIVE == "." else os.path.join(ROOT, *RELATIVE.split("/"))
36
- MAX_ENTRIES = int(sys.argv[3])
37
- MAX_SCANNED = int(sys.argv[4])
38
- MAX_PATH_BYTES = int(sys.argv[5])
39
- MAX_ENVELOPE_BYTES = int(sys.argv[6])
40
-
41
- def emit(value):
42
- print(json.dumps(value, separators=(",", ":")))
43
-
44
- def capped_entries(path, limit):
45
- """Inspect and sort at most limit names; probe one extra only to mark truncation."""
46
- values = []
47
- visited = 0
48
- cut = False
49
- try:
50
- with os.scandir(path) as iterator:
51
- for entry in iterator:
52
- if visited >= limit:
53
- cut = True
54
- break
55
- visited += 1
56
- try:
57
- if entry.is_symlink():
58
- kind, size = "symlink", 0
59
- elif entry.is_file(follow_symlinks=False):
60
- kind, size = "file", entry.stat(follow_symlinks=False).st_size
61
- elif entry.is_dir(follow_symlinks=False):
62
- kind, size = "directory", 0
63
- else:
64
- kind, size = "other", 0
65
- values.append((entry.name, kind, size))
66
- except OSError:
67
- continue
68
- except OSError:
69
- return [], False
70
- values.sort(key=lambda value: value[0])
71
- return values, cut
72
-
73
- if not os.path.isdir(ROOT) or os.path.islink(ROOT):
74
- emit({"state": "unavailable", "entries": [], "scannedEntries": 0, "truncated": False})
75
- raise SystemExit(0)
76
-
77
- relative = RELATIVE
78
- cursor = ROOT
79
- if relative != ".":
80
- for part in relative.split(os.sep):
81
- cursor = os.path.join(cursor, part)
82
- if os.path.islink(cursor):
83
- emit({"state": "symlink", "entries": [], "scannedEntries": 0, "truncated": False})
84
- raise SystemExit(0)
85
-
86
- if not os.path.exists(TARGET):
87
- emit({"state": "not_found", "entries": [], "scannedEntries": 0, "truncated": False})
88
- raise SystemExit(0)
89
- if not os.path.isdir(TARGET):
90
- emit({"state": "not_a_directory", "entries": [], "scannedEntries": 0, "truncated": False})
91
- raise SystemExit(0)
92
-
93
- entries = []
94
- scanned = 0
95
- path_bytes = 0
96
- truncated = False
97
-
98
- children, children_cut = capped_entries(TARGET, min(MAX_ENTRIES + 2, MAX_SCANNED))
99
- truncated = children_cut
100
-
101
- for name, kind, size in children:
102
- path = os.path.join(TARGET, name)
103
- rel = os.path.relpath(path, ROOT).replace(os.sep, "/")
104
- if rel == ".inflexa" or rel.startswith(".inflexa/") or rel == "registry.json":
105
- continue
106
- scanned += 1
107
- rendered = "/mnt/refs/" + rel
108
- item = None
109
-
110
- if kind == "symlink":
111
- item = {"path": rendered, "kind": "symlink", "bytes": 0}
112
- elif kind == "file":
113
- item = {"path": rendered, "kind": "file", "bytes": size}
114
- elif kind == "directory":
115
- total_bytes = 0
116
- file_count = 0
117
- file_types = set()
118
- directory_truncated = False
119
- stack = [path]
120
- while stack and scanned < MAX_SCANNED:
121
- current = stack.pop()
122
- descendants, descendants_cut = capped_entries(current, MAX_SCANNED - scanned)
123
- directory_truncated = directory_truncated or descendants_cut
124
- child_directories = []
125
- for child_name, child_kind, child_size in descendants:
126
- scanned += 1
127
- if child_kind == "directory":
128
- child_directories.append(os.path.join(current, child_name))
129
- elif child_kind == "file":
130
- file_count += 1
131
- total_bytes += child_size
132
- suffix = os.path.splitext(child_name)[1].lower()
133
- if suffix:
134
- file_types.add(suffix)
135
- if scanned >= MAX_SCANNED:
136
- directory_truncated = True
137
- break
138
- stack.extend(reversed(child_directories))
139
- item = {
140
- "path": rendered,
141
- "kind": "directory",
142
- "bytes": total_bytes,
143
- "fileCount": file_count,
144
- "fileTypes": sorted(file_types)[:10],
145
- "truncated": directory_truncated,
146
- }
147
- truncated = truncated or directory_truncated
148
-
149
- if item is not None:
150
- if len(entries) < MAX_ENTRIES and path_bytes + len(rendered.encode()) <= MAX_PATH_BYTES:
151
- entries.append(item)
152
- path_bytes += len(rendered.encode())
153
- else:
154
- truncated = True
155
- if scanned >= MAX_SCANNED:
156
- truncated = True
157
- break
158
-
159
- receipts = []
160
- receipt_bytes = 0
161
- metadata_dir = os.path.join(ROOT, ".inflexa")
162
- receipts_dir = os.path.join(metadata_dir, "receipts")
163
- try:
164
- if os.path.islink(metadata_dir) or os.path.islink(receipts_dir):
165
- raise ValueError("symlinked metadata directory")
166
- receipt_entries, _ = capped_entries(receipts_dir, 100)
167
- for name, kind, _ in receipt_entries:
168
- path = os.path.join(receipts_dir, name)
169
- if kind != "file":
170
- continue
171
- # Per-file, so one unreadable or malformed receipt costs only its own labels.
172
- # A shared try would abandon the loop and silently drop every receipt sorting
173
- # after the bad one, quietly stripping metadata from datasets that are fine.
174
- try:
175
- with open(path, "rb") as handle:
176
- raw = handle.read(16385)
177
- if len(raw) > 16384 or receipt_bytes + len(raw) > 32768:
178
- continue
179
- parsed = json.loads(raw)
180
- except Exception:
181
- continue
182
- receipts.append(parsed)
183
- receipt_bytes += len(raw)
184
- except Exception:
185
- pass
186
-
187
- legacy_entries = []
188
- try:
189
- registry_path = os.path.join(ROOT, "registry.json")
190
- if os.path.islink(registry_path):
191
- raise ValueError("symlinked registry")
192
- with open(registry_path, "rb") as handle:
193
- registry = json.loads(handle.read(131073))
194
- if isinstance(registry, dict):
195
- by_category = registry.get("files", {}).get("by_category", {})
196
- if isinstance(by_category, dict):
197
- for category in sorted(by_category):
198
- values = by_category.get(category)
199
- if not isinstance(values, list):
200
- continue
201
- for item in values:
202
- if len(legacy_entries) >= 200:
203
- break
204
- if isinstance(item, dict) and isinstance(item.get("local_path"), str):
205
- legacy_entries.append({
206
- "local_path": item["local_path"][:1000],
207
- "category": str(category)[:500],
208
- "dataset": str(item.get("dataset"))[:500] if item.get("dataset") is not None else None,
209
- "subtype": str(item.get("subtype"))[:500] if item.get("subtype") is not None else None,
210
- "organism": str(item.get("organism"))[:500] if item.get("organism") is not None else None,
211
- "rows": item.get("rows"),
212
- "endpoint": item.get("endpoint"),
213
- })
214
- except Exception:
215
- pass
216
-
217
- payload = {
218
- "state": "empty" if len(entries) == 0 else "populated",
219
- "entries": entries,
220
- "scannedEntries": scanned,
221
- "truncated": truncated,
222
- "receipts": receipts,
223
- "legacyEntries": legacy_entries,
224
- }
225
- while len(json.dumps(payload, separators=(",", ":")).encode()) > MAX_ENVELOPE_BYTES:
226
- payload["truncated"] = True
227
- if payload["legacyEntries"]:
228
- payload["legacyEntries"].pop()
229
- elif payload["receipts"]:
230
- payload["receipts"].pop()
231
- elif payload["entries"]:
232
- payload["entries"].pop()
233
- else:
234
- break
235
- emit(payload)
236
- `;
40
+ const MAX_RECEIPT_FILES = 100;
41
+ const MAX_RECEIPT_BYTES = 16_384;
42
+ const MAX_RECEIPT_TOTAL_BYTES = 32_768;
43
+ const MAX_REGISTRY_BYTES = 131_072;
44
+ const MAX_LEGACY_ENTRIES = 200;
237
45
  const ListAvailableRefsInputSchema = z.object({
238
46
  path: z
239
47
  .string()
@@ -257,25 +65,6 @@ const ListAvailableRefsInputSchema = z.object({
257
65
  .optional()
258
66
  .describe(`Max entries to return (default ${MAX_ENTRIES}); the response carries \`returned\`, \`total\`, and \`hasMore\` so truncation is never silent.`),
259
67
  });
260
- const RawScanSchema = z.object({
261
- state: z.enum(["unavailable", "empty", "populated", "not_found", "not_a_directory", "symlink"]),
262
- entries: z.array(z.discriminatedUnion("kind", [
263
- z.object({ path: z.string().startsWith(`${REFS_ROOT}/`), kind: z.literal("file"), bytes: z.number().nonnegative() }),
264
- z.object({ path: z.string().startsWith(`${REFS_ROOT}/`), kind: z.literal("symlink"), bytes: z.literal(0) }),
265
- z.object({
266
- path: z.string().startsWith(`${REFS_ROOT}/`),
267
- kind: z.literal("directory"),
268
- bytes: z.number().nonnegative(),
269
- fileCount: z.number().int().nonnegative(),
270
- fileTypes: z.array(z.string()),
271
- truncated: z.boolean(),
272
- }),
273
- ])),
274
- scannedEntries: z.number().int().nonnegative(),
275
- truncated: z.boolean(),
276
- receipts: z.array(z.unknown()).optional(),
277
- legacyEntries: z.array(z.unknown()).optional(),
278
- });
279
68
  function resolveRequestedPath(input) {
280
69
  if (input === undefined || input === "" || input === REFS_ROOT)
281
70
  return { ok: true, path: REFS_ROOT };
@@ -298,6 +87,287 @@ function resolveRequestedPath(input) {
298
87
  return { ok: false, reason: "Path is outside /mnt/refs." };
299
88
  return { ok: true, path: normalized };
300
89
  }
90
+ /**
91
+ * Inspect at most `limit` names in one directory, sorted by name.
92
+ *
93
+ * Sorting BEFORE the cap (rather than capping the raw readdir order) keeps
94
+ * truncation deterministic: the same store yields the same prefix on every call,
95
+ * which a durably-cached step result has to be able to promise.
96
+ */
97
+ async function cappedEntries(directory, limit) {
98
+ let names;
99
+ try {
100
+ names = (await readdir(directory, { withFileTypes: true })).map((entry) => entry.name);
101
+ }
102
+ catch {
103
+ return { values: [], cut: false };
104
+ }
105
+ names.sort();
106
+ const cut = names.length > limit;
107
+ const values = [];
108
+ for (const name of names.slice(0, limit)) {
109
+ try {
110
+ // lstat, never stat: a symlink is reported as a symlink and never resolved,
111
+ // so a link out of the store cannot smuggle in a foreign file's size or type.
112
+ const stats = await lstat(hostJoin(directory, name));
113
+ if (stats.isSymbolicLink())
114
+ values.push({ name, kind: "symlink", bytes: 0 });
115
+ else if (stats.isFile())
116
+ values.push({ name, kind: "file", bytes: stats.size });
117
+ else if (stats.isDirectory())
118
+ values.push({ name, kind: "directory", bytes: 0 });
119
+ else
120
+ values.push({ name, kind: "other", bytes: 0 });
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ }
126
+ return { values, cut };
127
+ }
128
+ /** Whether any component of `relative` beneath `root` is a symlink — the no-follow gate. */
129
+ async function crossesSymlink(root, relative) {
130
+ let cursor = root;
131
+ for (const segment of relative.split("/")) {
132
+ cursor = hostJoin(cursor, segment);
133
+ try {
134
+ if ((await lstat(cursor)).isSymbolicLink())
135
+ return true;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+ /** Recursively total a directory's files, bounded by the remaining scan budget. */
144
+ async function summarizeDirectory(directory, scanned) {
145
+ let totalBytes = 0;
146
+ let fileCount = 0;
147
+ let truncated = false;
148
+ const fileTypes = new Set();
149
+ const stack = [directory];
150
+ while (stack.length > 0 && scanned < MAX_SCANNED_ENTRIES) {
151
+ const current = stack.pop();
152
+ const { values, cut } = await cappedEntries(current, MAX_SCANNED_ENTRIES - scanned);
153
+ truncated = truncated || cut;
154
+ const childDirectories = [];
155
+ for (const child of values) {
156
+ scanned += 1;
157
+ if (child.kind === "directory")
158
+ childDirectories.push(hostJoin(current, child.name));
159
+ else if (child.kind === "file") {
160
+ fileCount += 1;
161
+ totalBytes += child.bytes;
162
+ const suffix = posixPath.extname(child.name).toLowerCase();
163
+ if (suffix)
164
+ fileTypes.add(suffix);
165
+ }
166
+ if (scanned >= MAX_SCANNED_ENTRIES) {
167
+ truncated = true;
168
+ break;
169
+ }
170
+ }
171
+ stack.push(...childDirectories.reverse());
172
+ }
173
+ return { bytes: totalBytes, fileCount, fileTypes: [...fileTypes].sort().slice(0, 10), truncated, scanned };
174
+ }
175
+ /** Install receipts written by the host installer under `<root>/.inflexa/receipts`. */
176
+ async function readReceipts(root) {
177
+ const receiptsDirectory = hostJoin(root, ".inflexa", "receipts");
178
+ if (await crossesSymlink(root, ".inflexa"))
179
+ return [];
180
+ const { values } = await cappedEntries(receiptsDirectory, MAX_RECEIPT_FILES);
181
+ const receipts = [];
182
+ let totalBytes = 0;
183
+ for (const entry of values) {
184
+ if (entry.kind !== "file" || entry.bytes > MAX_RECEIPT_BYTES || totalBytes + entry.bytes > MAX_RECEIPT_TOTAL_BYTES)
185
+ continue;
186
+ // Per-file, so one unreadable or malformed receipt costs only its own labels.
187
+ // A shared try would abandon the loop and silently drop every receipt sorting
188
+ // after the bad one, quietly stripping metadata from datasets that are fine.
189
+ try {
190
+ receipts.push(JSON.parse(await readFile(hostJoin(receiptsDirectory, entry.name), "utf-8")));
191
+ totalBytes += entry.bytes;
192
+ }
193
+ catch {
194
+ continue;
195
+ }
196
+ }
197
+ return receipts;
198
+ }
199
+ /** Labels from a `registry.json` an externally-provisioned store may carry. */
200
+ async function readLegacyEntries(root) {
201
+ const legacyEntries = [];
202
+ try {
203
+ if (await crossesSymlink(root, "registry.json"))
204
+ return [];
205
+ const registryPath = hostJoin(root, "registry.json");
206
+ if ((await lstat(registryPath)).size > MAX_REGISTRY_BYTES)
207
+ return [];
208
+ const registry = JSON.parse(await readFile(registryPath, "utf-8"));
209
+ if (!registry || typeof registry !== "object")
210
+ return [];
211
+ const byCategory = registry.files?.by_category;
212
+ if (!byCategory || typeof byCategory !== "object")
213
+ return [];
214
+ for (const category of Object.keys(byCategory).sort()) {
215
+ const values = byCategory[category];
216
+ if (!Array.isArray(values))
217
+ continue;
218
+ for (const item of values) {
219
+ if (legacyEntries.length >= MAX_LEGACY_ENTRIES)
220
+ break;
221
+ if (item && typeof item === "object" && typeof item.local_path === "string") {
222
+ legacyEntries.push({ ...item, category });
223
+ }
224
+ }
225
+ }
226
+ }
227
+ catch {
228
+ return legacyEntries;
229
+ }
230
+ return legacyEntries;
231
+ }
232
+ /**
233
+ * Collect FILES anywhere beneath `directory`, bounded by the scan budget.
234
+ *
235
+ * Searching by meaning only works if it reaches the files that carry meaning. A
236
+ * dataset's labels are joined onto its artifact at
237
+ * `managed/{id}/{version}/{artifact}`, so a listing of the store root — whose only
238
+ * entries are directories, which carry no such labels — can never match "regulon".
239
+ * Browsing wants the directory summary; searching wants the leaves.
240
+ */
241
+ async function collectFiles(directory, relative, budget) {
242
+ const entries = [];
243
+ let truncated = false;
244
+ const stack = [{ path: directory, relative }];
245
+ while (stack.length > 0 && budget.scanned < MAX_SCANNED_ENTRIES) {
246
+ const current = stack.pop();
247
+ const { values, cut } = await cappedEntries(current.path, MAX_SCANNED_ENTRIES - budget.scanned);
248
+ truncated = truncated || cut;
249
+ for (const child of values) {
250
+ budget.scanned += 1;
251
+ const childRelative = current.relative === "." ? child.name : `${current.relative}/${child.name}`;
252
+ // Installer metadata is not reference data — it never becomes a search hit.
253
+ if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
254
+ continue;
255
+ if (child.kind === "directory") {
256
+ stack.push({ path: hostJoin(current.path, child.name), relative: childRelative });
257
+ }
258
+ else if (child.kind === "file" || child.kind === "symlink") {
259
+ if (entries.length >= MAX_ENTRIES) {
260
+ truncated = true;
261
+ }
262
+ else {
263
+ entries.push(child.kind === "symlink"
264
+ ? { path: `${REFS_ROOT}/${childRelative}`, kind: "symlink", bytes: 0 }
265
+ : { path: `${REFS_ROOT}/${childRelative}`, kind: "file", bytes: child.bytes });
266
+ }
267
+ }
268
+ if (budget.scanned >= MAX_SCANNED_ENTRIES) {
269
+ truncated = true;
270
+ break;
271
+ }
272
+ }
273
+ }
274
+ return { entries, truncated };
275
+ }
276
+ /**
277
+ * Scan the store beneath `root`, reporting every expected state as data.
278
+ *
279
+ * `recursive` selects the two modes: a browse lists immediate children (directories
280
+ * summarized), a search walks to the files, because only a file carries the labels a
281
+ * search matches on.
282
+ */
283
+ async function scanStore(root, relative, recursive = false) {
284
+ const empty = () => ({ entries: [], scannedEntries: 0, truncated: false });
285
+ if (!root)
286
+ return { state: "unavailable", ...empty() };
287
+ try {
288
+ if (!(await lstat(root)).isDirectory())
289
+ return { state: "unavailable", ...empty() };
290
+ }
291
+ catch {
292
+ return { state: "unavailable", ...empty() };
293
+ }
294
+ if (relative !== "." && (await crossesSymlink(root, relative)))
295
+ return { state: "symlink", ...empty() };
296
+ const target = relative === "." ? root : hostJoin(root, ...relative.split("/"));
297
+ let targetStats;
298
+ try {
299
+ targetStats = await lstat(target);
300
+ }
301
+ catch {
302
+ return { state: "not_found", ...empty() };
303
+ }
304
+ if (!targetStats.isDirectory())
305
+ return { state: "not_a_directory", ...empty() };
306
+ if (recursive) {
307
+ const budget = { scanned: 0 };
308
+ const walked = await collectFiles(target, relative, budget);
309
+ return {
310
+ state: walked.entries.length === 0 ? "empty" : "populated",
311
+ entries: walked.entries,
312
+ scannedEntries: budget.scanned,
313
+ truncated: walked.truncated,
314
+ receipts: await readReceipts(root),
315
+ legacyEntries: await readLegacyEntries(root),
316
+ };
317
+ }
318
+ const entries = [];
319
+ let scanned = 0;
320
+ let pathBytes = 0;
321
+ let truncated = false;
322
+ const { values: children, cut } = await cappedEntries(target, Math.min(MAX_ENTRIES + 2, MAX_SCANNED_ENTRIES));
323
+ truncated = cut;
324
+ for (const child of children) {
325
+ const childRelative = relative === "." ? child.name : `${relative}/${child.name}`;
326
+ if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
327
+ continue;
328
+ scanned += 1;
329
+ const rendered = `${REFS_ROOT}/${childRelative}`;
330
+ let entry;
331
+ if (child.kind === "symlink")
332
+ entry = { path: rendered, kind: "symlink", bytes: 0 };
333
+ else if (child.kind === "file")
334
+ entry = { path: rendered, kind: "file", bytes: child.bytes };
335
+ else if (child.kind === "directory") {
336
+ const summary = await summarizeDirectory(hostJoin(target, child.name), scanned);
337
+ scanned = summary.scanned;
338
+ truncated = truncated || summary.truncated;
339
+ entry = {
340
+ path: rendered,
341
+ kind: "directory",
342
+ bytes: summary.bytes,
343
+ fileCount: summary.fileCount,
344
+ fileTypes: summary.fileTypes,
345
+ truncated: summary.truncated,
346
+ };
347
+ }
348
+ if (entry) {
349
+ if (entries.length < MAX_ENTRIES && pathBytes + Buffer.byteLength(rendered, "utf8") <= MAX_PATH_BYTES) {
350
+ entries.push(entry);
351
+ pathBytes += Buffer.byteLength(rendered, "utf8");
352
+ }
353
+ else {
354
+ truncated = true;
355
+ }
356
+ }
357
+ if (scanned >= MAX_SCANNED_ENTRIES) {
358
+ truncated = true;
359
+ break;
360
+ }
361
+ }
362
+ return {
363
+ state: entries.length === 0 ? "empty" : "populated",
364
+ entries,
365
+ scannedEntries: scanned,
366
+ truncated,
367
+ receipts: await readReceipts(root),
368
+ legacyEntries: await readLegacyEntries(root),
369
+ };
370
+ }
301
371
  function stringValue(value) {
302
372
  return typeof value === "string" && value.length > 0 ? value : undefined;
303
373
  }
@@ -312,6 +382,9 @@ function enrichEntries(entries, raw) {
312
382
  continue;
313
383
  const dataset = REFERENCE_DATA_CATALOG.datasets.find((candidate) => candidate.id === receipt.datasetId && candidate.version === receipt.datasetVersion);
314
384
  for (const artifact of receipt.artifacts) {
385
+ // Per-artifact, not per-dataset: format and contents differ between files of the
386
+ // same dataset (a Seurat object and its Annoy index are read nothing alike).
387
+ const catalogArtifact = dataset?.artifacts.find((candidate) => candidate.path === artifact.path);
315
388
  metadata.set(`${REFS_ROOT}/managed/${receipt.datasetId}/${receipt.datasetVersion}/${artifact.path}`, {
316
389
  datasetId: receipt.datasetId,
317
390
  version: receipt.datasetVersion,
@@ -321,8 +394,11 @@ function enrichEntries(entries, raw) {
321
394
  description: dataset.description,
322
395
  sourceUrl: dataset.sourceUrl,
323
396
  license: dataset.license.url ?? dataset.license.identifier,
397
+ category: dataset.recommendation.group,
398
+ ...(dataset.organism ? { organism: dataset.organism } : {}),
324
399
  }
325
400
  : {}),
401
+ ...(catalogArtifact ? { format: catalogArtifact.format, contents: catalogArtifact.contents } : {}),
326
402
  });
327
403
  }
328
404
  }
@@ -351,9 +427,23 @@ function enrichEntries(entries, raw) {
351
427
  return extra ? { ...entry, metadata: extra } : entry;
352
428
  });
353
429
  }
354
- /** Case-insensitive substring match over an inventory entry's path and joined metadata labels. */
430
+ /**
431
+ * Case-insensitive substring match over an inventory entry's path and joined metadata labels.
432
+ * `description` and `contents` are in scope deliberately: a caller searching for what the data
433
+ * IS ("regulon", "tf-target") should hit a file whose name says only "CollecTRI".
434
+ */
355
435
  function entryMatchesQuery(entry, needle) {
356
- const fields = [entry.path, entry.metadata?.datasetId, entry.metadata?.title, entry.metadata?.category, entry.metadata?.subtype, entry.metadata?.organism];
436
+ const fields = [
437
+ entry.path,
438
+ entry.metadata?.datasetId,
439
+ entry.metadata?.title,
440
+ entry.metadata?.description,
441
+ entry.metadata?.category,
442
+ entry.metadata?.subtype,
443
+ entry.metadata?.organism,
444
+ entry.metadata?.format,
445
+ entry.metadata?.contents,
446
+ ];
357
447
  return fields.some((field) => typeof field === "string" && field.toLowerCase().includes(needle));
358
448
  }
359
449
  /** The drill-in index: directory groups present in the scan, plus any category labels joined from metadata. */
@@ -369,7 +459,8 @@ function collectCategories(entries) {
369
459
  }
370
460
  function renderContent(path, scan, entries) {
371
461
  if (scan.state === "unavailable")
372
- return "Reference store is not mounted at /mnt/refs. Reference data cannot be downloaded from sandbox workloads; provision it through the host setup flow.";
462
+ return ("No reference store is provisioned. Nothing in the analysis environment can fetch reference data — the host stages it, " +
463
+ "so a store provisioned through the host's own path will show up on a later call.");
373
464
  if (scan.state === "not_found")
374
465
  return `Reference path does not exist: ${path}`;
375
466
  if (scan.state === "not_a_directory")
@@ -377,7 +468,7 @@ function renderContent(path, scan, entries) {
377
468
  if (scan.state === "symlink")
378
469
  return `Reference path crosses a symlink and was not scanned: ${path}`;
379
470
  if (scan.state === "empty")
380
- return `Reference store path is mounted but contains no reference files: ${path}`;
471
+ return `Reference store path exists but contains no reference files: ${path}`;
381
472
  const lines = [`Reference files visible beneath ${path}:`];
382
473
  for (const entry of entries) {
383
474
  const labels = entry.metadata
@@ -391,26 +482,36 @@ function renderContent(path, scan, entries) {
391
482
  ? `file, ${entry.bytes} bytes`
392
483
  : "symlink (not followed)";
393
484
  lines.push(`- ${entry.path} (${details}${labels ? `; ${labels}` : ""})`);
485
+ // The deciding line: whether this file is the thing you need, and how to open it.
486
+ // Without it a caller is back to inferring both from the filename.
487
+ const shape = [entry.metadata?.format, entry.metadata?.contents].filter(Boolean).join(" — ");
488
+ if (shape)
489
+ lines.push(` ${shape}`);
394
490
  }
395
491
  if (scan.truncated)
396
492
  lines.push(`Inventory truncated after scanning ${scan.scannedEntries} entries. Call list_available_refs with a narrower path to drill down.`);
397
493
  return lines.join("\n");
398
494
  }
399
- /** Create replay-safe reference discovery bound to the active sandbox. */
495
+ /** Create reference discovery over the host-visible reference store. */
400
496
  export function createListAvailableRefsTool(deps) {
401
497
  return defineTool({
402
498
  id: "list_available_refs",
403
- executionMode: "workflow",
404
- description: "Inspect the reference files actually mounted read-only at /mnt/refs — the ONLY reference data that exists here; nothing can be downloaded at runtime. " +
405
- "Returns managed and user-added files even when no manifest names them; valid receipts and legacy registry data only add labels (dataset, category, organism, subtype). Symlinks are never followed. " +
499
+ description: "Resolve reference data you need into a real path. Reports the reference data provisioned for this environment right now — the only reference data an analysis script can open. " +
500
+ "Nothing inside the analysis environment reaches the network, so never write a download into a script. " +
501
+ "Search by what the data IS, not by where you expect it: the store's directory layout is an installer detail and is not a stable interface. " +
502
+ "Catalogued files come back with the organism, the format, and the file's internal shape (key columns, identifier space), so you can pick the right reader and the right species without opening the file first. " +
503
+ "User-added files are returned too, with whatever labels exist — often none. Symlinks are never followed. " +
504
+ "Paths are reported as the analysis environment sees them, so a returned path can be used verbatim in a script. " +
406
505
  "Results are ALWAYS bounded — prefer a targeted query over a full dump: " +
506
+ '`query` is the main entry point, a case-insensitive substring filter over each entry\'s path and its dataset/title/category/organism/format/contents labels (e.g. "regulon", "hallmark", "mouse"); ' +
407
507
  "`path` inspects one directory beneath /mnt/refs (a returned path, or store-relative) — drill in with it when a listing is truncated; " +
408
- '`category` is shorthand for a top-level group (e.g. "atlas_singlecell", "msigdb"), and the groups present come back in `categories`; ' +
409
- "`query` is a case-insensitive substring filter over each entry's path and its dataset/title/category/organism/subtype labels; " +
508
+ '`category` is shorthand for a top-level group (e.g. "pathways", "regulatory-networks"), and the groups present come back in `categories`; ' +
410
509
  "`limit` caps the entries returned. The response carries `returned`, `total`, and `hasMore`, so truncation is never silent. " +
411
- "Provision additional files through the host and re-run in a sandbox with that store mounted.",
510
+ "An empty result means the dataset is absent right now — not that it is unobtainable. Say so; never guess a path, and never silently substitute a different dataset. " +
511
+ "The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path, and whatever is added shows up on a later call. " +
512
+ "So before you report a gap as permanent, check whether any tool you hold reaches that provisioning path — offer that route if one exists, and call the gap permanent only when none does.",
412
513
  inputSchema: ListAvailableRefsInputSchema,
413
- execute: async ({ path, category, query, limit }, ctx) => {
514
+ execute: async ({ path, category, query, limit }) => {
414
515
  // `category` is shorthand for a top-level path; an explicit `path` wins.
415
516
  const requestedInput = path ?? category;
416
517
  const requested = resolveRequestedPath(requestedInput);
@@ -430,33 +531,11 @@ export function createListAvailableRefsTool(deps) {
430
531
  };
431
532
  return ok(response);
432
533
  }
433
- const execId = `${deps.workflowId}:${deps.stepId}:${deps.nextFunctionId()}`;
434
- const result = await runSandboxExec({
435
- sandboxClient: deps.sandboxClient,
436
- sandbox: deps.sandbox,
437
- execId,
438
- command: [
439
- "python3",
440
- "-c",
441
- SCAN_SCRIPT,
442
- deps.scanRoot ?? REFS_ROOT,
443
- posixPath.relative(REFS_ROOT, requested.path) || ".",
444
- String(MAX_ENTRIES),
445
- String(MAX_SCANNED_ENTRIES),
446
- String(MAX_PATH_BYTES),
447
- String(MAX_ENVELOPE_BYTES),
448
- ],
449
- timeoutSeconds: 20,
450
- deadlineMs: deps.deadlineMs(),
451
- emit: ctx.emit,
452
- ...(deps.markExecActive ? { markExecActive: deps.markExecActive } : {}),
453
- });
454
- if (result.exitCode !== 0)
455
- throw new Error(`Reference inventory command failed (${result.exitCode}): ${result.stderr}`);
456
- if (Buffer.byteLength(result.stdout, "utf8") > MAX_ENVELOPE_BYTES + 1) {
457
- throw new Error("Reference inventory response exceeded its output bound");
458
- }
459
- const raw = RawScanSchema.parse(JSON.parse(result.stdout));
534
+ // A query searches the leaves; a bare listing browses this level. Without the
535
+ // recursive pass a search from the store root only ever sees directories,
536
+ // which carry no dataset labels, so "find me a regulon network" finds nothing.
537
+ const needleGiven = (query?.trim() ?? "") !== "";
538
+ const raw = await scanStore(deps.refStorePath, posixPath.relative(REFS_ROOT, requested.path) || ".", needleGiven);
460
539
  const enriched = enrichEntries(raw.entries, raw);
461
540
  const categories = collectCategories(enriched);
462
541
  const needle = query?.trim().toLowerCase();
@@ -495,166 +574,3 @@ export function createListAvailableRefsTool(deps) {
495
574
  },
496
575
  });
497
576
  }
498
- const CATEGORY_LABELS = {
499
- atlas_singlecell: "Single-cell reference atlases for label transfer (Pan-Cancer T cell — " +
500
- "Zheng 2021, Pan-Cancer Myeloid — Cheng 2021, Tumor Immune Cell Atlas — " +
501
- "Nieto 2021, Tabula Sapiens v2). Available as .h5ad (Python: anndata, " +
502
- "scanpy, scvi-tools, symphonypy) and/or .rds (R: Seurat, ProjecTILs). " +
503
- "Tabula Sapiens ships full + immune_subset variants. " +
504
- "Use these for label transfer BEFORE falling back to marker genes.",
505
- atlas_azimuth: "Azimuth references (Seurat-native): PBMC v1.0.0 and Tonsil v1.0.0. " +
506
- "Each entry is a (ref.Rds, idx.annoy) pair stored under " +
507
- "/mnt/refs/atlas_azimuth/{pbmc,tonsil}/. Load with " +
508
- "Azimuth::LoadReference(path) where path is the directory containing " +
509
- "ref.Rds + idx.annoy (e.g. '/mnt/refs/atlas_azimuth/pbmc'), then " +
510
- "Azimuth::RunAzimuth(query, reference = path).",
511
- atlas_projectils: "ProjecTILs reference atlases (Seurat .rds): human + mouse, CD8 + CD4. " +
512
- "Use ProjecTILs::ProjecTILs.classifier() to assign every CD8 to one of " +
513
- "the canonical TCF7+/GZMK+/TEX/TPEX/MAIT substates without re-deriving " +
514
- "the classifier from scratch.",
515
- celltypist_models: "Pre-staged CellTypist .pkl classifier models (Immune_All_Low backed by " +
516
- "the Cross-Tissue Immune Cell Atlas; Immune_All_High; Pan_Fetal_Human; " +
517
- "COVID19_Immune_Landscape). CELLTYPIST_FOLDER env var is preset, so " +
518
- "celltypist.annotate_cells(model='Immune_All_Low') resolves with no " +
519
- "network call.",
520
- marker_panels: "Marker gene panels: PanglaoDB (community, parquet long-format), " +
521
- "CellMarker 2.0 (tumor-context, parquet long-format), and the Cortex " +
522
- "hand-curated panel for Stage 1 substates (TCF7+ memory CD8, exhausted " +
523
- "CD8, effector-memory CD8, proliferating T, cDC1/cDC2/LAMP3+ DC, SPP1+ " +
524
- "TAM, C1Q+ TAM, Tregs) — JSON with full provenance.",
525
- gene_signatures: "Curated gene signatures from key tumor-immune papers: Sade-Feldman " +
526
- "2018 exhausted CD8 (Table S2), Jerby-Arnon 2018 malignant resistance " +
527
- "program (Table S1, Stage 3 classifier substrate), Tirosh 2016 AXL/MITF " +
528
- "programs, Puram 2017 p-EMT (Table S5, HNSCC), Reactome cGAS-STING " +
529
- "pathway (Stage 2.3 type-I IFN). JSON with PMID/DOI per entry.",
530
- normal_reference: "Normal-tissue references for safety firing checks: GTEx v8 per-tissue " +
531
- "median TPM (parquet) + sample attributes (parquet). Use to quantify " +
532
- "off-target activation of any nominated classifier across healthy tissues.",
533
- gene_mappings: "Gene ID Conversion Tables: Entrez/Ensembl/RefSeq/UniProt to symbol (NCBI); " +
534
- "orthologs (Ensembl Compara) — file: orthologs_{tax_id}.parquet, columns: " +
535
- "tax_id, ensembl_gene_id, entrez_id, symbol, relationship, other_tax_id, " +
536
- "other_ensembl_gene_id, other_entrez_id, other_symbol; symmetric — to find " +
537
- "human orthologs of Macaca fascicularis genes, open orthologs_9541.parquet " +
538
- "and filter other_tax_id == '9606'; cross-division (e.g. yeast↔mouse) " +
539
- "requires pivoting through human: yeast→human via orthologs_4896.parquet, " +
540
- "then human→mouse via orthologs_9606.parquet",
541
- omnipath: "OmniPath (interactions, regulons, annotations)",
542
- reactome: "Reactome Pathways",
543
- progeny: "PROGENy Pathway Weights (decoupler: source/target/weight/padj)",
544
- collectri: "CollecTRI TF-Target Regulons (decoupler: source/target/mor)",
545
- dorothea: "DoRothEA TF Regulons (decoupler: source/target/weight/confidence)",
546
- lincs: "LINCS L1000 Drug Perturbation Consensus Signatures (up/down gene sets)",
547
- hpa: "Human Protein Atlas (tissue RNA expression, target safety/secretome, full 107-column atlas)",
548
- wikipathways: "WikiPathways Gene Sets",
549
- msigdb: "MSigDB Gene Set Collections",
550
- safety_targets: "Curated Off-Target Safety Panel (CSV + JSON: chembl_id, gene_symbol, uniprot, organ_system, severity, clinical_consequence)",
551
- };
552
- const CATEGORY_ORDER = [
553
- "atlas_singlecell",
554
- "atlas_azimuth",
555
- "atlas_projectils",
556
- "celltypist_models",
557
- "marker_panels",
558
- "gene_signatures",
559
- "normal_reference",
560
- "safety_targets",
561
- "progeny",
562
- "collectri",
563
- "dorothea",
564
- "omnipath",
565
- "lincs",
566
- "msigdb",
567
- "wikipathways",
568
- "reactome",
569
- "hpa",
570
- "gene_mappings",
571
- ];
572
- /** The registry's categories in canonical order, unknown ones appended. */
573
- function orderedCategories(registry) {
574
- const byCategory = registry.files.by_category;
575
- const known = new Set(CATEGORY_ORDER);
576
- return [...CATEGORY_ORDER.filter((c) => byCategory[c]), ...Object.keys(byCategory).filter((c) => !known.has(c))];
577
- }
578
- /** Case-insensitive substring match over the descriptive fields the registry carries. */
579
- function matchesQuery(category, entry, needle) {
580
- const haystack = [category, entry.local_path, entry.dataset, entry.organism, entry.subtype, entry.tax_id].filter((v) => typeof v === "string" && v.length > 0);
581
- return haystack.some((field) => field.toLowerCase().includes(needle));
582
- }
583
- /** One file line: `name: /mnt/refs/<local_path> (rows, subtype, dataset)`. */
584
- function renderEntry(entry) {
585
- const path = join(REFS_ROOT, entry.local_path);
586
- const name = basename(entry.local_path);
587
- const info = [entry.rows != null ? `${entry.rows.toLocaleString()} rows` : null, entry.subtype, entry.dataset].filter(Boolean).join(", ");
588
- return ` ${name}: ${path}${info ? ` (${info})` : ""}`;
589
- }
590
- /**
591
- * Answer a refs query against a parsed registry. Pure — the tool's `execute` is
592
- * only the file read plus this call.
593
- */
594
- export function queryRefs(registry, { category, query, limit }) {
595
- const categories = orderedCategories(registry);
596
- // An unknown category is a caller mistake, not a failure — name the valid set
597
- // rather than returning an empty listing with no explanation.
598
- if (category && !categories.includes(category)) {
599
- return {
600
- available: true,
601
- total: 0,
602
- returned: 0,
603
- hasMore: false,
604
- categories,
605
- content: `Unknown category "${category}". Valid categories: ${categories.join(", ")}.`,
606
- };
607
- }
608
- const needle = query?.trim().toLowerCase();
609
- const selected = category ? [category] : categories;
610
- // Filter first so `total` is the honest count of matching files, then bound.
611
- const matched = selected.map((c) => {
612
- const entries = registry.files.by_category[c] ?? [];
613
- return { category: c, entries: needle ? entries.filter((e) => matchesQuery(c, e, needle)) : entries };
614
- });
615
- const total = matched.reduce((n, m) => n + m.entries.length, 0);
616
- if (total === 0) {
617
- const scope = [category ? `category: ${category}` : null, query ? `query: "${query}"` : null].filter(Boolean).join(", ");
618
- return {
619
- available: true,
620
- total: 0,
621
- returned: 0,
622
- hasMore: false,
623
- categories,
624
- content: `No reference files match this filter${scope ? ` (${scope})` : ""}. Nothing can be downloaded at runtime.`,
625
- };
626
- }
627
- const cap = limit ?? DEFAULT_LIMIT;
628
- const lines = [];
629
- let returned = 0;
630
- for (const { category: c, entries } of matched) {
631
- if (entries.length === 0)
632
- continue;
633
- lines.push(`\n## ${CATEGORY_LABELS[c] ?? c}`);
634
- const room = cap - returned;
635
- if (room <= 0) {
636
- lines.push(` ${entries.length} file(s) — not shown (limit reached). Call with category: "${c}" to list them.`);
637
- continue;
638
- }
639
- const shown = entries.slice(0, room);
640
- returned += shown.length;
641
- for (const entry of shown)
642
- lines.push(renderEntry(entry));
643
- const hidden = entries.length - shown.length;
644
- if (hidden > 0)
645
- lines.push(` … and ${hidden} more in this category — call with category: "${c}" (and a higher \`limit\`) to list them.`);
646
- }
647
- const header = [
648
- `Reference Store (build ${registry.build_id}, ${registry.generated_at})`,
649
- `Mount: ${REFS_ROOT}`,
650
- `Matching files: ${total} (showing ${returned})`,
651
- ].join("\n");
652
- return {
653
- available: true,
654
- total,
655
- returned,
656
- hasMore: returned < total,
657
- categories,
658
- content: header + "\n" + lines.join("\n"),
659
- };
660
- }