UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

455 lines • 21.3 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 { currentCaller, sessionVisibility } from "../../../context/session_context.js"; import { eacces, eisdir, enoent, isDotWalkError, isEnoent } from "../../../errors/fs.js"; import { ensureStream } from "../../../io/stream.js"; import { revisionFor } from "../../../observe/context.js"; import { FileStat, FileType } from "../../../types.js"; import { hasAborted, makeAbortError } from "../../../utils/abort.js"; import { makeResolveGlob } from "../../../utils/glob_walk.js"; import { moveReveals } from "../../../utils/hidden.js"; import { CycleError } from "../../../utils/path.js"; import { guardInput } from "../utils/limit.js"; import { crossesMounts, followOf, linkLocation, linksOf, mountRoot, mountView, mountsOf, owedNames, } from "../utils/links.js"; import { isImplicitDir } from "../utils/paths.js"; import { callerStream } from "./dispatch.js"; const READINGS = new WeakMap(); /** * `ctx` as the factory hands it to a builder, its files read as `reading` * says and under the command tier's guards. Mirrors Python's `with_reading`. */ export function withReading(ctx, reading) { const bound = { ...ctx }; READINGS.set(bound, reading); return bound; } // The writes a run across mounts makes at the dispatcher, whichever mount // serves the path; of the other functions it takes only `readTree`'s // shortcut, where no mount sits below the subtree. const CROSSING_CALLS = new Set([ 'write', 'pwrite', 'unlink', 'mkdir', 'truncate', ]); // How many operands a whole read warms ahead of itself (`readAhead`). const READ_AHEAD = 8; /** * What a command's whole read of an operand awaits first, and what learns the * operands its globs resolve to. * * A command reads its operands in turn, each whole once the one before is * done. On a whole read of one, the next READ_AHEAD go to the dispatcher as * one `read_many`, which keeps the small files among them in the read cache * (one request where the store reads a batch), and the batch after goes once * half of that one is read. A read whose file is still being warmed waits for * it, so it is served warm instead of fetching the file again. No bytes are * held here: a write in between drops the cached copy as it drops any. A * path that is not an operand warms nothing. Mirrors Python's `read_ahead`. */ export function readAhead(dispatch, paths) { const operands = []; const order = new Map(); const warming = new Map(); let sent = 1; const noted = (found) => { for (const path of found) { if (path.pattern === null && !order.has(path.virtual)) { order.set(path.virtual, operands.length); operands.push(path); } } }; const warmed = async (path) => { const at = order.get(path.virtual); if (at === undefined) return; const start = Math.max(sent, at + 1); if (start < operands.length && start <= at + READ_AHEAD / 2) { const batch = operands.slice(start, start + READ_AHEAD); sent = start + batch.length; const task = warm(dispatch, batch); for (const p of batch) warming.set(p.virtual, task); } await warming.get(path.virtual); }; noted(paths); return [warmed, noted]; } /** * Ask the dispatcher to keep `paths` in the read cache. A failure is logged * and dropped: each read that follows meets it. Mirrors Python's `_warm`. */ async function warm(dispatch, paths) { const [first, ...rest] = paths; if (first === undefined) return; try { await dispatch('read_many', first, [], { paths: rest }); } catch (err) { console.debug(`read-ahead from ${first.virtual} failed: ${String(err)}`); } } /** * Send one call to the dispatcher and hand back its answer: the one way a * command's file call, a cross-mount run and a relayed command reach a mount. * Mirrors Python's `dispatched_call`. */ export async function dispatchedCall(dispatch, name, path, args = [], kwargs = {}) { const [result] = await dispatch(name, path, args, kwargs); return result; } /** * The `readTree` a walk through the dispatcher takes, asked only where the * mount a subtree lands on answers one; elsewhere null, and the walk lists * instead. * * The serving mount's answer is no guide: a walk through the dispatcher also * reaches the mounts below it and those a link above the subtree leads to, so * a subtree on one that answers no read_tree (a disk mount under the root RAM) * is walked rather than refused with ENOTSUP. A walk across mounts * (`crossing`) asks only where no mount sits below the subtree either: the * serving backend's answer holds keys a mount there shadows, and lacks the * mount's own. A subtree read stops at a link at the final name (ENOTDIR), so * a walk that follows links lists them directory by directory instead. One * level (`level`) is the listing a `readdir` answers with each child's stat, * which every mount that keeps listings answers, asked only where no mount * sits below, whose root the listing would name. * `mounts` is the boundary view a walk that does not cross them sees * (`mountView(files)`), undefined to take no shortcut; `landing` where a path lands, every link above its * final name followed (`linkLocation`, `NamespaceLinks.followParent`), null * for a walk whose paths name their own place. Mirrors Python's * `dispatched_read_tree`. */ export function dispatchedReadTree(dispatch, mounts, landing, crossing) { return async (path, limit, level = false) => { let at; try { at = landing === null ? path.virtual : landing(path.virtual); } catch (err) { if (!(err instanceof CycleError)) throw err; console.debug(`following the links above ${path.virtual} failed: ${String(err)}`); return null; } if (mounts === undefined || (!level && !mounts.answers(at, 'read_tree')) || ((crossing || level) && mounts.descendants(at).length > 0)) return null; return (await dispatchedCall(dispatch, 'read_tree', path, [], { nofollow: true, ...(limit === undefined ? {} : { limit }), ...(level ? { level: true } : {}), })); }; } /** A command's streamed read of the stored bytes, or a window of them, at the * dispatcher, opened at the first pull as a backend stream is. */ async function* dispatchedStream(dispatch, path, ahead, offset = 0, size = null) { if (ahead !== undefined) await ahead(path); const [data] = await dispatch('read', path, [], { stream: true, filetype: null, offset, size }); yield* ensureStream(data); } /** * A command's stat of the entry at `path`, at the dispatcher: the entry * itself, as lstat answers, so a link at the final name is the link and a * trailing slash reaches what it names. The dispatcher merges the * namespace's overlay into the row. Mirrors Python's `_dispatched_stat`. */ async function dispatchedStat(dispatch, path) { const [found] = await dispatch('stat', path, [], { nofollow: true }); if (!(found instanceof FileStat)) throw enoent(path); return found; } /** * Whether an entry is at `path`, as `dispatchedStat` answers. Mirrors * Python's `_dispatched_exists`. */ async function dispatchedExists(dispatch, path) { try { await dispatchedStat(dispatch, path); } catch (err) { if (!isEnoent(err)) throw err; console.debug(`nothing at ${path.virtual}: ${String(err)}`); return false; } return true; } /** `fn`, refused with the line's abort instead of started once `signal` has fired. */ function refusedAfterAbort(signal, fn) { if (signal === undefined) return fn; return (...args) => (hasAborted(signal) ? Promise.reject(makeAbortError(signal)) : fn(...args)); } // Whether a path no backend knows is a directory the namespace owns: the // third way a read operand can be a directory, after the explicit stat row // and the implicit keyed-backend prefix. A directory that exists only // because a mount or a link sits under it (`/repos` when `/repos/alpha` is // mounted) belongs to no backend at all, so the mount this command runs on // can neither stat it nor list it, and every read command reported it // missing while stat, file, ls, du, find and tree all called it a directory. // // The names the namespace owes the path, not a dispatched stat. Both answer // for a mount parent, but a dispatched stat also answers from a backend's own // listing, and a backend that answers a path it does not hold with entries // rather than a miss turns every such path into a directory: postgres reads // any first segment as a schema and lists `tables` and `views` under it, so // `cat /pg/nope.txt` refused a directory that is not there. The namespace // cannot over-claim that way, because it derives a segment only from a mount // prefix or a link path it actually holds, and it is the same authority // `namespaceListing` gates on, so the listing and this refusal cannot // disagree. It is hide-filtered for free, which is what keeps the parent of a // mount the session may not be told about reading as absence. function isNamespaceDir(ctx, p) { return owedNames(ctx.vfs, p.virtual).length > 0; } // The one place the read family decides what a directory is, shared by the // stat and the stream chokepoints so the two cannot drift. async function statRefusingDirs(files, ctx, p) { let st; try { st = await files.stat(p); } catch (e) { if (e.code !== 'ENOENT' || isDotWalkError(e)) throw e; if (await isImplicitDir(files.readdir, p)) throw eisdir(p); if (isNamespaceDir(ctx, p)) throw eisdir(p); throw e; } if (st.type === FileType.DIRECTORY) throw eisdir(p); return st; } // Stat for the read-family chokepoint (`splitReadable`): a directory operand // fails with EISDIR instead of succeeding (explicit, via the stat type) or // failing with ENOENT (implicit keyed-backend directory via a readdir probe, // or a namespace-only mount parent via the name plane), so cat/head/tail // report GNU's `Is a directory` and keep the remaining operands (#457). // Mirrors Python's `dir_aware_stat`. export function dirAwareStat(files, ctx) { return (p) => statRefusingDirs(files, ctx, p); } /** * A regular file's size from its stat, for a command whose answer is the size * alone (`wc -c`), as GNU takes it from `st_size`. Null reads the file * instead: for a file the read cache holds, which is read from there as the * read it replaces would be; for anything but a regular file (a device or a * pipe has no size to trust); for a size the stat does not know; for a path * pinned to an older revision, whose size the stat does not give; and for a * store that keeps permission bits (a disk, SFTP), where a file the stat sees * may still refuse its read, and a disk's named pipe stats as a file. A store * without them grants the read with the stat. A directory fails with EISDIR * as `dirAwareStat` has it. A listing the index holds can be up to its TTL * old, so the size is checked against a stat that asks the backend now * (`fresh`), and a file whose size moved since the listing is read: whether * that read serves the listed version (a GitHub blob the listing named) or the * current one, it is what the command counts. Mirrors Python's `file_size`. */ export function fileSize(files, ctx, opened = false) { return async (p) => { if (READINGS.get(ctx)?.stream != null || (opened && crossesMounts(ctx))) return null; if (revisionFor(p.virtual) !== null) return null; if (await files.isWarm(p)) return null; const st = await statRefusingDirs(files, ctx, p); if (st.type !== FileType.FILE || st.size === null || (st.mode !== null && (!opened || st.size === 0))) return null; if (ctx.vfs === undefined) return st.size; const [now] = await ctx.vfs.dispatch('stat', p, [], { fresh: true }); return now instanceof FileStat && now.size === st.size ? st.size : null; }; } async function* streamRefusingDirs(files, ctx, p) { await statRefusingDirs(files, ctx, p); yield* files.read(p); } // The per-operand read stream, `read(path)`, the read family takes: // the operand is stat'ed first so a directory fails with EISDIR before any // backend read runs (sftp reads of a directory raise an opaque `Failure`, // not ENOENT), and an ENOENT for an implicit keyed-backend directory or a // namespace-only mount parent is refined the same way `dirAwareStat` does, // before the generic formats the stderr line (#457). Mirrors Python's // `dir_aware_stream`. export function dirAwareStream(files, ctx) { return (p) => streamRefusingDirs(files, ctx, p); } /** * Expand glob operands, or [] when there is nothing to resolve: no operand * resolves to no paths, which the generics read as stdin mode. Operand * semantics (report-and-continue, directory refusal) live in the generics * via `splitReadable`; this is wiring only. Mirrors Python's * `resolve_or_empty`. */ export async function resolveOrEmpty(files, paths) { return paths.length > 0 ? files.resolveGlob(paths) : []; } /** Refuse a relocation that would surface a hidden path. * * A rename or a native directory copy re-anchors everything below its * source, and a hide's coverage does not move with the content, so * hidden bytes would land at paths the session can see. EACCES on the * source, which mv and cp render in GNU's permission-denied voice. * Only a directory has anything below it to re-anchor, so callers * check this for a source they know is a directory and skip it for a * file. Mirrors Python's `refuse_reveal`. */ export function refuseReveal(src, dst, access) { if (moveReveals(sessionVisibility(access), src.virtual, dst.virtual)) throw eacces(src.virtual); } /** * The files of the command `ctx` runs, its operands `paths`. * * Every call goes to the dispatcher through `ctx.vfs`, which refuses a read * of a directory and a write to a slash-terminated name and fills a stat's * unknown size from the read cache. A command on one mount reads the stored * bytes (`filetype: null`), its whole reads warmed ahead of themselves * (`readAhead`), and, as the factory hands it on (`withReading`), under the * command tier's guards: no call started once the line's signal has fired, * each operand's read cut into pieces under its abort and deadline. A run * across mounts (`crossesMounts`) reads each operand as the * command that asked for it, through the path's renderer, after a stat that * refuses a directory, and lists with no boundary to stop at. Throws without * `ctx.vfs`: a builder outside a workspace has no files to read. Mirrors * Python's `command_files`. */ export function commandFiles(ctx, paths = []) { const vfs = ctx.vfs; if (vfs === undefined) throw new TypeError(`${ctx.spec?.name ?? ''}: no workspace files`); const dispatch = vfs.dispatch; const crossing = crossesMounts(ctx); const reading = crossing ? undefined : READINGS.get(ctx); const signal = reading === undefined ? undefined : ctx.signal; // What the mount can do is the serving mount's to say: an option can route // the command to it with no operand there (`shuf -i 1-1 -o /ram/out` typed // on another mount). const home = mountRoot(ctx); const anchor = `${home}/`; const mounts = mountsOf(ctx); const [ahead, noted] = readAhead(dispatch, paths); const call = (name, path, args = [], kwargs = {}) => dispatchedCall(dispatch, name, path, args, kwargs); const guard = (fn) => refusedAfterAbort(signal, fn); let rawStat; let rawReaddir; let rawStream; if (crossing) { rawStat = async (path) => (await dispatch('stat', path, [], { nofollow: true }))[0]; rawReaddir = async (path, _nofollow, limit) => (await dispatch('readdir', path, [], limit === undefined ? {} : { limit }))[0]; rawStream = (path) => callerStream(dispatch, currentCaller(), path, ahead); } else { rawStat = (path) => dispatchedStat(dispatch, path); rawReaddir = async (path, nofollow, limit) => (await call('readdir', path, [], { ...(nofollow === true ? { nofollow } : {}), ...(limit === undefined ? {} : { limit }), })); const stream = reading?.stream ?? null; rawStream = stream === null ? (path) => dispatchedStream(dispatch, path, ahead) : (path) => stream(ctx, path) ?? dispatchedStream(dispatch, path, ahead); } const read = (path, offset = 0, size = null) => { const source = offset === 0 && size === null ? rawStream(path) : dispatchedStream(dispatch, path, undefined, offset, size); return reading === undefined ? source : guardInput(source, ctx, reading.split); }; // A run across mounts runs in no mount's cache scope: it takes an operand's // size from its stat, warm or not. const isWarm = async (path) => crossing ? false : (await call('read', path, [], { filetype: null, warm: true })) === true; const stat = guard(rawStat); const readdir = guard(rawReaddir); const table = linksOf(ctx); const links = crossing ? undefined : table; const access = crossing ? undefined : ctx.session?.access(); return { ...(ctx.signal === undefined ? {} : { signal: ctx.signal }), stat, exists: guard((path) => dispatchedExists(dispatch, path)), readdir, read, isWarm: guard(isWarm), write: guard(async (path, data) => { await call('write', path, [data]); }), append: guard(async (path, data) => { await call('append', path, [data]); }), pwrite: guard(async (path, data, offset) => { await call('pwrite', path, [data, offset]); }), mkdir: guard(async (path, parents) => { await call('mkdir', path, [], { parents: parents ?? false }); }), unlink: guard(async (path) => { await call('unlink', path); }), rmdir: guard(async (path) => { await call('rmdir', path); }), rmR: guard(async (path) => { await call('rm_r', path); }), rename: guard(async (src, dst) => { await call('rename', src, [dst]); }), copy: guard(async (src, dst) => { await call('copy', src, [dst]); }), dirCopy: guard(async (src, dst) => { await call('dir_copy', src, [dst]); }), truncate: guard(async (path, length, noCreate) => { await call('truncate', path, [length], { no_create: noCreate ?? false }); }), setAttrs: guard(async (path, fields) => (await call('setattr', path, [], { ...fields }))), firstLines: guard(async (path, n) => (await call('first_lines', path, [n]))), lastLines: guard(async (path, n) => (await call('last_lines', path, [n]))), lineCount: guard(async (path) => (await call('line_count', path))), checksum: guard(async (path, algorithm) => (await call('checksum', path, [algorithm]))), follow: guard(async (path, n, interval) => (await call('follow', path, [n, interval]))), readTree: guard(dispatchedReadTree(dispatch, mountView(vfs), table === undefined ? null : (virtual) => linkLocation(table, virtual), crossing)), filesContaining: async (path, text, opts) => (await call('files_containing', path, [text], { whole_word: opts.wholeWord, ignore_case: opts.ignoreCase, })), linesContaining: async (path, text, opts) => (await call('lines_containing', path, [text], { ignore_case: opts.ignoreCase, })), resolveGlob: makeResolveGlob(readdir, mounts?.walkLimit?.(anchor) ?? -1, stat, links === undefined ? undefined : (virtual) => links.targetStat(virtual), access, followOf(ctx), noted), answers: (name) => name === 'read_range' && reading?.stream != null ? false : crossing ? CROSSING_CALLS.has(name) || name === 'read_tree' : (mounts?.answers(anchor, name) ?? false), }; } //# sourceMappingURL=files.js.map