UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

138 lines • 6.23 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 { CAS_MAX_RETRIES, generationOf } from "../record/types.js"; /** * Provider for a workspace's whole control-plane state. * * One store, five planes, each scoped by workspace id: namespace nodes * (symlinks, attribute overlays), observer events (command history), * the session table (mount grants, cwd, env), the workspace metadata * record (discovery: which sessions exist, which is the default), and * the jobs (each session's job list and each job's console). Handing two * processes the same store config plus a workspace id gives them the same * workspace state, which is the seam that lets a kernel tier bind a * session-bound mountpoint created by another daemon, and lets any of * them list, wait on and kill a session's jobs. * * The node table and the session table are two NamespaceStores, the * observer keeps its ObserverStore and the jobs their JobStore; this * provider only unifies their construction, connection, and key scoping. Per-group overrides * redirect a plane group to a different provider (e.g. large observer * logs elsewhere while everything else stays on Redis). Sessions and * metadata form one inseparable group: the default-session pointer in * the metadata must never live on a different server than the session * table it points into. */ export class WorkspaceStateStore { namespaceOverride; observerOverride; workspaceOverride; jobsOverride; constructor(overrides = {}) { this.namespaceOverride = overrides.namespace ?? null; this.observerOverride = overrides.observer ?? null; this.workspaceOverride = overrides.workspace ?? null; this.jobsOverride = overrides.jobs ?? null; } /** The namespace plane (nodes) for one workspace. */ namespace(workspaceId) { return (this.namespaceOverride ?? this).makeNamespace(workspaceId); } /** The observer plane (history events) for one workspace. */ observer(workspaceId) { return (this.observerOverride ?? this).makeObserver(workspaceId); } /** The session table for one workspace. */ sessions(workspaceId) { return (this.workspaceOverride ?? this).makeSessions(workspaceId); } /** The job plane (job lists, consoles, leases) for one workspace. */ jobs(workspaceId) { return (this.jobsOverride ?? this).makeJobs(workspaceId); } /** Read one workspace's metadata record; null when never written. */ loadMeta(workspaceId) { return (this.workspaceOverride ?? this).readMeta(workspaceId); } /** * Write the metadata record iff its stored generation matches. * * Same optimistic-concurrency contract as NamespaceStore.casSet: a * missing record (and a legacy record without the field) counts as * generation 0, so create-if-absent is `expectedGeneration = 0`. * Resolves true when the write landed, false on conflict. */ casSetMeta(workspaceId, fields, expectedGeneration) { return (this.workspaceOverride ?? this).casWriteMeta(workspaceId, fields, expectedGeneration); } /** * CAS-write `fields` over the stored record, retrying on conflict. * * Ours-wins content (snapshot restore semantics): each attempt * merges `fields` over the stored record, preserves the stored * `created_at`, bumps the generation, and retries when another * writer got there first. Resolves with the record as written. */ async replaceMeta(workspaceId, fields) { for (let attempt = 0; attempt < CAS_MAX_RETRIES; attempt++) { const existing = await this.loadMeta(workspaceId); const stored = existing ?? {}; const expected = generationOf(existing); const merged = { ...stored, ...fields, created_at: stored.created_at ?? Date.now() / 1000, generation: expected + 1, }; if (await this.casSetMeta(workspaceId, merged, expected)) return merged; } throw new Error(`workspace ${workspaceId} meta kept conflicting with another writer`); } /** * Delete everything this store holds for one workspace. * * Deleting a workspace calls this and closing one does not, which is * what lets a daemon's workspaces survive a restart. Each plane is * cleared through its own store, then every provider involved forgets * the workspace (its metadata record and the handles it keeps for the * id), so a workspace created later under the same id starts empty. */ async drop(workspaceId) { await this.namespace(workspaceId).clear(); await this.observer(workspaceId).clear(); await this.sessions(workspaceId).clear(); await this.jobs(workspaceId).clear(); const providers = new Set([this]); for (const override of this.overrides()) providers.add(override); for (const provider of providers) await provider.forgetSelf(workspaceId); } /** Release connections held by this provider and its overrides. */ async close() { for (const override of this.overrides()) await override.close(); await this.closeSelf(); } overrides() { return [ this.namespaceOverride, this.observerOverride, this.workspaceOverride, this.jobsOverride, ].filter((override) => override !== null); } } //# sourceMappingURL=base.js.map