@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
373 lines • 19.1 kB
TypeScript
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