@inflexa-ai/harness 0.5.0 → 0.7.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.
@@ -1,239 +1,44 @@
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; nothing can be downloaded at runtime.
4
8
  *
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`.
9
+ * The store is read from the host filesystem at the embedder-supplied
10
+ * `refStorePath` and reported at `/mnt/refs`, the container path sandboxes mount
11
+ * it at — read where it lives, render where the caller will open it. Reading it
12
+ * host-side is what lets a planner ask what reference data exists before any
13
+ * sandbox is created; a step agent asking mid-run sees the same store, because
14
+ * the mount and this path are the same bytes.
15
+ *
16
+ * The scan reports paths; meaning is joined on afterwards from the install
17
+ * receipt and the catalog it names (organism, format, and each file's internal
18
+ * shape). Callers search by what the data IS, so the store's directory layout
19
+ * stays an installer detail rather than an interface anything encodes. Metadata
20
+ * only ever ENRICHES an entry — a file with no receipt and no catalog match is
21
+ * still returned, because a user-provided store is as valid as a managed one.
14
22
  */
15
- import { basename, join, posix as posixPath } from "node:path";
23
+ import { lstat, readFile, readdir } from "node:fs/promises";
24
+ import { join as hostJoin, posix as posixPath } from "node:path";
16
25
  import { ok } from "neverthrow";
17
26
  import { z } from "zod";
18
27
  import { REFERENCE_DATA_CATALOG } from "../../reference-data/catalog.js";
19
28
  import { parseReferenceInstallReceipt } from "../../reference-data/receipt.js";
20
29
  import { defineTool } from "../define-tool.js";
21
- import { runSandboxExec } from "../workspace/run-exec.js";
30
+ /** Container path the ref store is mounted at; every reported path is rendered under it. */
22
31
  const REFS_ROOT = "/mnt/refs";
23
32
  const MAX_ENTRIES = 200;
24
33
  const MAX_SCANNED_ENTRIES = 2_000;
25
34
  const MAX_PATH_BYTES = 40_000;
26
35
  const MAX_ENVELOPE_BYTES = 64_000;
27
36
  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
- `;
37
+ const MAX_RECEIPT_FILES = 100;
38
+ const MAX_RECEIPT_BYTES = 16_384;
39
+ const MAX_RECEIPT_TOTAL_BYTES = 32_768;
40
+ const MAX_REGISTRY_BYTES = 131_072;
41
+ const MAX_LEGACY_ENTRIES = 200;
237
42
  const ListAvailableRefsInputSchema = z.object({
238
43
  path: z
239
44
  .string()
@@ -257,25 +62,6 @@ const ListAvailableRefsInputSchema = z.object({
257
62
  .optional()
258
63
  .describe(`Max entries to return (default ${MAX_ENTRIES}); the response carries \`returned\`, \`total\`, and \`hasMore\` so truncation is never silent.`),
259
64
  });
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
65
  function resolveRequestedPath(input) {
280
66
  if (input === undefined || input === "" || input === REFS_ROOT)
281
67
  return { ok: true, path: REFS_ROOT };
@@ -298,6 +84,287 @@ function resolveRequestedPath(input) {
298
84
  return { ok: false, reason: "Path is outside /mnt/refs." };
299
85
  return { ok: true, path: normalized };
300
86
  }
87
+ /**
88
+ * Inspect at most `limit` names in one directory, sorted by name.
89
+ *
90
+ * Sorting BEFORE the cap (rather than capping the raw readdir order) keeps
91
+ * truncation deterministic: the same store yields the same prefix on every call,
92
+ * which a durably-cached step result has to be able to promise.
93
+ */
94
+ async function cappedEntries(directory, limit) {
95
+ let names;
96
+ try {
97
+ names = (await readdir(directory, { withFileTypes: true })).map((entry) => entry.name);
98
+ }
99
+ catch {
100
+ return { values: [], cut: false };
101
+ }
102
+ names.sort();
103
+ const cut = names.length > limit;
104
+ const values = [];
105
+ for (const name of names.slice(0, limit)) {
106
+ try {
107
+ // lstat, never stat: a symlink is reported as a symlink and never resolved,
108
+ // so a link out of the store cannot smuggle in a foreign file's size or type.
109
+ const stats = await lstat(hostJoin(directory, name));
110
+ if (stats.isSymbolicLink())
111
+ values.push({ name, kind: "symlink", bytes: 0 });
112
+ else if (stats.isFile())
113
+ values.push({ name, kind: "file", bytes: stats.size });
114
+ else if (stats.isDirectory())
115
+ values.push({ name, kind: "directory", bytes: 0 });
116
+ else
117
+ values.push({ name, kind: "other", bytes: 0 });
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ }
123
+ return { values, cut };
124
+ }
125
+ /** Whether any component of `relative` beneath `root` is a symlink — the no-follow gate. */
126
+ async function crossesSymlink(root, relative) {
127
+ let cursor = root;
128
+ for (const segment of relative.split("/")) {
129
+ cursor = hostJoin(cursor, segment);
130
+ try {
131
+ if ((await lstat(cursor)).isSymbolicLink())
132
+ return true;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+ /** Recursively total a directory's files, bounded by the remaining scan budget. */
141
+ async function summarizeDirectory(directory, scanned) {
142
+ let totalBytes = 0;
143
+ let fileCount = 0;
144
+ let truncated = false;
145
+ const fileTypes = new Set();
146
+ const stack = [directory];
147
+ while (stack.length > 0 && scanned < MAX_SCANNED_ENTRIES) {
148
+ const current = stack.pop();
149
+ const { values, cut } = await cappedEntries(current, MAX_SCANNED_ENTRIES - scanned);
150
+ truncated = truncated || cut;
151
+ const childDirectories = [];
152
+ for (const child of values) {
153
+ scanned += 1;
154
+ if (child.kind === "directory")
155
+ childDirectories.push(hostJoin(current, child.name));
156
+ else if (child.kind === "file") {
157
+ fileCount += 1;
158
+ totalBytes += child.bytes;
159
+ const suffix = posixPath.extname(child.name).toLowerCase();
160
+ if (suffix)
161
+ fileTypes.add(suffix);
162
+ }
163
+ if (scanned >= MAX_SCANNED_ENTRIES) {
164
+ truncated = true;
165
+ break;
166
+ }
167
+ }
168
+ stack.push(...childDirectories.reverse());
169
+ }
170
+ return { bytes: totalBytes, fileCount, fileTypes: [...fileTypes].sort().slice(0, 10), truncated, scanned };
171
+ }
172
+ /** Install receipts written by the host installer under `<root>/.inflexa/receipts`. */
173
+ async function readReceipts(root) {
174
+ const receiptsDirectory = hostJoin(root, ".inflexa", "receipts");
175
+ if (await crossesSymlink(root, ".inflexa"))
176
+ return [];
177
+ const { values } = await cappedEntries(receiptsDirectory, MAX_RECEIPT_FILES);
178
+ const receipts = [];
179
+ let totalBytes = 0;
180
+ for (const entry of values) {
181
+ if (entry.kind !== "file" || entry.bytes > MAX_RECEIPT_BYTES || totalBytes + entry.bytes > MAX_RECEIPT_TOTAL_BYTES)
182
+ continue;
183
+ // Per-file, so one unreadable or malformed receipt costs only its own labels.
184
+ // A shared try would abandon the loop and silently drop every receipt sorting
185
+ // after the bad one, quietly stripping metadata from datasets that are fine.
186
+ try {
187
+ receipts.push(JSON.parse(await readFile(hostJoin(receiptsDirectory, entry.name), "utf-8")));
188
+ totalBytes += entry.bytes;
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ }
194
+ return receipts;
195
+ }
196
+ /** Labels from a `registry.json` an externally-provisioned store may carry. */
197
+ async function readLegacyEntries(root) {
198
+ const legacyEntries = [];
199
+ try {
200
+ if (await crossesSymlink(root, "registry.json"))
201
+ return [];
202
+ const registryPath = hostJoin(root, "registry.json");
203
+ if ((await lstat(registryPath)).size > MAX_REGISTRY_BYTES)
204
+ return [];
205
+ const registry = JSON.parse(await readFile(registryPath, "utf-8"));
206
+ if (!registry || typeof registry !== "object")
207
+ return [];
208
+ const byCategory = registry.files?.by_category;
209
+ if (!byCategory || typeof byCategory !== "object")
210
+ return [];
211
+ for (const category of Object.keys(byCategory).sort()) {
212
+ const values = byCategory[category];
213
+ if (!Array.isArray(values))
214
+ continue;
215
+ for (const item of values) {
216
+ if (legacyEntries.length >= MAX_LEGACY_ENTRIES)
217
+ break;
218
+ if (item && typeof item === "object" && typeof item.local_path === "string") {
219
+ legacyEntries.push({ ...item, category });
220
+ }
221
+ }
222
+ }
223
+ }
224
+ catch {
225
+ return legacyEntries;
226
+ }
227
+ return legacyEntries;
228
+ }
229
+ /**
230
+ * Collect FILES anywhere beneath `directory`, bounded by the scan budget.
231
+ *
232
+ * Searching by meaning only works if it reaches the files that carry meaning. A
233
+ * dataset's labels are joined onto its artifact at
234
+ * `managed/{id}/{version}/{artifact}`, so a listing of the store root — whose only
235
+ * entries are directories, which carry no such labels — can never match "regulon".
236
+ * Browsing wants the directory summary; searching wants the leaves.
237
+ */
238
+ async function collectFiles(directory, relative, budget) {
239
+ const entries = [];
240
+ let truncated = false;
241
+ const stack = [{ path: directory, relative }];
242
+ while (stack.length > 0 && budget.scanned < MAX_SCANNED_ENTRIES) {
243
+ const current = stack.pop();
244
+ const { values, cut } = await cappedEntries(current.path, MAX_SCANNED_ENTRIES - budget.scanned);
245
+ truncated = truncated || cut;
246
+ for (const child of values) {
247
+ budget.scanned += 1;
248
+ const childRelative = current.relative === "." ? child.name : `${current.relative}/${child.name}`;
249
+ // Installer metadata is not reference data — it never becomes a search hit.
250
+ if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
251
+ continue;
252
+ if (child.kind === "directory") {
253
+ stack.push({ path: hostJoin(current.path, child.name), relative: childRelative });
254
+ }
255
+ else if (child.kind === "file" || child.kind === "symlink") {
256
+ if (entries.length >= MAX_ENTRIES) {
257
+ truncated = true;
258
+ }
259
+ else {
260
+ entries.push(child.kind === "symlink"
261
+ ? { path: `${REFS_ROOT}/${childRelative}`, kind: "symlink", bytes: 0 }
262
+ : { path: `${REFS_ROOT}/${childRelative}`, kind: "file", bytes: child.bytes });
263
+ }
264
+ }
265
+ if (budget.scanned >= MAX_SCANNED_ENTRIES) {
266
+ truncated = true;
267
+ break;
268
+ }
269
+ }
270
+ }
271
+ return { entries, truncated };
272
+ }
273
+ /**
274
+ * Scan the store beneath `root`, reporting every expected state as data.
275
+ *
276
+ * `recursive` selects the two modes: a browse lists immediate children (directories
277
+ * summarized), a search walks to the files, because only a file carries the labels a
278
+ * search matches on.
279
+ */
280
+ async function scanStore(root, relative, recursive = false) {
281
+ const empty = () => ({ entries: [], scannedEntries: 0, truncated: false });
282
+ if (!root)
283
+ return { state: "unavailable", ...empty() };
284
+ try {
285
+ if (!(await lstat(root)).isDirectory())
286
+ return { state: "unavailable", ...empty() };
287
+ }
288
+ catch {
289
+ return { state: "unavailable", ...empty() };
290
+ }
291
+ if (relative !== "." && (await crossesSymlink(root, relative)))
292
+ return { state: "symlink", ...empty() };
293
+ const target = relative === "." ? root : hostJoin(root, ...relative.split("/"));
294
+ let targetStats;
295
+ try {
296
+ targetStats = await lstat(target);
297
+ }
298
+ catch {
299
+ return { state: "not_found", ...empty() };
300
+ }
301
+ if (!targetStats.isDirectory())
302
+ return { state: "not_a_directory", ...empty() };
303
+ if (recursive) {
304
+ const budget = { scanned: 0 };
305
+ const walked = await collectFiles(target, relative, budget);
306
+ return {
307
+ state: walked.entries.length === 0 ? "empty" : "populated",
308
+ entries: walked.entries,
309
+ scannedEntries: budget.scanned,
310
+ truncated: walked.truncated,
311
+ receipts: await readReceipts(root),
312
+ legacyEntries: await readLegacyEntries(root),
313
+ };
314
+ }
315
+ const entries = [];
316
+ let scanned = 0;
317
+ let pathBytes = 0;
318
+ let truncated = false;
319
+ const { values: children, cut } = await cappedEntries(target, Math.min(MAX_ENTRIES + 2, MAX_SCANNED_ENTRIES));
320
+ truncated = cut;
321
+ for (const child of children) {
322
+ const childRelative = relative === "." ? child.name : `${relative}/${child.name}`;
323
+ if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
324
+ continue;
325
+ scanned += 1;
326
+ const rendered = `${REFS_ROOT}/${childRelative}`;
327
+ let entry;
328
+ if (child.kind === "symlink")
329
+ entry = { path: rendered, kind: "symlink", bytes: 0 };
330
+ else if (child.kind === "file")
331
+ entry = { path: rendered, kind: "file", bytes: child.bytes };
332
+ else if (child.kind === "directory") {
333
+ const summary = await summarizeDirectory(hostJoin(target, child.name), scanned);
334
+ scanned = summary.scanned;
335
+ truncated = truncated || summary.truncated;
336
+ entry = {
337
+ path: rendered,
338
+ kind: "directory",
339
+ bytes: summary.bytes,
340
+ fileCount: summary.fileCount,
341
+ fileTypes: summary.fileTypes,
342
+ truncated: summary.truncated,
343
+ };
344
+ }
345
+ if (entry) {
346
+ if (entries.length < MAX_ENTRIES && pathBytes + Buffer.byteLength(rendered, "utf8") <= MAX_PATH_BYTES) {
347
+ entries.push(entry);
348
+ pathBytes += Buffer.byteLength(rendered, "utf8");
349
+ }
350
+ else {
351
+ truncated = true;
352
+ }
353
+ }
354
+ if (scanned >= MAX_SCANNED_ENTRIES) {
355
+ truncated = true;
356
+ break;
357
+ }
358
+ }
359
+ return {
360
+ state: entries.length === 0 ? "empty" : "populated",
361
+ entries,
362
+ scannedEntries: scanned,
363
+ truncated,
364
+ receipts: await readReceipts(root),
365
+ legacyEntries: await readLegacyEntries(root),
366
+ };
367
+ }
301
368
  function stringValue(value) {
302
369
  return typeof value === "string" && value.length > 0 ? value : undefined;
303
370
  }
@@ -312,6 +379,9 @@ function enrichEntries(entries, raw) {
312
379
  continue;
313
380
  const dataset = REFERENCE_DATA_CATALOG.datasets.find((candidate) => candidate.id === receipt.datasetId && candidate.version === receipt.datasetVersion);
314
381
  for (const artifact of receipt.artifacts) {
382
+ // Per-artifact, not per-dataset: format and contents differ between files of the
383
+ // same dataset (a Seurat object and its Annoy index are read nothing alike).
384
+ const catalogArtifact = dataset?.artifacts.find((candidate) => candidate.path === artifact.path);
315
385
  metadata.set(`${REFS_ROOT}/managed/${receipt.datasetId}/${receipt.datasetVersion}/${artifact.path}`, {
316
386
  datasetId: receipt.datasetId,
317
387
  version: receipt.datasetVersion,
@@ -321,8 +391,11 @@ function enrichEntries(entries, raw) {
321
391
  description: dataset.description,
322
392
  sourceUrl: dataset.sourceUrl,
323
393
  license: dataset.license.url ?? dataset.license.identifier,
394
+ category: dataset.recommendation.group,
395
+ ...(dataset.organism ? { organism: dataset.organism } : {}),
324
396
  }
325
397
  : {}),
398
+ ...(catalogArtifact ? { format: catalogArtifact.format, contents: catalogArtifact.contents } : {}),
326
399
  });
327
400
  }
328
401
  }
@@ -351,9 +424,23 @@ function enrichEntries(entries, raw) {
351
424
  return extra ? { ...entry, metadata: extra } : entry;
352
425
  });
353
426
  }
354
- /** Case-insensitive substring match over an inventory entry's path and joined metadata labels. */
427
+ /**
428
+ * Case-insensitive substring match over an inventory entry's path and joined metadata labels.
429
+ * `description` and `contents` are in scope deliberately: a caller searching for what the data
430
+ * IS ("regulon", "tf-target") should hit a file whose name says only "CollecTRI".
431
+ */
355
432
  function entryMatchesQuery(entry, needle) {
356
- const fields = [entry.path, entry.metadata?.datasetId, entry.metadata?.title, entry.metadata?.category, entry.metadata?.subtype, entry.metadata?.organism];
433
+ const fields = [
434
+ entry.path,
435
+ entry.metadata?.datasetId,
436
+ entry.metadata?.title,
437
+ entry.metadata?.description,
438
+ entry.metadata?.category,
439
+ entry.metadata?.subtype,
440
+ entry.metadata?.organism,
441
+ entry.metadata?.format,
442
+ entry.metadata?.contents,
443
+ ];
357
444
  return fields.some((field) => typeof field === "string" && field.toLowerCase().includes(needle));
358
445
  }
359
446
  /** The drill-in index: directory groups present in the scan, plus any category labels joined from metadata. */
@@ -369,7 +456,7 @@ function collectCategories(entries) {
369
456
  }
370
457
  function renderContent(path, scan, entries) {
371
458
  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.";
459
+ return "No reference store is provisioned. Reference data cannot be downloaded at runtime; provision it through the host setup flow.";
373
460
  if (scan.state === "not_found")
374
461
  return `Reference path does not exist: ${path}`;
375
462
  if (scan.state === "not_a_directory")
@@ -377,7 +464,7 @@ function renderContent(path, scan, entries) {
377
464
  if (scan.state === "symlink")
378
465
  return `Reference path crosses a symlink and was not scanned: ${path}`;
379
466
  if (scan.state === "empty")
380
- return `Reference store path is mounted but contains no reference files: ${path}`;
467
+ return `Reference store path exists but contains no reference files: ${path}`;
381
468
  const lines = [`Reference files visible beneath ${path}:`];
382
469
  for (const entry of entries) {
383
470
  const labels = entry.metadata
@@ -391,26 +478,33 @@ function renderContent(path, scan, entries) {
391
478
  ? `file, ${entry.bytes} bytes`
392
479
  : "symlink (not followed)";
393
480
  lines.push(`- ${entry.path} (${details}${labels ? `; ${labels}` : ""})`);
481
+ // The deciding line: whether this file is the thing you need, and how to open it.
482
+ // Without it a caller is back to inferring both from the filename.
483
+ const shape = [entry.metadata?.format, entry.metadata?.contents].filter(Boolean).join(" — ");
484
+ if (shape)
485
+ lines.push(` ${shape}`);
394
486
  }
395
487
  if (scan.truncated)
396
488
  lines.push(`Inventory truncated after scanning ${scan.scannedEntries} entries. Call list_available_refs with a narrower path to drill down.`);
397
489
  return lines.join("\n");
398
490
  }
399
- /** Create replay-safe reference discovery bound to the active sandbox. */
491
+ /** Create reference discovery over the host-visible reference store. */
400
492
  export function createListAvailableRefsTool(deps) {
401
493
  return defineTool({
402
494
  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. " +
495
+ description: "Resolve reference data you need into a real path. Reports the reference data actually provisioned for this environment — the ONLY reference data that exists here; nothing can be downloaded at runtime. " +
496
+ "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. " +
497
+ "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. " +
498
+ "User-added files are returned too, with whatever labels exist — often none. Symlinks are never followed. " +
499
+ "Paths are reported as the analysis environment sees them, so a returned path can be used verbatim in a script. " +
406
500
  "Results are ALWAYS bounded — prefer a targeted query over a full dump: " +
501
+ '`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
502
  "`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; " +
503
+ '`category` is shorthand for a top-level group (e.g. "pathways", "regulatory-networks"), and the groups present come back in `categories`; ' +
410
504
  "`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.",
505
+ "An empty result means the data is genuinely absent — say so and proceed with what you have; do not guess a path. Provision additional files through the host setup flow.",
412
506
  inputSchema: ListAvailableRefsInputSchema,
413
- execute: async ({ path, category, query, limit }, ctx) => {
507
+ execute: async ({ path, category, query, limit }) => {
414
508
  // `category` is shorthand for a top-level path; an explicit `path` wins.
415
509
  const requestedInput = path ?? category;
416
510
  const requested = resolveRequestedPath(requestedInput);
@@ -430,33 +524,11 @@ export function createListAvailableRefsTool(deps) {
430
524
  };
431
525
  return ok(response);
432
526
  }
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));
527
+ // A query searches the leaves; a bare listing browses this level. Without the
528
+ // recursive pass a search from the store root only ever sees directories,
529
+ // which carry no dataset labels, so "find me a regulon network" finds nothing.
530
+ const needleGiven = (query?.trim() ?? "") !== "";
531
+ const raw = await scanStore(deps.refStorePath, posixPath.relative(REFS_ROOT, requested.path) || ".", needleGiven);
460
532
  const enriched = enrichEntries(raw.entries, raw);
461
533
  const categories = collectCategories(enriched);
462
534
  const needle = query?.trim().toLowerCase();
@@ -495,166 +567,3 @@ export function createListAvailableRefsTool(deps) {
495
567
  },
496
568
  });
497
569
  }
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
- }