@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
379 lines • 19 kB
JavaScript
// ========= 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