UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

379 lines • 19 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 { ListingCheckStore } from "../cache/index/ram.js"; import { enoent, isEnoent, isEnotdir, isMissingOp } from "../errors/fs.js"; import { FileChangeKind, FileStat, FileType, ListingVersion, PathSpec, ReadPolicy, } from "../types.js"; import { mountKey } from "../utils/key_prefix.js"; import { ancestors } from "../utils/path.js"; import { rstripSlash } from "../utils/slash.js"; import { BaseVFS } from "../vfs/base.js"; import { callNames, declaredCalls } from "../vfs/call.js"; import { Effect, Target } from "../vfs/types.js"; const CALLS = declaredCalls(BaseVFS); // What a FRESH read policy revalidates: a file's content and a stat. const REVALIDATE_OPS = new Set([ ...callNames(CALLS, { effects: [Effect.READ], targets: [Target.FILE] }), ...callNames(CALLS, { effects: [Effect.METADATA] }), ]); // The spec a backend op sees for an absolute virtual path on `mount`. function scopeOf(mount, path) { const lastSlash = path.lastIndexOf('/'); return new PathSpec({ virtual: path, directory: lastSlash > 0 ? path.slice(0, lastSlash + 1) : '/', vfsPath: mountKey(path, rstripSlash(mount.prefix)), }); } var Verdict; (function (Verdict) { Verdict["FRESH"] = "fresh"; Verdict["STALE"] = "stale"; Verdict["GONE"] = "gone"; Verdict["UNKNOWN"] = "unknown"; })(Verdict || (Verdict = {})); /** * Keep the local view honest against backend truth. * * The single reconcile point every read path shares. Under a mount's * `read: fresh` a backend re-stat classifies a path as fresh, stale * (fingerprint mismatch), gone * (deletion), or unknown (no fingerprint to compare). One deletion signal * feeds both consumers with separate reactions: the file cache evicts and the * namespace GCs any orphaned attribute overlay. * * Three read paths call in, all at the dispatcher: its cached-read gate * (mayServeCached), its main-op catch (onEnoent), and its first lookup of a * path the running command's line names (reconcileRead). The re-stat goes * through the ops registry (not mount.call, whose op set omits stat). * Reconcile state follows each consumer's store (RAM local, Redis shared * across runtimes), so this is a thin coordinator holding references, not * config. * * The gate and reconcileRead both run for a warm named operand, once at the * command's first lookup of it and again at the gate, and they share one * scope: the command. The * first probe's backend answer is kept on the mount's CacheManager for the * rest of the command, so the gate, and the command's own stat of the * operand, reuse it instead of asking again. A write in the command, the * clear after an external program, or a re-list that finds the path gone * retires it, and a read outside any command (FUSE, the dispatcher) never sees * it. */ export class Reconciler { cache; namespace; constructor(cache, namespace) { this.cache = cache; this.namespace = namespace; } // Drop what the backend no longer vouches for at `path` as a remove drops // it (CacheManager.invalidate): its body, its own listing, its row in its // parent's and what is cached below a folder, not every listing the mount // holds. `keep`: the probe's backend answer // still stands, so the command's own stat of `path` reuses it past the // drop. async drop(mount, path, keep = false) { const manager = mount.cacheManager; if (manager === null) { await this.cache.remove(path); return; } const scope = scopeOf(mount, path); await manager.invalidate(scope, FileChangeKind.DELETE, null, { probed: keep ? manager.probedStat(scope) : null, }); } // Re-stat the backend and apply the matching cache/overlay reaction. A // missing path GCs (evict cache + drop overlay); a fingerprint mismatch // evicts the stale cache entry. Non-ENOENT errors propagate. // // Inside a command, what an earlier probe of the same command got from the // backend is reused (CacheManager.probedStat) until a write lands: the // verdict and its reactions still run, only the round trip is skipped. async probe(mount, path) { const scope = scopeOf(mount, path); const manager = mount.cacheManager; let remoteStat = manager?.probedStat(scope) ?? null; let scratch = null; const generation = manager?.generation; if (remoteStat === null) { scratch = new ListingCheckStore({ hints: mount.index }); try { // No cached row answers; the mount's rows ride along as hints. remoteStat = await mount.callKeyed('stat', scope, [], { index: scratch }); } catch (err) { if (isEnoent(err) || isEnotdir(err)) { await this.drop(mount, path); await this.namespace.dropOverlay(path); return Verdict.GONE; } // A backend that registers no stat op cannot be revalidated at all. // probeOrUnknown would reach the same verdict, but it would also log // every read: this is a permanent capability of the mount, not an // anomaly worth a log line each time. isMissingOp, not a bare ENOTSUP // check: python catches OperationNotSupportedError, which only the // dispatcher raises, and `stat` is the only op probed here -- so a backend // that stamps ENOTSUP itself takes the logged path on both sides. if (isMissingOp(err, 'stat')) { await this.drop(mount, path); return Verdict.UNKNOWN; } throw err; } if (manager !== null && manager.generation === generation && remoteStat instanceof FileStat) { manager.noteProbed(scope, remoteStat); } } const fp = remoteStat instanceof FileStat ? remoteStat.fingerprint : null; if (fp === null) { await this.drop(mount, path, true); return Verdict.UNKNOWN; } if (!(await this.cache.isFresh(path, fp))) { await this.drop(mount, path, true); return Verdict.STALE; } const predecessor = scratch?.hintedRows.get(path); if (scratch !== null && predecessor !== undefined && manager !== null && generation !== undefined) { const confirmed = (await scratch.get(path)).entry; if (confirmed != null) await manager.retainResolvedEntry(scope, generation, predecessor, confirmed); } return Verdict.FRESH; } // Probe, treating a failed probe as "cannot verify". A backend that cannot // answer right now is the same situation as one that answers without a // fingerprint: the copy cannot be verified, so it is dropped and the caller // reads cold. Throwing instead would be strictly worse -- it serves nothing // and protects nothing further, and inside a recursive walk one transient // stat would abort the whole traversal rather than the one file. async probeOrUnknown(mount, path) { try { return await this.probe(mount, path); } catch (err) { if (isEnoent(err) || isEnotdir(err)) throw err; // A backend that cannot answer is one thing; a bug in the probe path // is another, and degrading it to "cannot verify" would hide it behind // a log line and a lifetime of cold reads. if (err instanceof TypeError || err instanceof ReferenceError) throw err; await this.drop(mount, path); console.warn(`probe failed for ${path}: ${String(err)}`); return Verdict.UNKNOWN; } } // Gate a cached read: is the cached copy still valid to serve? Under // `bounded` the cache is trusted within its bound. Under `fresh` the // backend probe (reused within its command until a write) supplies the // fingerprint: a match serves the cached copy, a mismatch evicts it, a path the // backend no longer has GCs and throws, and a backend that answers no // fingerprint at all -- or no stat at all -- cannot be verified, so the // copy is dropped and the caller re-reads. // // supportsSnapshot deliberately does not appear here. It used to // short-circuit this function, dropping every cached copy on a resource // that declares it false. That is a proxy for "the stat carries no content // token", and it is the wrong one: box and dropbox stamp a fingerprint // without setting the flag (as does ssh on the python side, whose roster // differs here), so the shortcut threw away entries this probe can // verify. The backends that really cannot be checked are answered by // probe's own UNKNOWN arm, one stat later. async mayServeCached(mount, path) { if (mount.read.policy !== ReadPolicy.FRESH) { // Bounded: the store expires the entry on its own, except for one // population it cannot. Nothing stamped a ttl before this policy // existed, and setCachedLocked short-circuits a warm read rather // than re-setting it, so a bound-less entry would never acquire one // and never expire. Removing it -- not merely declining to serve it // -- is what makes the cold read that follows stamp the bound; // refusing alone would leave the entry in place and refetch on // every read forever. if (await this.cache.isUnbounded(path)) { await this.cache.remove(path); return false; } return true; } const verdict = await this.probeOrUnknown(mount, path); if (verdict === Verdict.GONE) throw enoent(path); return verdict === Verdict.FRESH; } /** * Gate a cached listing: may it be served without re-listing? * * Under `bounded` the listing is trusted within its bound. Under `fresh` a * listing the running command fetched itself is served (a read outside any * command trusts one fetched within the last `LISTING_TRUST_WINDOW` * seconds instead, `CacheManager.listingTrusted`). Past that, a listing * stored at the mount's pin is served without asking: github pins a * full-sha ref and serves its listing unchecked when the stored version * equals that sha. It names a commit: github.com refuses a 40- or 64-hex * branch or tag name, and a GitHub Enterprise host is assumed to as well * (`pinOf` in vfs/github/github.ts). A mount that declares a * `listingVersion` then has its stored `version` checked against a stat of * the mount root (MOUNT) or of the folder (FOLDER), sent through a * throwaway index so no cached row answers it. One check answers for a * whole command, and concurrent callers share it * (`CacheManager.checkedVersion`). * * A match serves the listing. Anything else answers EXPIRED and keeps the * listing stored for the re-list to diff: a moved version, a path the * backend no longer has, a stat with no fingerprint, a mount with no stat * at all, and a backend that cannot answer, which is logged. The index is * never cleared here. A programming error propagates. */ async mayServeListing(mount, folder, version) { if (mount.read.policy !== ReadPolicy.FRESH) return true; const manager = mount.cacheManager; if (manager === null) return false; if (manager.listingTrusted(folder)) return true; const vfs = mount.vfs; if (vfs.listingsPin !== null && version === vfs.listingsPin) return true; if (vfs.listingVersion === ListingVersion.NONE || version === null) return false; const key = vfs.listingVersion === ListingVersion.FOLDER ? folder : rstripSlash(mount.prefix) || '/'; let remote; try { remote = await manager.checkedVersion(key, version, () => this.listingFingerprint(mount, key)); } catch (err) { if (isEnoent(err) || isEnotdir(err) || isMissingOp(err, 'stat')) return false; if (err instanceof TypeError || err instanceof ReferenceError) throw err; console.warn(`listing check failed for ${key}: ${String(err)}`); return false; } return remote === version; } // Ask the backend for the version a listing check compares: the mount root // or the folder the version covers. async listingFingerprint(mount, path) { const remote = await mount.callKeyed('stat', scopeOf(mount, path), [], { index: new ListingCheckStore(), }); return remote instanceof FileStat ? remote.fingerprint : null; } // Reconcile a path a command names before it answers the command's first // lookup of it. The dispatcher calls this under `read: fresh` for a stat, // listing or exists check of one of the line's own paths: a metadata // command (ls, stat) reads no bytes, so the cache gate never checks its // operand. Only paths that carry an overlay or a cached copy are probed (a // plain lookup pays nothing); a remote delete then evicts the cache AND GCs // the orphaned overlay, and a stale entry is dropped. // // Nothing escapes. A lookup that cannot be verified must still be answered, // so the probe is best-effort: drop what could not be verified, log it, and // let the lookup ask the backend itself. async reconcileRead(mount, path) { if (mount.read.policy !== ReadPolicy.FRESH) return; if (this.namespace.metaFor(path) === null && !(await this.cache.exists(path))) return; try { await this.probeOrUnknown(mount, path); } catch (err) { await this.drop(mount, path); console.warn(`reconcile probe failed for ${path}: ${String(err)}`); } } // React to a read/stat op that the backend reported gone (ENOENT). // // Keyed on the mount's policy rather than fired unconditionally, and // that is deliberate. An ENOENT here is not proof the backend said so: // object-store `stat` answers a miss straight out of a live index // listing, and so do the box, gdrive, dropbox, hierarchy and hf_hub // reads. Reacting to one of those would drop an attribute overlay -- // which no backend stores, so nothing can put it back -- on the // strength of cached negative knowledge. // // `isEnoent` is load-bearing and stays: the call site is a generic // catch, so without it a 500, a timeout or an auth failure would GC. async onEnoent(mount, name, path, err) { if (mount.read.policy === ReadPolicy.FRESH && REVALIDATE_OPS.has(name) && (isEnoent(err) || isEnotdir(err))) { await this.onMissing(path); } } // Reconcile one path with what a fresh stat got from the backend (`row`, // null when it reported the path gone), through the mount's cache manager // so a read that began before the answer cannot stamp the cache after it. // A path gone, or a listed folder now a file, is reported gone as a // re-list's vanished child is: its bodies and overlays below go, nested // mounts and links stay, and its listings are dropped. A row that disagrees // with the stored one, or a held copy the fingerprint cannot vouch for, is // invalidated as a write is, keeping the version a conditional write sends. // A vouched copy whose row moved keeps its bytes. The dispatcher runs the // stat and this under the path's writer hold, so no local write to the path // lands between the answer and its reconcile. async onFreshStat(mount, path, row) { const manager = mount.cacheManager; if (manager === null) return; const stored = (await mount.index.get(path)).entry ?? null; const folder = stored?.type === FileType.DIRECTORY || (await mount.index.holdsSubtree(path)); if (row === null || (folder && row.type !== FileType.DIRECTORY)) { await mount.index.reportGone([{ path, folder }]); await manager.invalidate(path, FileChangeKind.DELETE); return; } const held = await this.cache.exists(path); const vouched = held && row.fingerprint !== null && (await this.cache.isFresh(path, row.fingerprint)); const same = stored !== null && JSON.stringify(stored.with({ name: row.name, extra: row.extra })) === JSON.stringify(row); if (same && (vouched || !held)) return; if (vouched) { await mount.index.invalidateEntry(path); return; } const spec = PathSpec.fromStrPath(path); const version = await manager.readVersion(spec); await manager.invalidate(spec, FileChangeKind.UPDATE); if (version !== null) await manager.keepVersion(spec, version); } async onGone(gone, excluded = []) { const paths = new Set(gone.map((child) => rstripSlash(child.path) || '/')); const folders = new Set(gone.filter((child) => child.folder).map((child) => rstripSlash(child.path) || '/')); for (const path of paths) { const parents = [...ancestors(path), ...(path === '/' ? [] : ['/'])]; if (parents.some((parent) => folders.has(parent))) continue; await this.cache.remove(path, { subtree: folders.has(path), excluded }); } if (paths.size > 0) await this.namespace.dropOverlaysUnder([...paths], excluded); } // Apply the deletion reaction: evict cache + GC orphaned overlay. An // authoritative symlink node is left intact (dropOverlay skips it). async onMissing(path) { await this.cache.remove(path); await this.namespace.dropOverlay(path); } } //# sourceMappingURL=reconcile.js.map