@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
443 lines • 19.2 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 { releaseInputs } from "../../io/async_line_iterator.js";
import { SessionOwner } from "../../context/session_context.js";
import { ownRecord, SessionState, varsFromEntries, varsFromEnv } from "./session.js";
import { setCwd } from "./shell_dirs.js";
import { RAMNamespaceStore } from "../mount/namespace/ram.js";
import { applyProfile, narrow } from "./resolve.js";
import { CAS_MAX_RETRIES, generationOf } from "../record/types.js";
import { KeyLock } from "../../cache/lock.js";
import { SystemClock } from "../../utils/clock.js";
import { SystemIdSource } from "../../utils/ids.js";
/** Whether any of the session's variables carries a pointer. */
function holdsManaged(session) {
return Object.values(session.vars).some((v) => v.managed !== undefined);
}
/**
* Fill in template names a stored record predates.
*
* A record written before the workspace's env block gained an entry
* holds no var for the new name, so a session hydrated from the record
* alone could never reach the credential the deployment just
* configured. The record's own entries win per name -- an overwrite, a
* re-export, a stored pointer all round-trip untouched -- and only an
* absent name gains the seed. The records are frozen, so sharing them
* across sessions is safe.
*/
function mergeSeedVars(session, seedVars) {
for (const [name, seeded] of Object.entries(seedVars)) {
if (!(name in session.vars))
session.vars[name] = seeded;
}
}
/**
* Owns the live session table over a storage-agnostic NamespaceStore.
*
* The same store type the Namespace keeps its nodes in, one table each per
* workspace: sessions are worked on in memory (creation stays synchronous),
* the store hydrates once at the first async entry point, and durable fields
* flush back at async boundaries (end of execute, snapshot, explicit
* persist). `close`
* deletes from the store — closing a session revokes it everywhere —
* while process shutdown leaves stored sessions in place.
*/
export class SessionManager extends SessionOwner {
sessions = new Map();
persistLock = new KeyLock();
sessionStore;
defaultIdInternal;
loaded = false;
loadPromise = null;
// What the store last saw from us, per session id. Flush compares
// against this to skip clean sessions without a network read, and to
// avoid clobbering other writers. Kept as JSON strings: a string
// cannot alias the live session (Python needs a deep copy instead).
persisted = new Map();
defaultProfileInternal = null;
// The workspace's env block, translated: seeded onto the default
// session now and copied into every created session as its template.
// The records are frozen, so sharing them across sessions is safe;
// each session gets its own record.
seedVarsInternal;
hasManaged;
clock;
// The workspace's id source, which each session's `$RANDOM` first draws from.
ids;
/**
* @param clock the workspace clock a session's `createdAt` is read on;
* omitted means the system clock.
* @param ids the workspace's id source, which each session's `$RANDOM`
* first draws from; omitted means the system source.
*/
constructor(defaultSessionId, store, seedVars, clock, ids) {
super();
this.clock = clock ?? new SystemClock();
this.ids = ids ?? new SystemIdSource();
this.defaultIdInternal = defaultSessionId;
this.sessionStore = store ?? new RAMNamespaceStore();
this.seedVarsInternal = ownRecord(seedVars);
this.hasManaged = Object.values(this.seedVarsInternal).some((v) => v.managed !== undefined);
const dflt = new SessionState({
sessionId: defaultSessionId,
vars: ownRecord(this.seedVarsInternal),
createdAt: this.clock.now(),
});
dflt.ids = this.ids;
this.sessions.set(defaultSessionId, dflt);
}
/**
* True once any session may hold a managed variable.
*
* The fill step is skipped entirely while this is false, so it is
* sticky-true: set by the workspace's env block, a created session's
* own entries, a hydrated record, or a snapshot, and never cleared (a
* detached name costs nothing extra -- the fill pass finds nothing to
* fetch and returns).
*/
get hasManagedEnv() {
return this.hasManaged;
}
/** The env template a created session starts from, copied. */
get seedVars() {
return ownRecord(this.seedVarsInternal);
}
/**
* Install the env template a snapshot or copy carried over.
*
* The template is constructor state, so `fromState` rebuilds a
* workspace without it; existing sessions recover their own vars,
* but a session created afterward would start bare while its older
* siblings still carry every workspace env entry. Sticky on
* `hasManagedEnv`, like every other writer of it.
*/
restoreSeed(seedVars) {
this.seedVarsInternal = ownRecord(seedVars);
this.hasManaged =
this.hasManaged || Object.values(this.seedVarsInternal).some((v) => v.managed !== undefined);
}
/** The document's default profile, as compiled for this workspace. */
get defaultProfile() {
return this.defaultProfileInternal;
}
/**
* Shape the default session by the document's default profile. The
* workspace's own session is a session created without a name, so
* `profiles.default` reaches it the way it reaches `createSession(id)`:
* applied in full now (modes, hides, exported env, cwd), and its
* narrowing stamped again after hydration, where a record from before
* the profile existed would otherwise wake the primary agent
* unrestricted. null (no default profile) leaves the session, and
* hydration, as they were.
*/
set defaultProfile(compiled) {
this.defaultProfileInternal = compiled;
if (compiled === null)
return;
const session = this.defaultSession();
applyProfile(session, compiled);
this.hasManaged = this.hasManaged || holdsManaged(session);
}
/** Replace an existing, hydrated session's restrictions, preserving its scratch state. */
async setProfile(sessionId, compiled) {
return this.persistLock.withLock(sessionId, async () => {
const session = this.get(sessionId);
const candidate = SessionState.fromDict(session.toDict());
narrow(candidate, compiled);
await this.flushOne(candidate);
narrow(session, compiled);
session.generation = candidate.generation;
if (sessionId === this.defaultId)
this.defaultProfileInternal = compiled;
return session;
});
}
/**
* The admission rules one session runs under (SessionCommandsQuery).
* The default profile's rules for an id this manager does not know, the
* empty id of an unbound entry point included, so an entry point that names no
* session still fails toward refusal.
*/
commandsOf(sessionId) {
const session = this.sessions.get(sessionId);
return session === undefined
? (this.defaultProfileInternal?.policies.commands ?? null)
: session.commands;
}
/**
* The policy programs of the profile one session runs under, by
* method (SessionScriptsQuery).
* The default profile's for an id this manager does not know, the
* same fallback `commandsOf` makes and for the same reason: an entry point
* that names no session is judged like a session that named no
* profile.
*/
scriptsOf(sessionId) {
const session = this.sessions.get(sessionId);
return session === undefined
? (this.defaultProfileInternal?.policies.scripts ?? {})
: session.scripts;
}
/**
* The ledger records one session holds (SessionDecisionsQuery). Read off the
* registered session, never a fork, so a line running in a background
* copy sees the same answers.
*/
decisionsOf(sessionId) {
return this.get(sessionId).decisions;
}
/** Every session id holding ledger records (SessionDecisionsQuery). */
decisionSessions() {
return [...this.sessions.entries()].filter(([, s]) => s.decisions.length > 0).map(([id]) => id);
}
/** Replace one session's ledger records (SessionDecisionsQuery); durable at the next flush. */
setDecisions(sessionId, records) {
this.get(sessionId).decisions = records;
}
get store() {
return this.sessionStore;
}
get defaultId() {
return this.defaultIdInternal;
}
/**
* Re-key the default session to an externally decided id.
*
* Two callers: attach (the discovery record already names a default
* session, so the freshly minted placeholder re-keys before hydration
* lands the stored durable fields on it) and snapshot restore (the
* snapshot's default identity wins). The store itself is untouched;
* the next flush or snapshot replace writes the new key.
*/
adoptDefault(sessionId) {
if (sessionId === this.defaultIdInternal)
return;
this.persisted.delete(this.defaultIdInternal);
const existing = this.sessions.get(sessionId);
if (existing !== undefined) {
this.sessions.delete(this.defaultIdInternal);
}
else {
const session = this.defaultSession();
this.sessions.delete(this.defaultIdInternal);
session.sessionId = sessionId;
this.sessions.set(sessionId, session);
}
this.defaultIdInternal = sessionId;
}
get cwd() {
return this.defaultSession().cwd;
}
set cwd(value) {
setCwd(this.defaultSession(), value);
}
get env() {
return this.defaultSession().env;
}
set env(value) {
this.defaultSession().vars = varsFromEnv(value);
}
/**
* Hydrate sessions from the store once.
*
* Stored sessions fill in ids this process has not created; locally
* created sessions win a conflict (they overwrite the store on the
* next flush). The default session adopts the stored durable fields
* so a restarted daemon keeps its cwd/env.
*/
ensureLoaded() {
if (this.loaded)
return Promise.resolve();
this.loadPromise ??= this.hydrate();
return this.loadPromise;
}
async hydrate() {
const entries = await this.sessionStore.load();
for (const [sid, fields] of entries) {
const stored = SessionState.fromDict(fields);
if (sid === this.defaultId) {
const dflt = this.defaultSession();
setCwd(dflt, stored.cwd);
dflt.vars = stored.vars;
dflt.functions = stored.functions;
dflt.functionSites.clear();
dflt.readonlyFunctions = stored.readonlyFunctions;
dflt.exportedFunctions = stored.exportedFunctions;
dflt.createdAt = stored.createdAt;
dflt.mountModes = stored.mountModes;
// The hidden shapes are durable restrictions, not scratch
// state: dropping them here would wake a restarted daemon
// unrestricted and let the next flush erase them from the
// store.
dflt.visibility = stored.visibility;
dflt.hideReasons = stored.hideReasons;
dflt.commands = stored.commands;
dflt.scripts = stored.scripts;
// The host's standing answers are session state like cwd:
// dropped here, an approved line would ask again after a
// restart and the next flush would erase the grant from the
// store.
dflt.decisions = stored.decisions;
dflt.generation = stored.generation;
// Hydrated sessions start clean: baseline what the store
// holds so the next flush skips them.
this.persisted.set(sid, JSON.stringify(dflt.toDict()));
// The document outranks the record for the fields no line can
// edit; stamped after the baseline so a stale record is
// rewritten on the next flush.
if (this.defaultProfileInternal !== null)
narrow(dflt, this.defaultProfileInternal);
// Same order for the same reason: an env entry the record
// predates lands durably on the next flush.
mergeSeedVars(dflt, this.seedVarsInternal);
this.hasManaged = this.hasManaged || holdsManaged(dflt);
continue;
}
if (this.sessions.has(sid))
continue;
stored.ids = this.ids;
this.sessions.set(sid, stored);
this.persisted.set(sid, JSON.stringify(stored.toDict()));
mergeSeedVars(stored, this.seedVarsInternal);
this.hasManaged = this.hasManaged || holdsManaged(stored);
}
this.loaded = true;
}
/** Persist one session, or all sessions at a workspace boundary. */
async flush(sessionId) {
const sessions = sessionId === undefined ? [...this.sessions.values()] : [this.sessions.get(sessionId)];
for (const session of sessions) {
if (session === undefined)
continue;
await this.persistLock.withLock(session.sessionId, async () => {
if (this.sessions.get(session.sessionId) === session)
await this.flushOne(session);
});
}
}
/** Wait for admitted session writes before their store closes. */
async settle() {
for (const sessionId of this.sessions.keys()) {
await this.persistLock.withLock(sessionId, () => Promise.resolve());
}
}
/** Persist one session, retrying when another writer races us. */
async flushOne(session) {
const sid = session.sessionId;
// Clean: the store already has exactly this state.
if (JSON.stringify(session.toDict()) === this.persisted.get(sid))
return;
for (let attempt = 0; attempt < CAS_MAX_RETRIES; attempt++) {
const expected = session.generation;
session.generation = expected + 1;
const fields = session.toDict();
if (await this.sessionStore.casSet(sid, fields, expected)) {
this.persisted.set(sid, JSON.stringify(fields));
return;
}
// Lost the race: adopt the winner's generation and retry our
// content on top (last-writer-wins until a merge policy exists).
session.generation = expected;
const stored = (await this.sessionStore.load()).get(sid);
if (stored !== undefined) {
session.generation = generationOf(stored);
}
}
throw new Error(`session ${sid} flush kept conflicting with another writer`);
}
/**
* Adopt a snapshot's session table and replace the store. The
* snapshot wins over prior store contents, mirroring
* `Namespace.replaceNodes`.
*/
async replaceFromSnapshot(sessions) {
this.loaded = true;
this.loadPromise = Promise.resolve();
const entries = new Map();
for (const s of this.sessions.values())
entries.set(s.sessionId, s.toDict());
for (const s of sessions) {
entries.set(s.sessionId, s.toDict());
this.hasManaged = this.hasManaged || holdsManaged(s);
}
await this.sessionStore.replaceAll(entries);
this.persisted.clear();
for (const [sid, fields] of entries)
this.persisted.set(sid, JSON.stringify(fields));
}
/**
* Create a session, seeded with the workspace's env template.
* `options.env` is this session's own env entries, literal or
* managed, merged over the template (session entries win), and
* `options.profile` the profile it is created under, applied here so
* a pointer its env names counts toward `hasManagedEnv`.
*/
create(sessionId, options = {}) {
if (this.sessions.has(sessionId)) {
throw new Error(`Session ${sessionId} already exists`);
}
const seeded = ownRecord(this.seedVarsInternal);
if (options.env !== undefined)
Object.assign(seeded, varsFromEntries(options.env));
const session = new SessionState({
sessionId,
mountModes: options.mountModes ?? null,
vars: seeded,
createdAt: this.clock.now(),
});
session.ids = this.ids;
if (options.profile !== undefined)
applyProfile(session, options.profile);
this.hasManaged = this.hasManaged || holdsManaged(session);
this.sessions.set(sessionId, session);
return session;
}
get(sessionId) {
const s = this.sessions.get(sessionId);
if (s === undefined)
throw new Error(`unknown session: ${sessionId}`);
return s;
}
has(sessionId) {
return this.sessions.has(sessionId);
}
list() {
return [...this.sessions.values()];
}
async close(sessionId) {
await this.persistLock.withLock(sessionId, async () => {
if (sessionId === this.defaultId) {
throw new Error('Cannot close the default session');
}
const session = this.sessions.get(sessionId);
if (session === undefined) {
throw new Error(`unknown session: ${sessionId}`);
}
const keep = [...this.sessions.entries()]
.filter(([id]) => id !== sessionId)
.flatMap(([, other]) => other.openInputs());
await releaseInputs(session.openInputs(), keep);
this.sessions.delete(sessionId);
this.persisted.delete(sessionId);
await this.sessionStore.delete([sessionId]);
});
}
defaultSession() {
const s = this.sessions.get(this.defaultId);
if (s === undefined)
throw new Error('default session missing');
return s;
}
}
//# sourceMappingURL=manager.js.map