@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
814 lines • 38.3 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 { 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