UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

319 lines • 13 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { classify } from "../../../errors/classify.js"; import { YieldBudget } from "../../../io/yield_budget.js"; import { isEnotdir, isFsError } from "../../../errors/fs.js"; import { WalkLimitError } from "../../../errors/types.js"; import { FileStat, FileType, PathSpec } from "../../../types.js"; import { compareCodePoints } from "../../../utils/sort.js"; import { UsageError } from "../../errors.js"; // The conditions under which a guard's path reaches no directory to walk. const NO_DIRECTORY = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']); /** * Let the mount that owns `path` refuse a command about to read every file * under it. The mount is asked at the resolved path: the command already * judged the spelling it was typed in, and reports a path that reaches no * directory (missing, not one, a link loop) itself. The mount refuses by * rejecting, which becomes the command's own error, `command: message` and * exit 1, on one mount or across several; any other filesystem error stays * one. Mirrors Python's `before_full_walk`. */ export async function beforeFullWalk(dispatch, path, command) { if (dispatch === undefined) return; try { await dispatch('before_full_walk', PathSpec.fromStrPath(path.virtual)); } catch (err) { const condition = classify(err); if (condition !== null && NO_DIRECTORY.has(condition)) { console.debug(`walk guard at ${path.virtual}: ${String(err)}`); return; } if (err instanceof UsageError || isFsError(err) || !(err instanceof Error)) throw err; throw new UsageError(`${command}: ${err.message}`, 1); } } /** * The entries one walk of an operand may still list. * * A walk lists at most `walkLimit` entries of each mount it crosses: an * entry is charged to the mount serving the directory that lists it, counted * under the operand, or under the mount's root where that mount starts below * the operand. Once a count runs out the walk lists nothing more there, and * `stopped` holds what the command says about it, one line per folder, * without the command's name, in the order the walk met them. Mirrors * Python's `WalkBudget`. */ export class WalkBudget { operand; mounts; spent = new Map(); stopped = []; constructor(operand, mounts) { this.operand = operand; this.mounts = mounts; } limitAt(directory) { return this.mounts?.walkLimit?.(directory) ?? -1; } folderOf(directory) { const top = this.operand.virtual.replace(/\/+$/, ''); const root = (this.mounts?.rootOf(directory) ?? '/').replace(/\/+$/, ''); if (root.startsWith(`${top}/`)) return root; return top === '' ? '/' : top; } stop(folder, limit) { const top = this.operand.virtual.replace(/\/+$/, '') || '/'; const at = folder === top ? this.operand : PathSpec.fromStrPath(folder); const line = new WalkLimitError(at, limit).message; if (!this.stopped.includes(line)) this.stopped.push(line); } /** * Charge one entry `directory` lists, or the one file a search reads when * `directory` is that file: true if the walk may keep it, false once its * folder's count is out, which records the stop. */ spend(directory) { const limit = this.limitAt(directory); if (limit < 0) return true; const folder = this.folderOf(directory); const used = this.spent.get(folder) ?? 0; if (used < limit) { this.spent.set(folder, used + 1); return true; } this.stop(folder, limit); return false; } /** * Whether the walk may still list `directory`; a folder whose count is out * records the stop and is not listed at all. */ opens(directory) { const limit = this.limitAt(directory); if (limit < 0) return true; const folder = this.folderOf(directory); if ((this.spent.get(folder) ?? 0) < limit) return true; this.stop(folder, limit); return false; } /** * The `limit` a listing of `directory` hands its backend: one entry past * what the walk may still keep there, so a backend that stops at it still * leaves the walk the entry that records its stop; null when nothing caps * the walk. */ listingLimit(directory) { const limit = this.limitAt(directory); if (limit < 0) return null; const used = this.spent.get(this.folderOf(directory)) ?? 0; return Math.max(limit - used, 0) + 1; } /** * `readdir` of `directory`, cut to what the walk may still list; the * backend is handed the `limit` past which it may stop. */ async listing(readdir, directory) { if (!this.opens(directory.virtual)) return []; const limit = this.listingLimit(directory.virtual); const names = await (limit === null ? readdir(directory) : readdir(directory, false, limit)); return names.filter(() => this.spend(directory.virtual)); } } // Each directory's children in name order, keyed by its path below the walk's // root ('' for the root itself). A directory a deeper key implies is added to // `found` as a bare one. Mirrors Python's `_listings`. function listings(found) { const children = new Map([['', new Set()]]); for (const [key, st] of found) { const parts = key.split('/'); for (let depth = 1; depth <= parts.length; depth++) { const parent = parts.slice(0, depth - 1).join('/'); const rel = parts.slice(0, depth).join('/'); let set = children.get(parent); if (set === undefined) { set = new Set(); children.set(parent, set); } set.add(rel); } if (st.type === FileType.DIRECTORY && !children.has(key)) children.set(key, new Set()); } for (const rel of children.keys()) { if (rel !== '' && !found.has(rel)) { found.set(rel, new FileStat({ name: rel.split('/').pop() ?? rel, type: FileType.DIRECTORY })); } } const out = new Map(); for (const [rel, names] of children) { out.set(rel, [...names].sort(compareCodePoints)); } return out; } /** * Whether an earlier answer may stand for `path`. A spelling with dots or a * trailing slash (`dotted`) or one the walk refused (`walkError`) goes to the * call, so the dispatcher checks it. Mirrors Python's `_answerable`. */ function answerable(path) { return path.dotted === null && path.walkError === null; } /** * One level of `path`: each child's stat by its name, and the listing, in the * order the answer gives it; null where the read gives way to the walk's own * listing. A name only an entry below implies (a link's parent the namespace * holds) is listed with no row, so its stat asks the dispatcher. Mirrors * Python's `_level`. */ async function level(readTree, path, limit) { let found; try { found = await readTree(path, limit, true); } catch (err) { if (err.declined === true || isEnotdir(err)) return null; if (err.code !== 'ENOTSUP') throw err; console.debug(`no level read of ${path.virtual}: ${String(err)}`); return null; } if (found === null) return null; const rows = new Map(); const names = new Set(); for (const [key, row] of found) { const name = key.split('/', 1)[0] ?? key; names.add(name); if (name === key) rows.set(name, row); } return [rows, [...names]]; } /** * A walk's `readdir` and `stat`, answered from the mount's `readTree`. * * The first `readdir` of a directory no answer covers asks `readTree` for * everything under it, and every later `readdir` and `stat` below it answers * from that, so a walk costs one call. An answer of null, a mount without * `readTree`, or a dispatcher that declines it (a path rule below the * directory) leaves the walk to `readdir` and `stat`, each judged on its own, * as does a path the answer does not hold; under a hide the dispatcher * answers without the hidden entries. A subtree read stops at a link * (ENOTDIR), so a directory a walk reached through one is opened by * `readdir`, which follows it as opendir(3) does and meets whatever refuses * the directory it leads to. Both take the path first and pass the rest * through; a listing's `limit` reaches `readTree` too, which may then answer * null for a subtree past it. * * A directory no whole answer covers is read one level at a time * (`readTree(path, limit, true)`): its listing and each child's stat in one * call, so a walk the mount reads no subtree for, or one a depth bounds, * stats no child on its own either. A listing a limit cuts is read again * under the next one, as `readdir` would be. `whole` false reads levels only, * for a walk a depth bounds or a single listing. Mirrors Python's * `tree_walk`. * * An answer from the tree resolves in a microtask, so the walk spends a * yield budget under `signal` (the line's abort and deadline) and stops once * it fires. */ export function treeWalk(readTree, readdir, stat, whole = true, signal) { if (readTree === undefined) return [readdir, stat]; const budget = new YieldBudget(signal); const roots = new Map(); const levels = new Map(); const asked = new Set(); const covering = (virtual) => { for (const root of roots.keys()) { if (virtual === root) return [root, '']; const base = root.replace(/\/+$/, '') + '/'; if (virtual.startsWith(base)) return [root, virtual.slice(base.length)]; } return null; }; const treeReaddir = async (path, nofollow, limit) => { await budget.run(); const virtual = path.virtual.replace(/\/+$/, '') || '/'; let hit = answerable(path) ? covering(virtual) : null; if (hit === null && whole && !asked.has(virtual) && path.pattern === null) { asked.add(virtual); let found; try { found = await readTree(path, limit); } catch (err) { if (err.declined !== true && !isEnotdir(err)) throw err; found = null; } if (found !== null) { const copy = new Map(found); roots.set(virtual, [copy, listings(copy)]); hit = [virtual, '']; } } if (hit !== null) { const [root, rel] = hit; const listing = roots.get(root)?.[1].get(rel); if (listing !== undefined) { const base = root.replace(/\/+$/, '') + '/'; return listing.map((name) => base + name); } } else if (answerable(path) && path.pattern === null) { if (limit !== undefined || !levels.has(virtual)) { levels.set(virtual, await level(readTree, path, limit)); } const answer = levels.get(virtual); if (answer != null) { const base = virtual.replace(/\/+$/, '') + '/'; return answer[1].map((name) => base + name); } } return readdir(path, nofollow, limit); }; const treeStat = async (path, nofollow) => { await budget.run(); const virtual = path.virtual.replace(/\/+$/, '') || '/'; const hit = answerable(path) ? covering(virtual) : null; if (hit !== null && hit[1] !== '') { const found = roots.get(hit[0])?.[0].get(hit[1]); if (found !== undefined) return found; } if (answerable(path)) { const cut = virtual.lastIndexOf('/'); const row = levels.get(virtual.slice(0, cut) || '/')?.[0].get(virtual.slice(cut + 1)); if (row !== undefined) return row; } return stat(path, nofollow); }; return [treeReaddir, treeStat]; } //# sourceMappingURL=tree.js.map