UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

310 lines • 12.2 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 { currentCaller, currentExecutionId, liveCallers, runWithCaller, } from "../context/session_context.js"; import { LostPaths, OpRecord, RecordIndex, STAMP_FINGERPRINT_OPS, } from "./record.js"; import { SystemClock } from "../utils/clock.js"; const SYSTEM_CLOCK = new SystemClock(); /** * Collect the records the running command itself emits. * * `fn` gets a fresh list that {@link record} and {@link recordStream} * append to, beside the line's records, while it runs. A nested call opens * its own list, and where async context isolates tasks (node) a concurrent * task keeps its own, so a pipeline stage never sees a sibling stage's * records; the browser's shared frame stack cannot promise that (see * `asyncContextIsolatesTasks`). Nothing is collected outside a recording * scope. Each record also reaches the list of the nearest call made with * `own`: a command's, which the dispatcher calls under it add to too, and * which takes nothing for the call around it, so a command reading * another's output is not charged with that command's reads. Mirrors * python's `command_records`. */ export async function commandRecords(fn, own = false) { const mine = []; const held = currentCaller().commandSink; const command = own ? mine : (held?.[0] ?? null); return runWithCaller({ ...currentCaller(), commandSink: [command, mine] }, () => fn(mine)); } /** * The version the running line itself names for `key`, with whether the line * knows `key` at all. A lost path names the version its write lost on; * otherwise the newest version record does, a stamp its token and a * retraction none. Mirrors Python's `line_version`. */ export function lineVersion(index, lost, key) { if (lost?.holds(key) === true) return [true, lost.version(key)]; const rec = index.newestVersion(key); if (rec === null) return [false, null]; const fingerprint = STAMP_FINGERPRINT_OPS.has(rec.op) ? (rec.fingerprint ?? '') : ''; return [true, fingerprint !== '' ? fingerprint : null]; } const lostByLine = new WeakMap(); const indexByLine = new WeakMap(); /** The version index of the line whose records these are; one per line. */ export function recordIndex(records) { let index = indexByLine.get(records); if (index === undefined) { index = new RecordIndex(records); indexByLine.set(records, index); } return index; } /** The lost paths of the line whose records these are, null outside a line. */ export function lostPaths(records) { if (records === undefined) return null; let lost = lostByLine.get(records); if (lost === undefined) { lost = new LostPaths(records); lostByLine.set(records, lost); } return lost; } /** Mark `key` lost on the running line, if one is recording. */ export function markLost(path, version = null) { currentCaller().write?.context?.lose?.(path, version); lostPaths(currentCaller().recording?.records)?.mark(path.virtual, version); } /** The running line's marks so far, for a later `liftLost`. Mirrors Python's `lost_count`. */ export function lostCount() { return lostPaths(currentCaller().recording?.records)?.count ?? 0; } /** Lift the running line's marks a removal or move of `key` ended. Mirrors Python's `lift_lost`. */ export function liftLost(path, upto, subtree = false) { lostPaths(currentCaller().recording?.records)?.lift(path.virtual, upto, subtree); } export function activeRecords() { return currentCaller().recording?.records; } /** * Run `fn` recording its calls, and answer what it returned with what it * recorded. `ledger` is the workspace ledger a line hands its records to, * which a file call of that workspace made on the line joins; `clock` is * the workspace clock its ops are timed and stamped on. */ export async function runWithRecording(fn, ledger = null, clock = SYSTEM_CLOCK) { const state = { records: [], mountId: null, ledger, clock }; const value = await runWithCaller({ ...currentCaller(), recording: state }, fn); return [value, state.records]; } /** * Run `fn` with `mountId` as the mount its records belong to. * * Derives a state for this async branch and shares only the records array, * so two mounts consumed concurrently (`cat /s3/a & cat /db/b`) cannot see * or clobber each other's mount. Mirrors python's `push_mount_context`, * whose `Recorder` is frozen and re-set per task for the same reason. * An undefined `mountId` inherits the enclosing frame's. * * Inert (runs `fn` unchanged) when no recording context is active. */ export function runWithMountContext(fn, mountId) { const state = currentCaller().recording; if (state === null) return fn(); return runWithCaller({ ...currentCaller(), recording: { ...state, mountId: mountId === undefined ? state.mountId : mountId, }, }, fn); } /** * Wrap a stream so `mountId` is the active mount during each pull from the * underlying source. A command may return a stream that defers its backend * read to the first chunk request, by which point the mount's own scope has * already exited, so without this the record lands under whatever frame * drains it. An iterator rather than a generator: a generator queues `return` * behind a pull waiting for the next chunk, so the close of an idle follow * would never reach the source whose own close ends that wait. Mirrors * python's `with_mount_context`. */ export function withMountContext(it, mountId) { const iter = it[Symbol.asyncIterator](); let ended = false; const done = { value: undefined, done: true }; return { async next() { if (ended) return done; try { const step = await runWithMountContext(() => iter.next(), mountId); if (step.done === true) ended = true; return step; } catch (err) { ended = true; await iter.return?.(undefined); throw err; } }, async return() { if (!ended) { ended = true; await iter.return?.(undefined); } return done; }, [Symbol.asyncIterator]() { return this; }, }; } // Whether a recording context is active. Backends that need an extra API // call to capture fingerprint/revision metadata (Drive, Graph) gate it on // this so unrecorded reads stay single-request. export function recordingActive() { return currentCaller().recording !== null; } /** * A running stopwatch for one op, owned by the record path. * * Opened where the backend work begins and read once when the op * finishes, so an op module hands this around instead of reading a * clock of its own. The wall-clock stamp the record carries is taken at * finish time, not here, on the same clock. Mirrors python's `OpTimer`. */ export class OpTimer { clock; startMs; constructor(clock) { this.clock = clock; this.startMs = clock.monotonic() * 1000; } /** Milliseconds elapsed since the timer was opened. */ get elapsedMs() { return Math.floor(this.clock.monotonic() * 1000 - this.startMs); } } /** * Open the record path's stopwatch for one op. Hand the timer to * {@link record} or {@link finishRecord} when the op completes. The timer * runs on the clock of the line recording the op; outside a recording * nothing keeps the record, and the system clock times it. */ export function startOp() { return new OpTimer(currentCaller().recording?.clock ?? SYSTEM_CLOCK); } /** * Close `timer` and build the finished record. * * The one place an op's duration and wall-clock stamp are read, both on the * timer's clock, shared * by the recorder sink ({@link record}) and by the `Files` facade's own * ledger, so the two cannot disagree about what a duration measures. * `path` is stored as given. */ export function finishRecord(op, path, source, nbytes, timer, options = {}) { const elapsed = timer.elapsedMs; return new OpRecord({ op, path, source, bytes: nbytes, timestamp: Math.floor(timer.clock.now() * 1000), durationMs: elapsed, fingerprint: options.fingerprint ?? null, revision: options.revision ?? null, mountId: currentCaller().recording?.mountId ?? null, executionId: currentExecutionId(), }); } function charge(rec) { if (rec.op !== 'read') currentCaller().write?.context?.observe?.(rec); const held = currentCaller().commandSink; if (held !== null) { held[1].push(rec); if (held[0] !== null && held[0] !== held[1]) held[0].push(rec); } } /** * Append a finished record to the active recording, if any. * * `path`: the full virtual path. */ export function record(op, path, source, nbytes, timer, options = {}) { const state = currentCaller().recording; if (state === null) return; const rec = finishRecord(op, path, source, nbytes, timer, options); state.records.push(rec); charge(rec); } /** * Append a streaming record whose bytes are filled in as it drains. * Set `complete` after normal exhaustion, never after failure or early close. * * `path`: the full virtual path. */ export function recordStream(op, path, source, options = {}) { const state = currentCaller().recording; if (state === null) return null; const rec = new OpRecord({ op, path, source, bytes: 0, timestamp: Math.floor(state.clock.now() * 1000), durationMs: 0, fingerprint: options.fingerprint ?? null, revision: options.revision ?? null, mountId: currentCaller().recording?.mountId ?? null, executionId: currentExecutionId(), }); rec.complete = false; state.records.push(rec); charge(rec); return rec; } /** * Run `fn` inside a revisions context. Backend read functions inside * `fn` (or any async chain it starts) can consult {@link revisionFor} * to look up a pin. Independent of {@link runWithRecording} so that * direct {@link Workspace.dispatch} calls (which don't open a recording * scope) still honour installed pins. * * Task-isolated via AsyncLocalStorage: concurrent runs on different * mounts each see their own pin map. */ export function runWithRevisions(revisions, fn) { return runWithCaller({ ...currentCaller(), revisions: { map: revisions } }, fn); } /** * Look up the active revision pin for `path`, or null if no pin is * installed (or no revisions context is active). * * Every live frame's map is searched, because pins are mount state * threaded through the context only for reach: each bind hands over * the mount's own map, keyed by full virtual path, so a hit is never * another task's different pin — the same mount binds the same map, * and another mount's map cannot hold this path. On the fallback * storage this is what keeps a pinned read pinned while an unpinned * op's frame shadows the newest slot. */ export function revisionFor(path) { for (const state of liveCallers().flatMap((caller) => caller.revisions === null ? [] : [caller.revisions])) { const pin = state.map?.get(path); if (pin !== undefined) return pin; } return null; } //# sourceMappingURL=context.js.map