UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

814 lines • 38.3 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 { z } from 'zod'; import { CacheEntry } from "../../cache/file/entry.js"; import { RAMFileCacheStore } from "../../cache/file/ram.js"; import { tokenOrNull } from "../../cache/file/utils.js"; import { buildCli } from "../../commands/cli/mcp/build.js"; import { CLI } from "../../commands/cli/types.js"; import { CommandSpec } from "../../commands/spec/types.js"; import { IOConfig } from "../../io/config.js"; import { MAX_MEMORY } from "../../io/memory.js"; import { EVENT_CLEAR, EVENT_COMMAND, EVENT_DELETE, EVENT_RESULT } from "../../observe/log_entry.js"; import { commandLimitsToJSON, parseCommandLimits } from "../../policy/builtin/output_cap.js"; import { profileFromDict, profileToDict } from "../../policy/profile.js"; import { ScriptSource } from "../../runtime/types.js"; import { Channel, exitOutcome, JobConsole, killedOutcome, RAMConsoleStore, } from "../../shell/console/index.js"; import { BIN_PREFIX } from "../../shell/constants.js"; import { Job, JobStatus } from "../../shell/job_table/index.js"; import { DEFAULT_READ_SPEC, MountMode, VFSName, } from "../../types.js"; import { normDir } from "../../utils/slash.js"; import { VERSION } from "../../version.js"; import { BaseVFS } from "../../vfs/base.js"; import { HISTORY_PREFIX } from "../../vfs/history/history.js"; import { RAMVFS } from "../../vfs/ram/ram.js"; import { hasRedactedSecret, redactConfigWithSchema, vfsStateRequiresOverride, } from "../../vfs/secrets.js"; import { WritePolicyError } from "../mount/errors.js"; import { metaFromFields, metaToFields, } from "../mount/namespace/namespace.js"; import { resolveReadSpec } from "../mount/read_policy.js"; import { Mount } from "../mount/spec.js"; import { coerceWritePolicy } from "../mount/write_policy.js"; import { narrow, narrowingOf, stricterModes } from "../session/resolve.js"; import { SessionState, varsFromFields, varsToFields } from "../session/session.js"; import { setCwd } from "../session/shell_dirs.js"; import { gateRestoredVars } from "../session/state.js"; import { indexConfigDump, restoreIndexConfig } from "./config.js"; import { MCPKey } from "./keys.js"; import { captureFingerprints, liveOnlyMountPrefixes } from "./drift.js"; import { readRegular } from "./fs.js"; import { FORMAT_VERSION } from "./utils.js"; const VALID_MODES = [MountMode.READ, MountMode.WRITE, MountMode.EXEC]; export async function toStateDict(ws) { const skip = new Set(['/dev/', normDir(HISTORY_PREFIX), normDir(BIN_PREFIX)]); const mounted = [...ws.registry.allMounts()]; for (const mount of mounted) await mount.ensureReady(); const mounts = mounted.filter((m) => !skip.has(m.prefix) && m.vfs.name !== 'document'); const mountSnapshots = []; for (let i = 0; i < mounts.length; i++) { const m = mounts[i]; if (m === undefined) continue; // The VFS is no longer cast into a shape that promises getState: // the contract carries it, so a VFS missing one fails to compile // rather than throwing here at save time. What remains narrows the // returned state to the snapshot format's union. const state = (await m.use(() => Promise.resolve(m.vfs.getState()))); const dumped = indexConfigDump(m.indexConfig); mountSnapshots.push({ index: i, prefix: m.prefix, mode: m.mode, read: m.read.policy, ttl: m.read.ttl, write: m.write, vfs_class: m.vfs.name, vfs_ref: m.vfsRef, // A scratch root saves only an index of its own, as Python's does. index_config: ws.syntheticRoot && m.prefix === '/' && m.inheritsIndex ? null : dumped, vfs_state: state, // The scratch root nobody mounted keeps its files across the round // trip, and a load marks it the scratch root again. Mirrors python. ...(ws.syntheticRoot && m.prefix === '/' ? { anchor: true } : {}), ...(m.commandLimits.size > 0 ? { command_limits: commandLimitsToJSON(Object.fromEntries(m.commandLimits)) } : {}), }); } const ramCache = ws.cache instanceof RAMFileCacheStore ? ws.cache : null; const cacheEntries = ramCache !== null ? ramCache .snapshotEntries() .filter(({ entry }) => entry.hasBytes) .map(({ key, entry }) => ({ key, data: ramCache.store.files.get(key) ?? new Uint8Array(), fingerprint: entry.fingerprint, ttl: entry.ttl, cached_at: entry.cachedAt, size: entry.size, })) : []; const sessions = ws.sessionManager .list() .map((s) => s.toDict()); // The lists live in the job store, where other workspaces on it add jobs // this one has not read yet. A killed job is finished only once its // console ended: its runner may still leave output and a status. for (const s of ws.sessionManager.list()) await ws.jobTable.sync(s.sessionId); const jobs = await Promise.all(ws.jobTable .allJobs() .filter((j) => j.status !== JobStatus.RUNNING && (j.process === null || j.console.finished)) .map((j) => jobToDict(j))); const clisState = [...ws.registry.clis.items()].map(([name, install]) => cliSnapshot(name, install)); const historyEvents = (await ws.observer.events()).filter((e) => e.type === EVENT_COMMAND || e.type === EVENT_RESULT || e.type === EVENT_CLEAR || e.type === EVENT_DELETE); const current = ws.registry.allMounts(); if (current.length !== mounted.length || mounted.some((m, i) => m !== current[i] || m.retiring)) { throw new Error('mounts changed during snapshot'); } const fingerprints = captureFingerprints(ws.records, ws.registry); const liveOnly = liveOnlyMountPrefixes(ws.registry); const nodes = {}; for (const [path, meta] of ws.namespace.nodes) nodes[path] = metaToFields(meta); return { version: FORMAT_VERSION, io: { buffer_bytes: ws.registry.io.bufferBytes }, mirage_version: VERSION, write: ws.writeDefault, default_session_id: ws.sessionManager.defaultId, sessions, env: varsToFields(ws.sessionManager.seedVars), mounts: mountSnapshots, cache: { limit: ws.cache.cacheLimit, max_drain_bytes: ramCache !== null ? ramCache.maxDrainBytes : null, entries: cacheEntries, }, history: historyEvents, jobs, fingerprints, live_only_mounts: liveOnly, nodes, clis: clisState, // The profile table and the limits are what every session here // runs under: a load built without them ran each one unconfined. profiles: Object.fromEntries(Object.entries(ws.profiles).map(([name, profile]) => [name, profileToDict(profile)])), profile: ws.defaultProfileName, command_limits: commandLimitsToJSON(ws.registry.commandLimits), max_memory: ws.registry.memory?.limit ?? null, network: ws.network, system_time: ws.systemTime, }; } /** * The spec a snapshot entry restores: a registry key or a program. * * Throws when the captured script names a language no runtime can * speak. The value comes from a file, so it is checked here rather than * carried to the selector, which would report the world's runtimes for * a language that never existed. */ function cliSpecFromEntry(entry) { const source = entry.mcp; if (source !== undefined) { return buildCli({ title: source[MCPKey.TITLE], tools: source[MCPKey.TOOLS].map((tool) => ({ name: tool[MCPKey.NAME], description: tool[MCPKey.DESCRIPTION], inputSchema: tool[MCPKey.INPUT_SCHEMA], annotations: tool[MCPKey.ANNOTATIONS], })), }); } if (entry.script === undefined) return entry.spec; const language = entry.script.language; if (language !== 'python' && language !== 'js') { throw new Error(`snapshot cli '${entry.spec}': unknown script language '${language}'`); } return new CLI({ spec: new CommandSpec({ name: entry.spec }), script: new ScriptSource(entry.script.source, language, entry.script.module), runtime: entry.runtime ?? null, }); } function cliConfigDump(install) { const model = install.cli.configModel; if (model instanceof z.ZodObject) { return redactConfigWithSchema(model, install.config); } // A script's config is opaque, so it is captured verbatim rather than // guessed at; the config loader refuses a secrets pointer in one for // exactly this reason (`validateConfigKeys`), since resolved, the // value would sit in this capture. if (install.config !== null && typeof install.config === 'object') { return install.config; } return null; } /** One installed CLI as snapshot state. */ function cliSnapshot(name, install) { const entry = { name, spec: install.cli.spec.name, config: cliConfigDump(install), }; // A script install has no name to resolve (the spec is synthesized // from a yaml `script:`), so its embedded program rides along and // load rebuilds the spec from it. const script = install.cli.script; if (script !== null) { entry.script = { source: script.source, language: script.language, module: script.module, }; entry.runtime = install.cli.runtime; } // An MCP install's tool list rides along, so load rebuilds it with no request. const source = install.cli.mcp; if (source !== null) { entry.mcp = { [MCPKey.TITLE]: source.title, [MCPKey.TOOLS]: source.tools.map((tool) => ({ [MCPKey.NAME]: tool.name, [MCPKey.DESCRIPTION]: tool.description, [MCPKey.INPUT_SCHEMA]: { ...tool.inputSchema }, [MCPKey.ANNOTATIONS]: { ...tool.annotations }, })), }; } return entry; } /** * Refuse a snapshot in another format than this loader's, older or newer. * * An absent version is v3 or older, not "current". It used to be * harmless because every key the loader read had a default; v4 makes the * read policy required, so an unversioned dict would land on a bad * ReadSpec instead of this message. * * Both entry points run it, mirroring Python's `check_format_version`. * `buildMountArgs` builds a workspace from the state; `applyStateDict` * restores into one that already exists, and is what `version checkout`, * `version restore` and the agent sandbox's hydrate call. Checking in one * entry point only meant the same bytes were refused through `Workspace.load` * and half-restored through a checkout. */ export function checkFormatVersion(state) { // Widened deliberately: the type says `version` is always present, but a // dict written before the field existed comes back from JSON without it. const saved = state.version; // A newer snapshot may carry keys this loader would drop. if (saved === undefined || saved !== FORMAT_VERSION) { const shown = saved === undefined ? 'unversioned' : `v${String(saved)}`; throw new Error(`snapshot format ${shown} not supported (loader expects v${String(FORMAT_VERSION)})`); } } /** A saved policy as its refusal names it: as Python's str() for strings and integers. */ function savedName(value) { return typeof value === 'string' ? value : String(value); } export function buildMountArgs(state, overrides = {}, cliOverrides = {}, userOverrides) { checkFormatVersion(state); const normalized = {}; for (const [prefix, vfs] of Object.entries(overrides)) { normalized[normDir(prefix)] = vfs; } const userPrefixes = userOverrides ?? new Set(Object.keys(normalized)); // A mount with no override by now is one nobody can build: it asked to // be handed back live or was saved with a redacted secret, or the // registry `withRebuiltMounts` consulted had nothing for its ref or // type. Only a mount this builder restores itself is exempt. Refusing // is what Python's `requires_vfs_override` does for a class it // cannot import; an empty RAMVFS in its place would lose the // backend without a word. const missing = state.mounts .filter((m) => normalized[normDir(m.prefix)] === undefined && (vfsStateRequiresOverride(m.vfs_state) || !restoresAsFreshRAM(m))) .map((m) => m.prefix); if (missing.length > 0) { throw new Error(`Workspace.load: mounts= must include overrides for: ${missing.join(', ')}. ` + `A listed mount was saved with redacted credentials, asked to be handed back live ` + `(needs_override), or names a VFS this registry cannot build; register its ` + `factory (register) or pass a live instance.`); } const mountArgs = {}; let anchored = false; for (const m of state.mounts) { if (!VALID_MODES.includes(m.mode)) { throw new Error(`Workspace.fromState: mount '${m.prefix}' has invalid mode '${m.mode}'`); } const override = normalized[normDir(m.prefix)]; // The saved scratch root is restored like any prefix, then marked. const root = normDir(m.prefix) === '/' && m.anchor === true; anchored ||= root; const placed = override instanceof Mount ? override : null; // Required, never defaulted: a dict labelled v4 with the key missing // would install a default on a mount saved carrying something else, // and a junk policy or a null bound would restore a mount whose cache // can never serve. `mode` three lines up is validated the same way. // Widened deliberately: the type says both keys are present, but a // dict written by a foreign or older writer comes back from JSON // without them. const entry = m; if (entry.read === undefined || entry.ttl === undefined) { throw new Error(`Workspace.fromState: mount '${m.prefix}' is missing its read policy; ` + `regenerate the snapshot`); } // Coerced whatever happens, so a junk policy or a non-positive bound // is refused here rather than restoring a mount whose cache can // never serve -- even on a prefix whose spec is then discarded. const savedSpec = resolveReadSpec(entry.read, entry.ttl); // The saved policy belongs to the backend that was saved, so it // applies only where that backend is what is being mounted. A mount // handed back through the caller's `mounts=` -- which a // redacted-credential mount *must* be -- may be a different backend // entirely, and carrying `fresh` onto one that cannot revalidate // would refuse a restore that used to succeed. The override keeps its // own policy, else the default. Everything else here the loader // reconstructs from the saved state itself, which is the same // backend. Python reads it off the overrides it was handed; here // `fromState` merges its rebuilds into the same map first, so it names // its callers' set. const read = userPrefixes.has(normDir(m.prefix)) ? (placed?.options.read ?? DEFAULT_READ_SPEC) : savedSpec; // The saved policy survives an override, as `mode` does. const savedWrite = m.write; if (savedWrite === undefined || savedWrite === null || savedWrite === '') { throw new WritePolicyError(`Workspace.fromState: mount '${m.prefix}' is missing its write policy; ` + `regenerate the snapshot`); } const write = coerceWritePolicy(savedName(savedWrite)); const optionWrite = placed?.options.write; const asked = optionWrite !== undefined ? coerceWritePolicy(optionWrite) : undefined; if (asked !== undefined && asked !== write) { throw new WritePolicyError(`Workspace.fromState: mount '${m.prefix}' was saved write: ${write}; ` + `the override asks write: ${asked}`); } // The mount's own limits, its "@all" walk_limit among them, come back as // saved unless the override names its own. Mirrors python. // An override that names no limits keeps the saved ones, as Python's, // whose Mount cannot tell an omitted table from an empty one. const named = placed?.options.commandLimits; const commandLimits = named !== undefined && Object.keys(named).length > 0 ? named : parseCommandLimits(m.command_limits); // A prefix carries the label of what supplied its VFS, so a second // round trip rebuilds through the same entry point; a rebuild came // through the saved reference, so it keeps it. // The capacity a saved RAM or disk store held comes back with it, as // the `capacity` knob Python's `_construct_vfs` reads. const saved = m.vfs_state.config; const capacity = typeof saved?.capacity === 'number' ? saved.capacity : null; const [vfs, vfsRef] = override === undefined ? [new RAMVFS({ capacity }), savedRef(m)] : userPrefixes.has(normDir(m.prefix)) || override instanceof Mount ? placedVfs(override, m) : [override, savedRef(m)]; const index = restoreIndexConfig(m.index_config, placed?.options.index, m.prefix); mountArgs[m.prefix] = new Mount(vfs, { mode: m.mode, read, write, commandLimits, ...(index === undefined ? {} : { index }), vfsRef, }); } const cliEntries = state.clis ?? []; const missingClis = cliEntries .filter((e) => hasRedactedSecret(e.config) && !(e.name in cliOverrides)) .map((e) => e.name); if (missingClis.length > 0) { throw new Error(`Workspace.load: clis= must include fresh configs for: ${missingClis.join(', ')}. ` + `These CLIs were saved with redacted config secrets.`); } const cliArgs = {}; for (const e of cliEntries) { const override = cliOverrides[e.name]; if (Array.isArray(override)) { // copy() shares the live spec alongside the revealed config, so a // directly installed (never registry-named) spec survives the // round trip like a shared live VFS. cliArgs[e.name] = override; } else { cliArgs[e.name] = [cliSpecFromEntry(e), override ?? e.config]; } } const savedDefault = state.write; if (savedDefault === undefined || savedDefault === null || savedDefault === '') { throw new WritePolicyError('Workspace.fromState: the snapshot is missing its workspace write policy; ' + 'regenerate the snapshot'); } return { mountArgs, defaultSessionId: state.default_session_id, writeDefault: coerceWritePolicy(savedName(savedDefault)), ...(cliEntries.length > 0 ? { clis: cliArgs } : {}), ...(anchored ? { anchored } : {}), io: state.io === undefined ? new IOConfig() : new IOConfig({ bufferBytes: state.io.buffer_bytes }), commandLimits: parseCommandLimits(state.command_limits), profiles: Object.fromEntries(Object.entries(state.profiles ?? {}).map(([name, doc]) => [name, profileFromDict(doc)])), profile: state.profile ?? null, maxMemory: state.max_memory === undefined ? MAX_MEMORY : state.max_memory, network: state.network ?? true, systemTime: state.system_time ?? null, }; } /** * The VFS an override places at a saved mount, with its label. A `Mount` * names the entry point it came through. A bare VFS of the saved kind * keeps the saved reference; one of another kind has none, since the * saved one names a backend it is not. Mirrors Python `_placed_vfs`, which * compares the class: a snapshot here records only the VFS's `name`, so a * subclass that keeps its parent's name counts as the parent's kind. */ function placedVfs(override, entry) { if (override instanceof Mount) return [override.vfs, override.options.vfsRef ?? null]; return [override, override.name === entry.vfs_class ? savedRef(entry) : null]; } /** * The `vfs_ref` a saved mount was built from, or null: for one * constructed in code, and for a v3 snapshot written before the key * existed, which carries none (the format version did not move). */ function savedRef(entry) { return entry.vfs_ref ?? null; } /** * Whether `buildMountArgs` restores a saved mount itself, into a fresh * RAMVFS, so no registry is asked about it: `disk` (its content * rides the state, and reopening the original root is exactly what a * restore must not do), and `ram` declared by its builtin name or * constructed in code. A `ram` mount whose ref points elsewhere is an * alias registered over RAMVFS, and rebuilds through that alias so * the subclass survives; Python's `_construct_vfs` calls `cls()` on * the class its ladder found for the same reason. */ export function restoresAsFreshRAM(entry) { const type = entry.vfs_state.type; if (type === 'disk') return true; const ref = savedRef(entry); return type === 'ram' && (ref === null || ref === type); } /** * What a saved mount asks a registry to build: the name and config, or * null when the registry has nothing to say. The `vfs_ref` the * registry built the mount from when one was recorded (a registered name, * or a code reference, which is how a mount declared as * `./wiki.mjs:WikiVFS` comes back), else the VFS's `type`, the * one locator a VFS constructed in code leaves. The ref comes first * because `type` is the class's `name` and a subclass inherits it: an * alias registered over a builtin reports the builtin's type and rebuilt * as the builtin while the type was consulted first. A recorded ref this * registry cannot resolve is not a reason to fall back to that guess: the * answer is null, and `buildMountArgs` then asks for the mount live. */ export function savedVfsBuild(entry, known) { if (restoresAsFreshRAM(entry)) return null; const type = entry.vfs_state.type; const ref = savedRef(entry); const name = ref !== null ? (known(ref) || ref.includes(':') ? ref : null) : known(type) ? type : null; if (name === null) return null; const config = entry.vfs_state.config; return { name, config: typeof config === 'object' && config !== null && !Array.isArray(config) ? config : {}, }; } /** * The overrides `buildMountArgs` restores with: the caller's, plus every * mount the builder can rebuild from its saved state. A mount that asks * to be handed back live (`vfsStateRequiresOverride`) is never * built here, so the refusal `buildMountArgs` raises for it stands. */ export async function withRebuiltMounts(state, overrides, build) { checkFormatVersion(state); const merged = { ...overrides }; const held = new Set(Object.keys(overrides).map(normDir)); for (const m of state.mounts) { if (held.has(normDir(m.prefix))) continue; if (vfsStateRequiresOverride(m.vfs_state)) continue; const built = await build(m); if (built !== null) merged[m.prefix] = built; } return merged; } /** * Restore post-construction state into an already-built Workspace. * * Every session table and the env template clear the target's * `preSession` gate first (`gateRestoredState`), before any mount, * session or template lands, so a refusal aborts the load with the * workspace as it was. A snapshot mount with no mount at that exact * prefix here is not restored and is reported. `replaceCache` drops * the live cache once the gate has passed, ahead of the mounts' * loadState, so the snapshot's entries are all that is left: a * checkout onto a running workspace asks for it, a workspace built for * the state has nothing to drop. It sits behind the gate because the * callers used to clear before calling, and a refused checkout then * still sent every cached read back to an origin that may have moved. * `builtForState` says the workspace was just built from this state, * under its profile table, and has run nothing: each session then lands * the narrowing and ask decisions its table recorded, as a stored record * hydrates, where a checkout onto a running workspace keeps its own * sessions' narrowing and decisions, and a table's mount caps only narrow * them. `shared` names the prefixes whose mount a copy shares with its * origin: their content is already there, so no state is loaded into * them. * The target's VFS.md and SKILL.md bindings are dropped, since a * snapshot never carries them. Mirrors Python `apply_state_dict`. */ export async function applyStateDict(ws, state, options = {}) { checkFormatVersion(state); const [sessions, seed] = await gateRestoredState(ws, state, options.builtForState === true); // A snapshot holds no document bindings, so a load into a live // workspace drops its own: one left in place would shadow a file the // snapshot restores at the same path. await ws.documents.clear(); if (options.replaceCache === true) await ws.cache.clear(); // Every state is prepared before any mount loads, so a captured disk // file that is gone or now a link fails the load with no mount changed. const loads = []; for (const m of state.mounts) { // Exact-prefix lookup, mirroring Python: a snapshot prefix the new // workspace does not mount is skipped, never resolved to an // ancestor mount (which would load state into the wrong VFS). // loadState runs for every mount, an overridden one included, so a // disk's content is written into the new root and redis content into // the new URL; a mount keeping no content has a no-op loadState. Only // a mount a copy shares is left as it is: replaying the captured bytes // would undo what another writer did to it since. const mount = ws.registry.tryMountForPrefix(m.prefix); if (mount === null) { // Said out loud: a renamed or missing mount otherwise left no trace. console.warn(`Workspace.load: snapshot mount ${m.prefix} has no mount at that prefix in this ` + `workspace; its state was not restored`); continue; } if (options.shared?.has(mount.prefix) === true) continue; // A disk restored into another mount that keeps content (one // overriding loadState, as the fresh RAM stand-in of // `restoresAsFreshRAM`, redis and OPFS do, under any name) takes the // disk's state in RAM's shape; a mount keeping none reads no file. const vfsState = m.vfs_state.type === VFSName.DISK && mount.vfs.name !== VFSName.DISK && mount.vfs.loadState !== BaseVFS.prototype.loadState ? await diskStateAsRam(m.vfs_state) : m.vfs_state; loads.push([mount.vfs, vfsState]); } // No cast, for the same reason as toStateDict above. for (const [vfs, vfsState] of loads) await Promise.resolve(vfs.loadState(vfsState)); await restoreSessions(ws, state, sessions); // The env template is constructor state the rebuilt workspace was // never given: without it a session created after the load starts // bare while restored ones carry every workspace env entry. if (seed !== null) ws.sessionManager.restoreSeed(seed); restoreCache(ws, state); await restoreHistory(ws, state); await restoreJobs(ws, state); await restoreNodes(ws, state); } async function restoreNodes(ws, state) { const entries = new Map(); for (const [path, e] of Object.entries(state.nodes ?? {})) { entries.set(path, metaFromFields(e)); } await ws.namespace.replaceNodes(entries); } /** * Vet every env input the snapshot carries before any of it lands. * * Each session table and the env template fire the `preSession` gate * (`gateRestoredVars`) here, ahead of the mounts' loadState and the * session writes: a refusal that arrived once an earlier session had * already been overwritten left the workspace in a state no snapshot * describes, and one its close then persisted. The template is gated * under the id the restore makes the default session, which is the * session a live write of it would land in. On a checkout each table is * judged under the policy the target gives its session, never the one * the snapshot's own profile compiled, which was the source deployment's * and does not land: the live session's for an id the target already * has, and the default profile's for one the restore will create, which * is what `scriptsOf` answers for an id the manager does not know and * the profile `restoreSessions` then puts the created session under. A * workspace built for the state seats every table first, its narrowing, * ask decisions, cwd and variables, so each is judged as the session it * records, under the policy it then runs under. * Returns the parsed session tables and the template, null when the * snapshot carries none. Mirrors Python `_gate_restored_state`. */ async function gateRestoredState(ws, state, builtForState = false) { const sessions = state.sessions.map((s) => SessionState.fromDict(s)); if (builtForState) { const manager = ws.sessionManager; for (const fields of sessions) { const sid = fields.sessionId; const session = manager.has(sid) ? manager.get(sid) : manager.create(sid); narrow(session, narrowingOf(fields)); session.decisions = fields.decisions; setCwd(session, fields.cwd); session.vars = fields.vars; } } for (const fields of sessions) { await gateRestoredVars(ws.registry.policies, fields.sessionId, fields.vars); } if (state.env === undefined || Object.keys(state.env).length === 0) return [sessions, null]; const seed = varsFromFields(state.env); await gateRestoredVars(ws.registry.policies, state.default_session_id ?? ws.defaultSessionId, seed); return [sessions, seed]; } async function restoreSessions(ws, state, tables) { // The snapshot's default session identity wins over the live one, // and the discovery record's pointer follows it. A state without the // pointer (older commit metas) keeps the live default, mirroring the // Python None-guard. ws.forgetReads(); if (state.default_session_id != null) { await ws.adoptDefaultSession(state.default_session_id); } const restored = []; for (const fields of tables) { const exists = ws.sessionManager.has(fields.sessionId); const session = exists ? ws.sessionManager.get(fields.sessionId) : ws.sessionManager.create(fields.sessionId); if (!exists) { // A session the restore creates is one created without a profile // name, so it runs under the document's default: the policy // `gateRestoredState` judged its table under, where a bare session // ran under none. const compiled = ws.sessionManager.defaultProfile; if (compiled !== null) narrow(session, compiled); } setCwd(session, fields.cwd); session.vars = fields.vars; session.functions = fields.functions; // A restored function runs as a parse of its own, as one loaded from // storage does: the sites of what it replaced are gone. session.functionSites.clear(); session.readonlyFunctions = fields.readonlyFunctions; session.exportedFunctions = fields.exportedFunctions; // The caps are narrowing, which a checkout keeps: the table's narrow // the session's further and never lift one it holds. session.mountModes = stricterModes(session.mountModes, fields.mountModes); restored.push(session); } // The snapshot's session table wins over prior store contents, // mirroring Namespace.replaceNodes. await ws.sessionManager.replaceFromSnapshot(restored); } function restoreCache(ws, state) { if (!(ws.cache instanceof RAMFileCacheStore)) return; // A snapshot is a third entry point into the entry table, and a document is not // obliged to spell "no token" the way this version does, so each token is // folded the way the live write entry points fold it. for (const e of state.cache.entries) { ws.cache.loadEntry(e.key, e.data, new CacheEntry({ size: e.size, cachedAt: e.cached_at, fingerprint: tokenOrNull(e.fingerprint), ttl: e.ttl, })); } } async function restoreHistory(ws, state) { // Always load (loadEvents clears first): a snapshot with empty history // still rewinds the recorder, same as the cache clear. Foreign-format // entries (e.g. a Python snapshot's different history shape) are skipped // inside loadEvents. await ws.observer.loadEvents(state.history ?? []); } /** * Serialize one finished job. * * Output is stored per channel rather than chunk by chunk: the manifest * externalizes byte fields into tar entries, so keeping chunks would * write one entry per write a job ever made. The cost is that a restored * job's stdout and stderr no longer interleave, which only affects jobs * that have already ended. */ async function jobToDict(j) { return { id: j.id, command: j.command, cwd: j.cwd, status: j.status, stdout: await j.console.snapshot(Channel.STDOUT), stderr: await j.console.snapshot(Channel.STDERR), exit_code: j.exitCode, created_at: j.createdAt, session_id: j.sessionId, execution_id: j.executionId, }; } /** Rebuild a finished job's console from its serialized output. */ function restoredConsole(j) { const chunks = []; for (const [channel, data] of [ [Channel.STDOUT, j.stdout], [Channel.STDERR, j.stderr], ]) { if (data.byteLength > 0) { chunks.push({ seq: chunks.length, ts: j.created_at, channel, data }); } } const outcome = j.status === JobStatus.KILLED ? killedOutcome(j.exit_code) : exitOutcome(j.exit_code); chunks.push({ seq: chunks.length, ts: j.created_at, channel: Channel.CONTROL, data: new TextEncoder().encode(outcome), }); const console = new JobConsole(new RAMConsoleStore(null, chunks), true); if (j.execution_id !== null && j.execution_id !== undefined) console.bindExecution(j.execution_id); return console; } function jobFromDict(j) { return new Job({ id: j.id, command: j.command, cwd: j.cwd, sessionId: j.session_id, createdAt: j.created_at, status: j.status, exitCode: j.exit_code, console: restoredConsole(j), }); } async function restoreJobs(ws, state) { for (const j of state.jobs) await ws.jobTable.load(jobFromDict(j)); } /** * A disk mount's state as a RAM mount takes it: absolute keys, every * parent directory, each mode as an attribute, and bytes for each file * the disk state names by host path, read through `readRegular` so a * file swapped for a link since capture is refused, never followed. */ async function diskStateAsRam(vfsState) { const files = {}; const attrs = {}; const dirs = new Set(['/']); const modes = vfsState.modes ?? {}; for (const [rel, data] of Object.entries(vfsState.files ?? {})) { const key = `/${rel}`; files[key] = typeof data === 'string' ? await readRegular(data) : data; const mode = modes[rel]; if (mode !== undefined) attrs[key] = { mode }; for (let at = key.lastIndexOf('/'); at > 0; at = key.lastIndexOf('/', at - 1)) { dirs.add(key.slice(0, at)); } } return { type: VFSName.RAM, files, dirs: [...dirs], attrs }; } /** Live mounts a copy should share with its origin. Mirrors Python's `reusable_mounts`. */ export function reusableMounts(mounts, state) { const overrides = {}; for (const mount of mounts) { for (const snap of state.mounts) { if (snap.prefix === mount.prefix && vfsStateRequiresOverride(snap.vfs_state)) { overrides[mount.prefix] = new Mount(mount.vfs, { read: mount.read, vfsRef: mount.vfsRef }); } } } return overrides; } /** * Live-install overrides a same-process copy reinstalls from: every CLI's * spec and validated config, the way remote mounts share their live * mounts, so a directly installed spec and a redacted secret both survive * without a registry lookup. */ export function reusableClis(ws) { const overrides = {}; for (const [name, install] of ws.registry.clis.items()) { overrides[name] = [install.cli, install.config]; } return overrides; } //# sourceMappingURL=state.js.map