UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

278 lines • 11.7 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 { CONTENT_CHANGING_OPS, RETRACT_FINGERPRINT_OPS, STAMP_FINGERPRINT_OPS, SUBTREE_RETRACT_OPS, } from "../../observe/record.js"; import { DriftPolicy } from "../../types.js"; import { mountKey } from "../../utils/key_prefix.js"; import { rstripSlash } from "../../utils/slash.js"; /** * Raised at load time when a remote VFS's live fingerprint differs * from what was recorded in the snapshot. * * Indicates the underlying source has been modified since the snapshot * was taken, so reading current bytes would silently diverge from what * the original agent saw. Surface to the caller rather than mask. */ export class ContentDriftError extends Error { path; snapshotFingerprint; liveFingerprint; constructor(path, snapshotFingerprint, liveFingerprint) { const liveRepr = liveFingerprint === null ? '<missing>' : JSON.stringify(liveFingerprint); super(`${path}: snapshot fingerprint ${JSON.stringify(snapshotFingerprint)}, live ${liveRepr}; ` + 'data on the underlying source has changed since the snapshot was taken'); this.name = 'ContentDriftError'; this.path = path; this.snapshotFingerprint = snapshotFingerprint; this.liveFingerprint = liveFingerprint; } } /** * Fingerprint checks a load queued, drained on the first async op. * * `Workspace.load` records one entry per read whose snapshot manifest * carried a fingerprint but no stable revision (a pinned read needs no * check: the pin guarantees the bytes). The first `dispatch` or * `shell` drains them, so downstream code can rely on consistent * state. Mirrors the Python `DriftQueue` in `snapshot/drift.py`. */ export class DriftQueue { entries = []; isPending = false; get pending() { return this.isPending; } /** Paths still queued for a check (audit surface). */ get paths() { return this.entries.map((e) => e.path); } /** Drop any queued state (a re-install starts fresh). */ clear() { this.entries = []; this.isPending = false; } queue(path, fingerprint, mountId = null) { this.entries.push({ path, fingerprint, mountId }); this.isPending = true; } /** * Stat every queued path in parallel; throw on the first drift. * Subsequent calls are no-ops. Stats run concurrently so first-op * latency does not scale linearly with the number of recorded reads. */ async drain(registry, statFn) { this.isPending = false; if (this.entries.length === 0) return; const pending = this.entries; this.entries = []; const results = await Promise.allSettled(pending.map((p) => checkDrift(registry, statFn, p.path, p.fingerprint, p.mountId))); for (const r of results) { if (r.status === 'rejected') throw r.reason; } } } /** * Walk a loaded snapshot's fingerprint manifest. For entries with a * revision, install the pin on the owning mount so replay reads pin to * that revision. For fingerprint-only entries, queue the path on the * drift queue. OFF skips the checks and evicts the snapshot cache for * fingerprinted paths so reads serve current state. * * Idempotent: clears queued state before installing. Called from * `Workspace.fromState`. */ export async function installFingerprints(registry, cache, drift, state, policy) { drift.clear(); const entries = state.fingerprints ?? []; if (entries.length === 0) return; if (policy === DriftPolicy.OFF) { for (const e of entries) await cache.remove(e.path); return; } for (const e of entries) { const mount = registry.tryMountFor(e.path); if (mount === null) continue; if (e.revision !== undefined && e.revision !== null) { mount.revisions.set(e.path, e.revision); if (e.fingerprint !== undefined && e.fingerprint !== null) mount.cacheManager?.versionScope.pins.set('/' + mountKey(e.path, mount.prefix), e.fingerprint); continue; } if (e.fingerprint !== undefined && e.fingerprint !== null) { drift.queue(e.path, e.fingerprint, mount.mountId); } } const liveOnly = state.live_only_mounts ?? []; if (liveOnly.length > 0) { console.warn(`Workspace.load: ${String(liveOnly.length)} mount(s) opt out of snapshot replay; ` + `reads against them will serve current state with no drift detection: ` + liveOnly.join(', ')); } } /** * Drop the pin at `path` and every pin beneath it. * * Normalizes the probe, never the stored key: a mount-root op is spelled * `/s3` here and `/s3/` in python, and an unnormalized prefix test would * drop nothing in one language and a whole mount in the other. The stored * keys stay as recorded, so the snapshot's `path` values are unchanged. */ function dropPin(out, path, subtree, owner) { const base = rstripSlash(path); out.delete(base); if (!subtree) return; // A nested mount's keys live in a different backend, so an op on the // parent never touched them. That matters most for a mount at '/', // where `base` is '' and every virtual path is "under" it: an // unbounded sweep there would drop every other mount's pins and // silently lose their drift check. const prefix = `${base}/`; for (const [key, entry] of [...out.entries()]) { if (!key.startsWith(prefix)) continue; if (owner !== null && entry.mount_prefix !== owner) continue; out.delete(key); } } /** * Walk recorded ops and emit one pin per path still worth checking. * * A single forward pass over the time-ordered records, so the last word on * a path wins. Three things can be that last word: * * - an op that removed or replaced the object (`RETRACT_FINGERPRINT_OPS`) * drops the pin, and every pin beneath it — `rm -r` and a prefix rename * take a subtree with them; * - an op that changed the bytes without describing them — a write whose * backend returned no token, or any `append` — drops the pin too, * because the token on file no longer names what is there; * - an op carrying a token (`STAMP_FINGERPRINT_OPS`) replaces the pin * whole, never field-merging, so a read's revision cannot survive onto a * later write's fingerprint and pin replay to pre-write bytes. * * A read that reported no token changes nothing and leaves the pin alone. * Each token is what the backend returned at the moment the agent moved * the bytes, not a fresh stat at snapshot time. * * Paths on a mount that opts out of snapshot replay are never pinned; a * retraction still applies to them, because dropping a pin is the safe * direction and the mount a retraction names may no longer be the one that * set the pin. */ export function captureFingerprints(records, registry) { const out = new Map(); // By timestamp, not by position: a backend record reaches this list // only when its line ends, while a `Files` facade record appends as it // happens, so the list is flush-ordered and a retraction can otherwise // sit before the write it retracts. The sort is stable, so // same-millisecond records keep their order. for (const rec of [...records].sort((a, b) => a.timestamp - b.timestamp)) { if (RETRACT_FINGERPRINT_OPS.has(rec.op)) { // Resolved to bound the sweep, never to gate the drop: a // retraction whose mount has since gone still applies. const retracted = registry.tryMountFor(rec.path); dropPin(out, rec.path, SUBTREE_RETRACT_OPS.has(rec.op), retracted?.prefix ?? null); continue; } if (CONTENT_CHANGING_OPS.has(rec.op) && (!STAMP_FINGERPRINT_OPS.has(rec.op) || !(rec.fingerprint || rec.revision))) { // Dropped unless the token can actually be used below: an op // outside STAMP never reaches the stamping arm, so keeping its pin // would leave the pre-change token describing bytes that changed. // `append` is the live member of that shape. dropPin(out, rec.path, false, null); continue; } if (!STAMP_FINGERPRINT_OPS.has(rec.op)) continue; if (rec.fingerprint === null && rec.revision === null) continue; const mount = registry.tryMountFor(rec.path); if (mount === null || (rec.mountId !== null && rec.mountId !== mount.mountId)) continue; if (!mount.vfs.supportsSnapshot) continue; const entry = { path: rec.path, mount_prefix: mount.prefix }; if (rec.fingerprint !== null) entry.fingerprint = rec.fingerprint; if (rec.revision !== null) entry.revision = rec.revision; out.set(rec.path, entry); } return [...out.values()]; } /** * Return mount prefixes whose VFS opts out of snapshot replay. * * These mounts will serve current state at load time with no drift * detection. Surfaced in the snapshot manifest so the load layer can * log them and so users can audit which paths are non-replayable. */ export function liveOnlyMountPrefixes(registry) { const out = []; for (const m of registry.allMounts()) { if (m.prefix === '/dev/' || m.prefix === '/.bash_history/' || m.prefix === '/usr/bin/') continue; // A document binding is not saved at all, so a load never serves it // live either. if (m.vfs.name === 'document') continue; if (!m.vfs.supportsSnapshot) out.push(m.prefix); } return out; } /** * Stat `path` and throw {@link ContentDriftError} if the live fingerprint * does not match `recorded`. No-op if the mount cannot be resolved or the * VFS cannot fingerprint. * * The caller provides `statFn` (typically a thin wrapper over * {@link Workspace.dispatch}) so that drift.ts stays decoupled from the * workspace's op-resolution machinery. */ export async function checkDrift(registry, statFn, path, recorded, mountId = null) { const mount = registry.tryMountFor(path); if (mount === null || (mountId !== null && mount.mountId !== mountId)) return; if (!mount.vfs.supportsSnapshot) return; let stat; try { stat = (await statFn(path)); } catch (err) { const code = err?.code; if (code === 'ENOENT' || code === 'ENOTDIR') { if (registry.tryMountFor(path) !== mount) return; throw new ContentDriftError(path, recorded, null); } throw err; } if (registry.tryMountFor(path) !== mount) return; const live = stat.fingerprint; if (live === null) return; if (live !== recorded) throw new ContentDriftError(path, recorded, live); } //# sourceMappingURL=drift.js.map