UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

373 lines • 19.1 kB
import { type ByteSource, IOResult, OpReport } from '../io/types.ts'; import type { OpRecord } from '../observe/record.ts'; import { type Clock } from '../utils/clock.ts'; import type { FileStat, SetAttrFields } from '../types.ts'; import { type MountMode, PathSpec } from '../types.ts'; import { type Caller, type CallerDispatchFn } from '../context/session_context.ts'; import type { FollowOperands, OpKwargs, SessionAccess, SessionBind } from '../view/types.ts'; import { NamespaceReader } from './dispatcher/reader.ts'; /** Receives each record with the id of the session the op ran as. */ export type OpSink = (rec: OpRecord, sessionId: string) => Promise<void>; /** What a derived facade carries over and a constructor may set. */ export interface FilesOptions { bind?: SessionBind | null; caller?: Partial<Caller> | null; dispatchAs?: ((owner: SessionAccess) => CallerDispatchFn) | null; sessionId?: string | null; records?: OpRecord[]; follow?: FollowOperands | null; ledger?: boolean; served?: Caller['mount']; clock?: Clock; } /** * The typed file API: `ws.vfs`, `session.vfs` and a command's, CLI leaf's or * runtime's `ctx.vfs`. * * Every op delegates to the workspace dispatcher, so `ws.vfs` walks the * same pipeline as a shell command: link follow, session grants, * admission policies, cache read-through, namespace structure, and * post-write invalidation all fire once, at that one dispatcher. The facade * keeps only what is its own: the typed surface and the op ledger it * records on — the ledger lives here, not on the workspace, which is what * lets `MountCore` take one `Files` instead of reaching through a whole * `Workspace`. The ledger is read on `ws.vfs`, a `WorkspaceFiles`. Mirrors * Python's `Files`. * * The facade runs as one session, `sessionId`, through `bind`: every * op is judged under that session's profile (hides, mount modes, * grants) exactly as a shell line in it would be, so an agent whose * file tool reads through `ws.vfs` is confined the way its shell is. * null names the workspace's default session as it is when the op * runs, since a snapshot load can rename it. A session already bound * when the op arrives (a command's own runtime, a kernel mount serving * one session) is kept, so the facade never widens the caller's view, * and the record names the session that judged the op. `Session` * derives a facade for another session over the same ledger. * * Every op also takes a trailing `sessionId` for the one-call case, * the way `Workspace.shell` does. One rule decides between them: a * shell line *sets* the session, an op *inherits* it. So the argument * is the session to run as when no line is already running, and the * line's session wins when one is, which is what keeps a handler * reaching the dispatcher from widening the view it was given. */ export declare class Files { private readonly dispatchFn; private readonly namespace; private readonly sink; private readonly bind; private readonly dispatchAs; private readonly caller; /** The session this facade runs as; null for the workspace's default. */ readonly sessionId: string | null; /** The op ledger this facade records on; `WorkspaceFiles.records` reads it. */ protected readonly recorded: OpRecord[]; private readonly follow; private readonly ledger; private readonly clock; /** * The mount whose command scope these files run in, null for a run across * mounts; undefined where nothing said one, which the async context * answers instead (`servedBy`). * * @internal */ readonly served: Caller['mount'] | undefined; /** * @param dispatch the workspace dispatcher every op goes to. * @param namespace the reader of the namespace for the session an op runs * as (`Namespace.readers`); null for files outside a workspace, which see * no link and no mount under the bound session's hides. * @param sink receives each record with the session it ran as. * @param options what a derived facade carries over. */ constructor(dispatch: CallerDispatchFn, namespace?: ((sessionId: string | null) => NamespaceReader) | null, sink?: OpSink | null, options?: FilesOptions); private options; /** * The same facade run as another session, over the same ledger, so * the workspace-wide account stays one list. * * @internal The mechanism behind `Session.vfs`, not an entry point of * its own: a host binds a session with `ws.session(id)` (creating it * when the id is new) or `new Session(ws, id)` (adopting one * that exists), so there is one way to say it rather than two. * TypeScript has no package-private, so this stays reachable; it is * not part of the supported surface. Python spells it * `Files._for_session`. */ forSession(sessionId: string): Files; /** * The same facade with `changes` said of every op's caller: a file * tool's call, the unit an op-level answer covers, so an approval one op * is answered by is claimed for the call's other ops and a call running * at the same time is never taken for this one; a dry run's trace; or a * policy method's deciding mark. Said on each op, not bound in the async * context, which without task isolation another task would read. Reached * through `withCaller`. */ private forCaller; /** * The same files said to run in `mount`'s command scope, null for a run * across mounts: what `mountRoot` and `crossesMounts` read, tied to the * command's run, where an async context without task isolation answers * whichever task bound last. The ops are unchanged. * * @internal Said by a mount for each command it runs, and by a run across * mounts; Python's task context carries the caller instead. */ servedBy(mount: Caller['mount']): Files; /** * The same files over another dispatcher, keeping no ledger. * * @internal Behind `captureBinding`; Python spells it `Files._over`. * @param dispatch the dispatcher the calls go to. */ over(dispatch: CallerDispatchFn): Files; /** * The namespace as the session an op would run as sees it: the workspace's, * else, for files outside one, no link and no mount under the bound * session's hides. Mirrors Python's `Files._reader`. */ private reader; /** * The mount prefix serving `path`, without a trailing slash: the one * `RuntimeFiles.mountOf` gives a guest, where a walk up a tree stops, as git * stops looking for `.git` at a filesystem boundary. null for a path the * session cannot see. A path a show carve-out opens inside a hidden mount * names that mount, whose root the path itself runs through. Mirrors * Python's `Files.mount_of`. */ mountOf(path: string | PathSpec): string | null; /** Whether `path` is the root of a mount the session sees. Mirrors Python's * `Files.is_mount`. */ isMount(path: string | PathSpec): boolean; /** * The mount roots strictly under `path`, sorted, without a trailing slash, * as `findmnt --submounts` lists them. * * A mount the session hides still stands where it is: whatever removes or * renames the folder holding it would take it along. It is reported by the * nearest folder above it the session sees, `path` itself when nothing * between is visible, so a caller refuses that folder without learning the * mount's name. Mirrors Python's `Files.submounts`. */ submounts(path: string | PathSpec): string[]; /** * `Workspace.dispatch` as this facade runs it: as its session, and * recorded on the ledger. The dispatcher `RuntimeFiles` drives for node's * patched `fs` (`patchNodeFs`), a facade caller like FUSE, and a * handler's for any op its typed methods do not spell; bound, so a handler * can hand it on. The IOResult is empty unless this facade keeps no * ledger. Mirrors Python's `Files.dispatch`. */ readonly dispatch: (name: string, path: string | PathSpec, args?: readonly unknown[], kwargs?: OpKwargs, report?: OpReport) => Promise<[unknown, IOResult]>; /** * Run one op through the workspace dispatcher and record it. * * The dispatcher owns the whole pipeline (follow, grants, gates, cache, * structure, invalidation). On a line this workspace runs, the op records * where the line records, so its records are the line's; anywhere else * (outside a line, or on another workspace's) it records into its own * recording, handed to this ledger and the sink once the op ends, a * stream's once it closes. What records it is what records a line's * calls: the backend and the dispatcher, with the tokens they read. An op * nothing below recorded (a stat, a listing, a warm cache hit, a * synthetic namespace answer) is recorded here, on the path the * dispatcher's report says it ran on, its links followed. Mirrors * Python's Files._call. */ private through; /** The session id a record carries: the one the op ran as, else this * facade's own, else the unbound entry point's empty id. */ private sessionFor; /** * Read file content, whole or as the caller pulls it. `raw` skips the * filetype cascade: an explicit null filetype stops the dispatcher from * stamping the path's extension, so a rendered read op * (gdoc/gsheet/gslide) is bypassed and the stored bytes come back. * `offset`/`size` ride the same kwargs the generic read op already reads, * so a backend with a native range fetches one window instead of the * whole object. `stream` answers with an async iterable: its first chunk * is read before this returns, so a missing file fails here rather than at * the first pull, a cold read fills the cache as it is pulled, and the read * is recorded once the stream ends. A read the dispatcher answers whole (a * window, a warm copy, a rendering, a backend answering bytes) arrives as * one chunk. Python spells this `read(path, offset, size, raw, stream=)`. */ read(path: string | PathSpec, options?: { raw?: boolean; offset?: number; size?: number | null; stream?: false; }, sessionId?: string): Promise<Uint8Array>; read(path: string | PathSpec, options: { raw?: boolean; offset?: number; size?: number | null; stream: true; }, sessionId?: string): Promise<AsyncIterable<Uint8Array>>; read(path: string | PathSpec, options: { raw?: boolean; offset?: number; size?: number | null; stream: boolean; }, sessionId?: string): Promise<Uint8Array | AsyncIterable<Uint8Array>>; write(path: string | PathSpec, data: ByteSource | string, sessionId?: string): Promise<void>; /** * Append bytes or an async byte source through the dispatcher: a VFS's * `append` takes either, and one with no `append` is read and rewritten. */ append(path: string | PathSpec, data: ByteSource, sessionId?: string): Promise<void>; /** * Write bytes at an offset, keeping the rest of the file (the python * facade's `pwrite`). pwrite(2): the bytes outside the window stay, a gap * past the end reads back as zeros, and a missing file is created. It is * one write at the dispatcher, so a session that may write the file and not * read it can still do it. A stream lets the path's hold go while its next * bytes are pulled, as a streamed write does. */ pwrite(path: string | PathSpec, data: ByteSource, offset: number, sessionId?: string): Promise<void>; readdir(path: string | PathSpec, sessionId?: string): Promise<string[]>; /** * One path's row. `nofollow` reports a trailing symlink itself rather * than its target (lstat), so a dangling link is a name that is there. * `fresh` asks the backend past the mount's listing index and reconciles * the index and file cache with its answer. */ stat(path: string | PathSpec, sessionId?: string, opts?: { nofollow?: boolean; fresh?: boolean; }): Promise<FileStat>; exists(path: string | PathSpec, sessionId?: string): Promise<boolean>; isDir(path: string | PathSpec, sessionId?: string): Promise<boolean>; isFile(path: string | PathSpec, sessionId?: string): Promise<boolean>; mkdir(path: string | PathSpec, sessionId?: string): Promise<void>; create(path: string | PathSpec, sessionId?: string): Promise<void>; /** * Create a namespace symlink at `path`. * * Routed through the dispatcher like every write: session grants and * admission policies fire on the link's turf, and the write lands on * the ledger. The target is stored verbatim as typed. Throws EEXIST * when something is already at `path` (a file, a directory, another * link, a mount root): symlink(2) never overwrites, and the dispatcher is * the layer that can see both planes to tell. Throws ENOENT when the * directory `path` would sit in is absent, and ENOTDIR when a * non-directory stands there or above it. Mirrors Python's * Files.symlink. */ symlink(path: string | PathSpec, target: string, sessionId?: string): Promise<void>; /** The stored target of the link at `path`; EINVAL when not a link. */ readlink(path: string | PathSpec, sessionId?: string): Promise<string>; /** * Write metadata fields, natively where the backend can hold them. * * Every field is passed, unset ones as undefined, because the dispatcher * reads the whole set and stores in the namespace overlay whatever * the backend cannot keep. A mount with no setattr op therefore still * answers: a chmod on an s3 or dropbox mount lands in the name plane * and stat reports it back. Stored, not enforced; the mount mode is * the access control. Returns what the backend could not keep. * Mirrors Python's Files.setattr. */ setattr(path: string | PathSpec, attrs?: SetAttrFields, sessionId?: string): Promise<Record<string, number | string>>; /** * One extended attribute's value. The node table answers with what a * caller set. `nofollow` reads a link entry's own * attributes. Throws ENODATA when the path has no such attribute. * Mirrors Python's Files.getxattr. */ getxattr(path: string | PathSpec, name: string, opts?: { nofollow?: boolean; }, sessionId?: string): Promise<Uint8Array>; /** Every extended attribute name a path carries, sorted. */ listxattr(path: string | PathSpec, opts?: { nofollow?: boolean; }, sessionId?: string): Promise<string[]>; /** * Store an extended attribute on a path's namespace node, so it works * on every backend and moves with a rename. `create` refuses with * EEXIST when it is set (XATTR_CREATE) and `replace` with ENODATA when * it is not (XATTR_REPLACE). * Mirrors Python's Files.setxattr. */ setxattr(path: string | PathSpec, name: string, value: Uint8Array, opts?: { create?: boolean; replace?: boolean; nofollow?: boolean; }, sessionId?: string): Promise<void>; /** Drop an extended attribute; ENODATA when it is not set. */ removexattr(path: string | PathSpec, name: string, opts?: { nofollow?: boolean; }, sessionId?: string): Promise<void>; truncate(path: string | PathSpec, length: number, sessionId?: string): Promise<void>; unlink(path: string | PathSpec, sessionId?: string): Promise<void>; rmdir(path: string | PathSpec, sessionId?: string): Promise<void>; /** * Rename a file or directory within one mount. * * Both ends must resolve to the same mount: a mount is a filesystem * boundary, and the dispatcher answers EXDEV across two (ENOENT when a * parent directory is missing), which a kernel-facing whole-workspace FUSE * mount needs so `mv` between two backends falls back to its copy+unlink * path. Mirrors Python's Files.rename. */ rename(src: string | PathSpec, dst: string | PathSpec, sessionId?: string): Promise<void>; listFiles(path: string | PathSpec, sessionId?: string): Promise<string[]>; } /** * `ws.vfs`: the files the host holds, with what only the host asks of them. * * The op ledger every ledgered facade appends to (`records`, with the * network/cache split derived from it). The `Files` a handler, a CLI leaf, a * runtime or `session.vfs` gets is a facade of its own, on which the ledger * would report only that facade, so it is a plain `Files`. Mirrors Python's * `WorkspaceFiles`, which also answers the mount questions FUSE and the * runtime's `open()` interception ask; here the workspace's registry answers * those. */ export declare class WorkspaceFiles extends Files { /** * The op ledger: every facade op lands here, and the executor appends each * shell line's ops too, so this is the one workspace-wide account (Python's * `WorkspaceFiles.records`). */ get records(): OpRecord[]; /** Total bytes transferred over the network. */ get networkBytes(): number; /** Total bytes served from cache. */ get cacheBytes(): number; } /** * The namespace as the session a command's files run as sees it, for the * helpers that walk it (`NamespaceReader`); outside a workspace, no link and * no mount. * * The dispatcher's reader, never one of the facade's own: the link and mount * tables are the namespace's, and the reader filters every answer by the rule * the dispatcher refuses a call by. Mirrors Python's `reader_of`. */ export declare function readerOf(files: Files): NamespaceReader; /** * The mode a write at `path` meets, for the session `files` runs as * (`effectivePathMode`): asked under the binding its ops run under, so a * default session's profile counts, at the path a link leads to, where the * write lands, unless `follow` is off. Null where no mount owns it. Mirrors * Python's `path_mode`. */ export declare function pathMode(files: Files, path: string, follow?: boolean): Promise<MountMode | null>; /** * The line's operand resolution a command's globs give their matches, which a * command's files carry; null outside a line. Mirrors Python's `operands_of`. */ export declare function operandsOf(files: Files): FollowOperands | null; /** * The same files with `changes` said of every op's caller: a file tool's call, * a dry run's trace or a policy method's deciding mark. Never a handler's: the * dispatcher trusts the caller, so one that dropped its rule gate would read * past a path rule scoped to its command. Python's task context carries the * caller instead. */ export declare function withCaller(files: Files, changes: Partial<Caller>): Files; //# sourceMappingURL=files.d.ts.map