bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
165 lines
5.5 KiB
Python
165 lines
5.5 KiB
Python
# ========= 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 asyncio
|
|
import logging
|
|
import secrets
|
|
import time
|
|
from typing import Iterable
|
|
|
|
from mirage import Workspace, WorkspaceRunner
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def new_workspace_id() -> str:
|
|
"""Mint a fresh workspace id of the form ``ws_<16 hex chars>``.
|
|
|
|
Returns:
|
|
str: opaque, URL-safe, collision-resistant id.
|
|
"""
|
|
return f"ws_{secrets.token_hex(8)}"
|
|
|
|
|
|
class WorkspaceEntry:
|
|
|
|
def __init__(self, workspace_id: str, runner: WorkspaceRunner) -> None:
|
|
self.id = workspace_id
|
|
self.runner = runner
|
|
self.created_at = time.time()
|
|
|
|
|
|
class WorkspaceRegistry:
|
|
"""In-memory map of workspace_id -> WorkspaceRunner.
|
|
|
|
Owns the lifecycle for each workspace inside the daemon process:
|
|
register on create, drop on delete, and trip an idle-shutdown
|
|
event when the registry empties for ``idle_grace_seconds``.
|
|
|
|
Threading: the underlying ``dict`` is mutated only from the FastAPI
|
|
server loop (the same loop the registry is constructed on), so no
|
|
external lock is required.
|
|
"""
|
|
|
|
def __init__(self,
|
|
idle_grace_seconds: float = 30.0,
|
|
exit_event: asyncio.Event | None = None) -> None:
|
|
"""Construct an empty registry.
|
|
|
|
Args:
|
|
idle_grace_seconds (float): seconds to wait after the last
|
|
workspace is removed before signalling exit. ``0``
|
|
means exit immediately on empty.
|
|
exit_event (asyncio.Event | None): event to set when the
|
|
idle timer fires. Defaults to a fresh event.
|
|
"""
|
|
self._entries: dict[str, WorkspaceEntry] = {}
|
|
self.idle_grace_seconds = idle_grace_seconds
|
|
self.exit_event = exit_event or asyncio.Event()
|
|
self._idle_task: asyncio.Task | None = None
|
|
|
|
def __contains__(self, workspace_id: str) -> bool:
|
|
return workspace_id in self._entries
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._entries)
|
|
|
|
def get(self, workspace_id: str) -> WorkspaceEntry:
|
|
if workspace_id not in self._entries:
|
|
raise KeyError(workspace_id)
|
|
return self._entries[workspace_id]
|
|
|
|
def list(self) -> list[WorkspaceEntry]:
|
|
return list(self._entries.values())
|
|
|
|
def items(self) -> Iterable[tuple[str, WorkspaceEntry]]:
|
|
return self._entries.items()
|
|
|
|
def add(self,
|
|
workspace: Workspace,
|
|
workspace_id: str | None = None) -> WorkspaceEntry:
|
|
"""Wrap ``workspace`` in a runner and register it.
|
|
|
|
Args:
|
|
workspace (Workspace): freshly-constructed workspace.
|
|
workspace_id (str | None): explicit id, or None to auto-mint.
|
|
|
|
Returns:
|
|
WorkspaceEntry: the registered entry.
|
|
|
|
Raises:
|
|
ValueError: ``workspace_id`` is already registered.
|
|
"""
|
|
wid = workspace_id or new_workspace_id()
|
|
if wid in self._entries:
|
|
raise ValueError(f"workspace id already exists: {wid!r}")
|
|
runner = WorkspaceRunner(workspace)
|
|
entry = WorkspaceEntry(wid, runner)
|
|
self._entries[wid] = entry
|
|
self._cancel_idle_timer()
|
|
return entry
|
|
|
|
async def remove(self, workspace_id: str) -> WorkspaceEntry:
|
|
"""Stop the runner for ``workspace_id`` and drop it.
|
|
|
|
Args:
|
|
workspace_id (str): id to remove.
|
|
|
|
Returns:
|
|
WorkspaceEntry: the removed entry (after its runner is
|
|
stopped).
|
|
|
|
Raises:
|
|
KeyError: ``workspace_id`` is not registered.
|
|
"""
|
|
if workspace_id not in self._entries:
|
|
raise KeyError(workspace_id)
|
|
entry = self._entries.pop(workspace_id)
|
|
await entry.runner.stop()
|
|
if not self._entries:
|
|
self._start_idle_timer()
|
|
return entry
|
|
|
|
async def close_all(self) -> None:
|
|
"""Stop every runner. Used at daemon shutdown."""
|
|
self._cancel_idle_timer()
|
|
ids = list(self._entries)
|
|
for wid in ids:
|
|
entry = self._entries.pop(wid)
|
|
try:
|
|
await entry.runner.stop()
|
|
except Exception:
|
|
logger.exception("error stopping runner for %s", wid)
|
|
|
|
def _start_idle_timer(self) -> None:
|
|
if self.idle_grace_seconds <= 0:
|
|
self.exit_event.set()
|
|
return
|
|
if self._idle_task is not None and not self._idle_task.done():
|
|
return
|
|
self._idle_task = asyncio.create_task(self._idle_wait())
|
|
|
|
def _cancel_idle_timer(self) -> None:
|
|
if self._idle_task is not None and not self._idle_task.done():
|
|
self._idle_task.cancel()
|
|
self._idle_task = None
|
|
|
|
async def _idle_wait(self) -> None:
|
|
try:
|
|
await asyncio.sleep(self.idle_grace_seconds)
|
|
except asyncio.CancelledError:
|
|
return
|
|
if not self._entries:
|
|
self.exit_event.set()
|