UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

350 lines • 17.9 kB
import { type Caller } from '../context/session_context.ts'; import type { OpRecord } from '../observe/record.ts'; import { FileChangeKind, type FileStat, PathSpec } from '../types.ts'; import { type FileCacheMixin } from './file/mixin.ts'; import type { Evicted } from './index/config.ts'; import type { IndexCacheStore } from './index/store.ts'; import { type Measured } from './types.ts'; import { VersionScope } from './versions.ts'; /** * Post-mutation cache coherence for one mount. * * A backend mutation has two cache consequences: the file-cache entry * for the path is stale, and the parent directory listing in the index * cache (including negative knowledge that the path does not exist) is * stale. This class discharges both, synchronously, at the mutation * site: core backend mutators report through `cache/context.ts` so * invalidation happens before the next command in a pipeline runs * instead of after the whole command tree. */ export declare class CacheManager { private readonly readTtl; private readonly onGone?; private readonly mayServeListing?; private readonly excludedPrefixes; private readonly conditional; readonly versionScope: VersionScope; private readonly fileCache; private readonly index; private readonly clock; private readonly prefix; private readonly cachesReads; private readonly ownsPath; private readGeneration; private view; private readonly written; private readonly probed; private probeBound; private readonly checked; private readonly refused; private readonly checking; private checkBound; constructor(fileCache: FileCacheMixin | null, index: IndexCacheStore | null, prefix: string, cachesReads: boolean, ownsPath?: (path: string) => boolean, readTtl?: number, onGone?: ((gone: readonly Evicted[]) => Promise<void>) | undefined, mayServeListing?: ((folder: string, version: string | null) => Promise<boolean>) | undefined, excludedPrefixes?: () => readonly string[], conditional?: boolean); private millis; /** Drain raw backend index access before mount cache eviction. */ mutation<T>(call: () => Promise<T>): Promise<T>; /** * Drop this mount's listings and bodies, path unspecified. * * For a write that went past the dispatcher, so nothing here can say which * file changed, only that this mount may no longer match its backend: * native code (an external program, a remote runtime line), an account CLI * writing to its service by id, or a mount command writing through its own * client. Both caches go, because the two hide different writes: a stale * listing hides a create or a delete, a stale body an edit. It also retires * what the running command's probes saw. * * The index is expired rather than cleared, while this mount still owns it: * a cleared index reads exactly like one that was never filled, so a * backend whose index *is* its listing (github seeds the whole tree once) * could not tell the drop from an empty repository. Over-evicts when this * mount is the root and another mount sits beneath it, since keys are * compared by prefix; a refetch is the safe direction to be wrong in. * Mirrors Python's `CacheManager.drop`. * * @param bodies drop the bodies too; false when the caller clears the whole * file cache itself. */ drop(bodies?: boolean): Promise<void>; /** * Retire every in-flight read and every remembered probe answer. * * The one step every cache drop takes: a read that began before it must not * stamp the cache after it, and a probe answer from before it must not be * served after it. */ retire(): void; private goneLocked; /** * This mount's index, its backend metadata writes bound to the mount's * lifetime; null when the mount has none. * * The view is reused because refill locks are keyed by index identity. */ scopeIndex(): IndexCacheStore | null; private viewOptions; private noteWritten; /** * Whether the running command fetched `folder`'s listing itself. * * Stricter than `listingTrusted`: outside a command no listing is the * caller's own, and inside one only a listing fetched under the same command * stamp is: not one another session fetched meanwhile, nor one a nested * `$(...)` or function body fetched. A listing the command fetched stays its * own until the command ends, so a long-running command does not see a name * another client creates after it listed the folder. Mirrors Python's * `listed_this_command`. */ listedThisCommand(folder: string): boolean; /** * Whether `folder`'s listing is recent enough to serve under fresh. * * Inside a command: only if it was fetched since the command started, so * one command re-lists a folder once however often it reads it. Outside * any command (FUSE, a programmatic op) there is no command to belong to, * so a listing fetched within `LISTING_TRUST_WINDOW` seconds is trusted * instead: one `ls -l` over FUSE is a burst of calls that can share a * re-list until the window expires. * * Every view of the mount, shared or lock-held, records into one map, so * a glob's write counts for the `ls` that follows it. */ listingTrusted(folder: string): boolean; private pruneChecks; /** * Whether a version check is recent enough to answer for the caller. * * The rule `listingTrusted` applies to listings: inside a command, only a * check sent after the command started, since one sent before may predate a * change the command must see; outside any command, one sent within * `LISTING_TRUST_WINDOW` seconds. */ private sentRecently; /** * The backend's listing version for `key`, asking at most once. * * A check recent enough for the caller (`sentRecently`) that answered * `stored` is reused, so one command checks a mount once however many of * its folders it lists. Otherwise a check in flight that is recent enough * is shared, and only then is a new one sent; the newest in flight is the * one later callers find. A remembered answer that differs from `stored` is * asked again rather than trusted, since the listing may have been written * since, unless that answer already refused this same `stored`: nothing * about that listing changed. `check` answers null when the backend gives * no version. */ checkedVersion(key: string, stored: string, check: () => Promise<string | null>): Promise<string | null>; private sendCheck; /** * Remember what a probe or a direct stat got from the backend. * * Called only with an answer the backend gave: the reconciler's probe, and * under any read policy a stat that asked the backend itself, since its * point observation can expire the parent listing the next lookup would * otherwise walk again. A stat served from an index row -- which may carry * no content token -- never lands here. A path the backend reports gone * records nothing: the probe asks the backend only when no answer is * servable, so there is nothing left to take back. */ noteProbed(path: PathSpec, stat: FileStat): void; private pruneProbes; /** Mutation generation, captured before a freshness probe starts. */ get generation(): number; /** * The backend's answer for `path` from this command's probe. * * A read command stats its own operand after the probe already asked the * backend; asking again resolves through listings the command has not * re-checked (or that its observation expired), and re-lists every folder * on the path. The answer is * served only inside the command that probed, and only while no cache drop * has landed since: a write in the command (`sed -i`, `> f`), the clear * after an external program, and a re-list that found the path gone all * retire it (`retire()`), so the next stat goes back to the backend. */ probedStat(path: PathSpec): FileStat | null; /** Retain a live fallback row only while its predecessor still owns the slot. */ retainResolvedEntry(path: PathSpec, generation: number, predecessor: string, entry: FileStat): Promise<void>; /** * A view for a caller already inside `mutation`. * * Never share or retain it beyond that hold. A distinct refill lock * avoids lock inversion with readers of the shared view. */ private lockedIndex; /** * Drop one directory's cached listing. * * Both spellings of the directory go, because a backend may have keyed * it with or without its trailing slash and an eviction that hits no * key is silent. */ private evictDir; /** * Cache key for a path, derived rather than inferred. * * Both caches this class evicts from are keyed by the mount-absolute virtual * path, so that is what this returns: the mount prefix still attached, not * the mount-relative spelling `mountKey` produces on the way there. * * Only `virtual` is read, and the key is rebuilt against this manager's own * prefix, exactly as `Mount.call` rebuilds one before handing a path to * a backend. The caller's `vfsPath` is deliberately ignored: it is not * a fact this class can trust, because `PathSpec.fromStrPath` fabricates one * ("assumed root-mounted") for any caller that does not know its mount. * * The earlier version inferred which convention had arrived by comparing the * two strings, which cannot be done: under a `/d` mount a mount-relative `/d` * and an absolute `/d` are the same characters naming different files. * Inferring wrong is quiet rather than loud -- a key one level off simply * evicts nothing -- which is why it survived. Deriving asks no question that * has no answer. * * Mirrors Python `CacheManager._cache_key`. */ private cacheKey; /** The file cache this manager may read `key` from, if any. */ private readableCache; /** A browser fallback cannot attribute an overlapping result to a session. */ private sessionHistory; /** * Remember a refusal: the version it lost on stands until a read; null * forgets it. */ lose(path: PathSpec, version: string | null): void; /** * Record that `caller`'s session found no file at `path` on a read or stat. * It lifts a refusal the running line marked on `path` earlier, as a read * that finds the file does. A miss no one session owns (overlapping * sessions on the fallback storage) is recorded nowhere, so the next write * goes as it would have. Mirrors Python's `observe_miss`. */ observeMiss(path: PathSpec, caller: Caller | undefined): void; /** Record this session's exact result, never a peer's cached token. */ observe(rec: OpRecord, owner?: Caller): void; /** * Retain the refused condition in its session, or in the cache without a * session. ABSENT, a refused create, is kept only in session history. */ keepVersion(path: PathSpec, version: Measured): Promise<void>; /** * Conditional mounts use the live session's own observations, with * current-line refusals first. Without a session, and on unconditional * mounts, use current-line records then the shared cache's token. * Cache eviction never clears a session's history. */ readVersion(path: PathSpec): Promise<Measured | null>; /** `readVersion` for many paths; session history needs no I/O. */ readVersions(paths: readonly PathSpec[]): Promise<(Measured | null)[]>; /** * Run a cold whole-file read and keep its bytes for the next one. * * For the dispatcher, which probed the cache itself. A write that lands * while the fetch runs * retires the generation, so the bytes it read are not kept; an answer * that is not bytes is returned and kept nowhere. `keep`, when given, is * asked after the fetch with the cache's mutation lock held and has the * last say over whether the bytes are kept; the dispatcher passes "no * renderer resolves for the read". It must be synchronous; omitted, the * bytes are kept. Mirrors Python's `CacheManager.fill`. */ fill<T>(path: PathSpec, fetch: () => Promise<T>, keep?: () => boolean): Promise<T>; /** * Pass a cold streamed read through and keep its bytes for the next one. * * The streamed twin of `fill`: the bytes are kept once the last chunk is * pulled, under the same rules (a write that lands while the stream runs * retires the generation, and `keep` has the last say), labelled with the * token this stream's backend recorded (`records`). A stream closed early, * or larger than the cache's drain budget, keeps nothing, so the cache never * reads past what the caller pulled and never holds more than it could. * Mirrors Python's `fill_stream`. */ fillStream(path: PathSpec, source: AsyncIterable<Uint8Array>, records: readonly OpRecord[], keep?: () => boolean): AsyncIterable<Uint8Array>; private filling; private admits; /** * Keep a write's bytes for the next read, as a read keeps its own. * `fingerprint` is the token the write's backend answered, null when it * vouched for none. Mirrors Python's `keep_written`. */ keepWritten(path: PathSpec, data: Uint8Array, fingerprint: string | null): Promise<void>; /** * Store bytes a read or a write settled on, unless they went stale. * * The one way bytes enter the file cache. The checks run under the cache's * mutation lock, so a name this mount stopped owning or caching keeps * nothing, nor do bytes `admit` refuses. Bytes over the whole cache limit * would evict every warm entry, so the copy held for the key, now stale, * goes instead; a store that refuses the bytes (out of memory, a value over * its size limit) costs only the next read a fetch, so the refusal is * logged, never thrown, and the stale copy goes too. Mirrors Python's * `_keep`. */ private keep; /** * Return each cached render's byte length, in path order, without * revalidating or fetching the bytes. * * The size backfill a render-dependent backend cannot answer for itself * (the dispatcher's stat) runs only where the backend reported no size, * which is exactly the API mounts, so gating it would turn a stat into a * backend stat. It answers a length rather than content, so nothing can * serve unverified bytes through it. */ cachedSizes(paths: readonly PathSpec[]): Promise<(number | null)[]>; /** * Whether a read of `path` would be served from the cache, asked of the * store without fetching the bytes. */ isCached(path: PathSpec): Promise<boolean>; /** * Drop what a change at `path` made stale. The kind decides how far down it * reaches: * * - CREATE, UPDATE, and MOVE at its landing: the body, and the path's entry * in its parent's listing. A CREATE that answered its new entry's stat * (`row`) keeps that entry where the parent's listing names it instead, so * a listing of the parent and the next level of a nested create need no * request; a row naming another file (a store that matches names without * case wrote the one it holds) proves nothing, so the parent is listed * again. * - DELETE: the body, the path's own listing and its parent entry, and what * is cached beneath it when a listing at or under it is still cached. That * probe is for a remover that cannot say it removed a folder (a notified * DELETE, the vacated side of a MOVE): a folder's nested listings and * bodies were cached under keys of their own, while a plain file must not * pay a subtree drop, which on a Redis file cache scans the whole keyspace. * A remover that knows nothing was below (`leaf`: unlink, rmdir) skips it. * A folder with no listing cached at or under it keeps the bodies cached * beneath it until their ttl. * - UNKNOWN: everything cached at and below the path, as a push notification * that names only a folder, a recursive delete or a directory rename needs. * * Then the path's ancestors drop their entries in their parents' listings: a * keyed store materializes every missing level of a key in one put, so those * listings gained entries too. Each affected listing becomes partial and * keeps its unaffected siblings. Every change retires this mount's in-flight * reads and probe answers, all but `probed`. Mirrors Python's * `CacheManager.invalidate`. * * @param path the changed path; only `virtual` is read. * @param kind what happened, which decides the reach. * @param row a CREATE's new entry, kept in its parent's listing; null drops * that entry. * @param options.leaf a DELETE whose remover knows nothing was below the path. * @param options.probed the backend's answer the change was found by, which * this command's probe keeps serving for `path` (`probedStat`) past the * retire. */ invalidate(path: string | PathSpec, kind: FileChangeKind, row?: FileStat | null, options?: { leaf?: boolean; probed?: FileStat | null; }): Promise<void>; private invalidateOwn; private removed; private invalidateAncestors; private invalidateRemoved; private dropBelow; } //# sourceMappingURL=manager.d.ts.map