UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

230 lines • 8.79 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 { VFSName } from "../types.js"; import { underPath } from "../utils/key_prefix.js"; // Ops whose record carries a token describing the bytes it moved, split // by direction: the file cache stores bytes from either `IOResult.reads` // or `IOResult.writes` and must ask about the side it took, since one // line can carry both for a path. // // `create` and `truncate` stamp a token on their own record too, but // neither ever hands bytes to the cache: no command builder can ask for // a create (`Operation` has no member for it) and truncate's command // returns an empty IOResult, so no created or truncated path is ever // listed in `IOResult.cache`. A script runtime can still issue either // through `RuntimeFiles`, and those ops bubble into the enclosing line's // records, which is exactly why admitting them here could only pair one // op's token with another op's bytes. // 'append' is absent because no object store implements it and the // backends that record one stamp no token. // `truncate` would also need its record's `bytes` corrected before it // could join: it reports 0 while its token describes `length` bytes, so // the size check in `writtenVerdict` would refuse every one. export const READ_FINGERPRINT_OPS = new Set(['read']); export const WRITE_FINGERPRINT_OPS = new Set(['write']); // What snapshot drift capture asks instead, and it is a different question // from the cache's, so these are deliberately not the two sets above. // `STAMP_FINGERPRINT_OPS` is a superset: capture reads the record, not the // bytes, so it has none of the pairing problem that narrowed // `WRITE_FINGERPRINT_OPS` to one member. The three overlap on purpose -- a // write both describes and changes, and whether it carries a token is what // tells capture which. // // All three hold the op names a `record()` call spells, not the op-table // slots: the recursive delete is the `rm_recursive` slot but records as // 'rm_r', the rename op records as 'rename' or 'rename_prefix' // depending on which of its two paths ran, and a copy of a whole folder in // one request as 'copy_prefix'. export const STAMP_FINGERPRINT_OPS = new Set([ 'read', 'write', 'create', 'truncate', ]); export const CONTENT_CHANGING_OPS = new Set([ 'write', 'create', 'truncate', 'append', 'pwrite', ]); export const RETRACT_FINGERPRINT_OPS = new Set([ 'unlink', 'rm_r', 'rmdir', 'rename', 'rename_prefix', 'copy', 'copy_prefix', ]); // The subset that leaves no file at the path: the session saw it gone. export const REMOVAL_OPS = new Set(['unlink', 'rm_r', 'rmdir']); // Stamps name the bytes at a path; retracts mean the line no longer knows them. export const VERSION_OPS = new Set([ ...STAMP_FINGERPRINT_OPS, ...RETRACT_FINGERPRINT_OPS, ]); // The subset that moved a whole prefix, and so takes every pin beneath // it. Membership is what the op *did*, never what it could have done: // rename has two code paths and only one of them is a prefix walk, so it // spells them with two names. A point op must not take a subtree: it // touched one key, and on a keyed store the keys beneath its path are // objects of their own. export const SUBTREE_RETRACT_OPS = new Set([ 'rm_r', 'rename_prefix', 'copy_prefix', ]); /** * A line's records indexed as they arrive, for per-path version lookups. * Each lookup first takes in the records appended since the last one, so a * record added while a caller awaits is seen, and no record is read twice. * The records are only ever appended to. Mirrors Python's `RecordIndex`. */ export class RecordIndex { records; seen = 0; at = new Map(); subtree = []; constructor(records) { this.records = records; } absorb() { for (let i = this.seen; i < this.records.length; i++) { const rec = this.records[i]; if (rec === undefined) continue; if (VERSION_OPS.has(rec.op)) this.at.set(rec.path, i); if (SUBTREE_RETRACT_OPS.has(rec.op)) this.subtree.push(i); } this.seen = this.records.length; } /** * The newest record that says which version of `key` the line knows: one * at the path that stamps or retracts a version, or one at an ancestor * that moved the whole subtree, which took `key` with it. Mirrors * Python's `RecordIndex.newest_version`. */ newestVersion(key) { this.absorb(); const at = this.at.get(key) ?? -1; for (let j = this.subtree.length - 1; j >= 0; j--) { const i = this.subtree[j] ?? -1; if (i <= at) break; const rec = this.records[i]; if (rec !== undefined && underPath(key, rec.path)) return rec; } return at >= 0 ? (this.records[at] ?? null) : null; } } export class OpRecord { /** In-process transfer lifecycle; excluded from serialized observations. */ complete = true; executionId; op; path; source; bytes; timestamp; durationMs; fingerprint; revision; mountId; constructor(init) { this.executionId = init.executionId ?? null; this.op = init.op; this.path = init.path; this.source = init.source; this.bytes = init.bytes; this.timestamp = init.timestamp; this.durationMs = init.durationMs; this.fingerprint = init.fingerprint ?? null; this.revision = init.revision ?? null; this.mountId = init.mountId ?? null; } get isCache() { return this.source === VFSName.RAM; } toJSON() { return this.toDict(); } toDict() { return { op: this.op, path: this.path, source: this.source, bytes: this.bytes, timestamp: this.timestamp, durationMs: this.durationMs, fingerprint: this.fingerprint, revision: this.revision, ...(this.executionId === null ? {} : { execution_id: this.executionId }), }; } } /** * The paths whose conditional write lost on a line. A lost path's cached * copy was dropped; nothing the line read of it before the loss may be * cached again. The version the write lost on is the one a retry sends, so * it is refused again until a read. A read or write of the path after the * loss names the bytes now there, and lifts the mark. Mirrors Python's * `LostPaths`, which rides the recorder; here it is keyed by the line's * records, since `keepVersions` runs after the recording scope ends. */ export class LostPaths { records; marks = new Map(); /** The marks made so far. */ count = 0; constructor(records) { this.records = records; } mark(key, version = null) { this.count += 1; this.marks.set(key, { at: this.records.length, order: this.count, version: version !== null && version !== '' ? version : null, }); } /** * Lift the marks a removal or move of `key` (and, with `subtree`, below it) * ended: only those made by the time it began (`upto`, the `count` then), so * a refusal another command of the line made while it ran stays. */ lift(key, upto, subtree = false) { for (const [marked, mark] of [...this.marks]) { const covered = marked === key || (subtree && underPath(marked, key)); if (covered && mark.order <= upto) this.marks.delete(marked); } } /** The version a write to `key` lost on, while it is still lost. */ version(key) { return this.holds(key) ? (this.marks.get(key)?.version ?? null) : null; } holds(key) { const mark = this.marks.get(key); if (mark === undefined) return false; return !this.records .slice(mark.at) .some((rec) => rec.path === key && STAMP_FINGERPRINT_OPS.has(rec.op)); } } //# sourceMappingURL=record.js.map