chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# ========= 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. =========
from mirage.server.app import build_app
from mirage.server.registry import WorkspaceRegistry
__all__ = ["WorkspaceRegistry", "build_app"]
+148
View File
@@ -0,0 +1,148 @@
# ========= 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 os
import signal
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from mirage.server.auth import (AuthConfig, AuthMiddleware, AuthMode,
resolve_auth_config)
from mirage.server.daemon_config import (read_daemon_table,
validate_daemon_table)
from mirage.server.host_validation import (HostHeaderMiddleware,
resolve_allowed_hosts)
from mirage.server.jobs import JobTable
from mirage.server.paths import (mirage_home, pid_file_path,
snapshot_root_path, version_root_path)
from mirage.server.registry import WorkspaceRegistry
from mirage.server.routers import (execute, health, jobs, sessions, versions,
workspaces)
from mirage.server.version.backend import LocalBackend
logger = logging.getLogger(__name__)
def _write_pid_file(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(os.getpid()))
def _remove_pid_file(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
logger.debug("could not remove pid file %s", path)
async def _watch_exit(exit_event: asyncio.Event) -> None:
"""Send SIGTERM to self when ``exit_event`` is set.
Lets uvicorn handle its own graceful shutdown sequence.
"""
try:
await exit_event.wait()
except asyncio.CancelledError:
return
logger.info("exit event tripped; sending SIGTERM to self")
os.kill(os.getpid(), signal.SIGTERM)
@asynccontextmanager
async def _lifespan(app: FastAPI):
_write_pid_file(app.state.pid_file)
exit_task = asyncio.create_task(_watch_exit(app.state.exit_event))
try:
yield
finally:
exit_task.cancel()
await app.state.registry.close_all()
_remove_pid_file(app.state.pid_file)
def build_app(idle_grace_seconds: float = 30.0,
exit_event: asyncio.Event | None = None,
allowed_hosts: list[str] | None = None,
auth_config: AuthConfig | None = None,
version_root: str | Path | None = None,
snapshot_root: str | Path | None = None,
pid_file: str | Path | None = None) -> FastAPI:
"""Construct a daemon FastAPI app.
The workspace registry is created eagerly so the app is usable
even without ASGI lifespan events firing (e.g. inside an
``httpx.ASGITransport`` test client).
Args:
idle_grace_seconds (float): seconds to wait after the last
workspace is removed before signalling shutdown.
exit_event (asyncio.Event | None): event the registry trips
when the idle timer fires. The runner of this app should
await it and shut uvicorn down. Defaults to a fresh event.
allowed_hosts (list[str] | None): host allowlist for the
``Host`` header. ``None`` (default) reads
``$MIRAGE_ALLOWED_HOSTS`` (CSV) or falls back to
loopback-only (``127.0.0.1``, ``localhost``, ``::1``).
Pass ``["*"]`` to disable enforcement (only safe behind
a trusted reverse proxy).
auth_config (AuthConfig | None): bearer/JWT auth config.
``None`` (default) resolves from ``MIRAGE_AUTH_MODE`` env
and the mode-specific ``MIRAGE_*`` env vars.
version_root (str | Path | None): git repos root. ``None``
(default) uses ``$MIRAGE_HOME/repos`` (or ``~/.mirage/repos``).
snapshot_root (str | Path | None): snapshot root. ``None``
(default) uses ``$MIRAGE_HOME/snapshots``.
pid_file (str | Path | None): daemon pid file path. ``None``
(default) resolves ``$MIRAGE_PID_FILE`` then
``$MIRAGE_HOME/daemon.pid`` (or ``~/.mirage/daemon.pid``).
Returns:
FastAPI: configured app with all routers mounted.
"""
validate_daemon_table(read_daemon_table(mirage_home()))
app = FastAPI(title="Mirage daemon", version="0.1", lifespan=_lifespan)
hosts = resolve_allowed_hosts(allowed_hosts)
if "*" not in hosts:
app.add_middleware(HostHeaderMiddleware, allowed_hosts=hosts)
auth = auth_config if auth_config is not None else resolve_auth_config()
if auth.mode == AuthMode.LOCAL and auth.local_token is None:
logger.warning(
"daemon starting without bearer auth; anyone who can reach "
"it can drive it. Set MIRAGE_AUTH_TOKEN or use a non-local "
"MIRAGE_AUTH_MODE to enforce authentication.")
app.add_middleware(AuthMiddleware, config=auth)
app.state.allowed_hosts = hosts
app.state.auth_config = auth
app.state.started_at = time.time()
app.state.exit_event = exit_event or asyncio.Event()
app.state.registry = WorkspaceRegistry(
idle_grace_seconds=idle_grace_seconds,
exit_event=app.state.exit_event,
)
app.state.jobs = JobTable()
app.state.pid_file = pid_file_path(pid_file)
app.state.version_backend = LocalBackend(version_root_path(version_root))
app.state.snapshot_root = snapshot_root_path(snapshot_root)
app.include_router(workspaces.router)
app.include_router(versions.router)
app.include_router(sessions.router)
app.include_router(execute.router)
app.include_router(jobs.router)
app.include_router(health.router)
return app
+32
View File
@@ -0,0 +1,32 @@
# ========= 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. =========
from mirage.server.auth.config import (AuthConfig, AuthMode, JWTConfig,
resolve_auth_config,
resolve_local_token)
from mirage.server.auth.middleware import AuthMiddleware
from mirage.server.auth.storage import (default_token_file, ensure_token_file,
read_token_file)
__all__ = [
"AuthConfig",
"AuthMiddleware",
"AuthMode",
"JWTConfig",
"default_token_file",
"ensure_token_file",
"read_token_file",
"resolve_auth_config",
"resolve_local_token",
]
+208
View File
@@ -0,0 +1,208 @@
# ========= 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 os
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Mapping
from mirage.server.auth import storage as _storage
from mirage.server.daemon_config import read_daemon_table
from mirage.server.paths import mirage_home
ENV_AUTH_MODE = "MIRAGE_AUTH_MODE"
ENV_AUTH_TOKEN = "MIRAGE_AUTH_TOKEN"
ENV_JWT_PUBKEY = "MIRAGE_JWT_PUBKEY"
ENV_JWT_PUBKEY_FILE = "MIRAGE_JWT_PUBKEY_FILE"
ENV_JWT_ALG = "MIRAGE_JWT_ALG"
ENV_JWT_ISSUER = "MIRAGE_JWT_ISSUER"
ENV_JWT_AUDIENCE = "MIRAGE_JWT_AUDIENCE"
ENV_JWT_AUTHORIZED_PARTIES = "MIRAGE_JWT_AUTHORIZED_PARTIES"
ENV_JWT_CLOCK_SKEW = "MIRAGE_JWT_CLOCK_SKEW_SECONDS"
DEFAULT_CLOCK_SKEW_SECONDS = 5
class AuthMode(StrEnum):
LOCAL = "local"
TOKEN = "token"
JWT = "jwt"
@dataclass(frozen=True)
class JWTConfig:
key: str
algorithm: str
issuer: str | None = None
audience: str | None = None
authorized_parties: tuple[str, ...] = field(default_factory=tuple)
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS
@dataclass(frozen=True)
class AuthConfig:
mode: AuthMode
local_token: str | None = None
bearer_token: str | None = None
jwt: JWTConfig | None = None
def resolve_local_token(
env: Mapping[str, str] | None = None,
token_file: Path | None = None,
) -> str | None:
"""Resolve the local-mode bearer token via env > file > None.
Args:
env (Mapping[str, str] | None): environment to read
``MIRAGE_AUTH_TOKEN`` from. Defaults to ``os.environ``.
token_file (Path | None): location of the token file.
Defaults to ``default_token_file()``.
Returns:
str | None: resolved token, or ``None`` if no source provides one.
"""
e = env if env is not None else os.environ
val = e.get(ENV_AUTH_TOKEN, "").strip()
if val:
return val
path = (token_file
if token_file is not None else _storage.default_token_file())
return _storage.read_token_file(path)
def _read_jwt_key(env: Mapping[str, str]) -> str:
inline = env.get(ENV_JWT_PUBKEY, "").strip()
if inline:
return inline
path = env.get(ENV_JWT_PUBKEY_FILE, "").strip()
if path:
return Path(path).read_text()
raise RuntimeError(
f"mode=jwt requires {ENV_JWT_PUBKEY} or {ENV_JWT_PUBKEY_FILE}")
def _parse_csv(value: str) -> tuple[str, ...]:
return tuple(p.strip() for p in value.split(",") if p.strip())
_CONFIG_ENV_KEYS = {
"auth_mode": ENV_AUTH_MODE,
"jwt_alg": ENV_JWT_ALG,
"jwt_issuer": ENV_JWT_ISSUER,
"jwt_audience": ENV_JWT_AUDIENCE,
"jwt_pubkey_file": ENV_JWT_PUBKEY_FILE,
"jwt_clock_skew": ENV_JWT_CLOCK_SKEW,
"jwt_authorized_parties": ENV_JWT_AUTHORIZED_PARTIES,
}
def _merge_config_table(env: Mapping[str, str],
table: Mapping[str, object]) -> dict[str, str]:
"""Fold config.toml auth keys under their env names, env winning.
Only non-secret keys have config counterparts: the raw
``MIRAGE_AUTH_TOKEN`` and inline ``MIRAGE_JWT_PUBKEY`` stay
env-only (use the token file / ``jwt_pubkey_file`` instead).
Args:
env (Mapping[str, str]): the process environment view.
table (Mapping[str, object]): the ``[daemon]`` config table.
Returns:
dict[str, str]: env copy with config fallbacks applied.
"""
merged = dict(env)
for cfg_key, env_name in _CONFIG_ENV_KEYS.items():
if merged.get(env_name, "").strip():
continue
value = table.get(cfg_key)
if value is not None and str(value).strip():
merged[env_name] = str(value)
return merged
def resolve_auth_config(
env: Mapping[str, str] | None = None,
token_file: Path | None = None,
table: Mapping[str, object] | None = None,
) -> AuthConfig:
"""Resolve daemon auth configuration from environment and config.
Per key the environment variable wins over the ``[daemon]`` table
in ``config.toml``, which wins over the default.
Args:
env (Mapping[str, str] | None): environment to read from.
Defaults to ``os.environ``.
token_file (Path | None): override the local-mode token file
location. Defaults to ``default_token_file()``.
table (Mapping[str, object] | None): the ``[daemon]`` config
table. Defaults to reading ``$MIRAGE_HOME/config.toml``
when ``env`` is also defaulted; an explicit ``env`` with no
``table`` stays hermetic and reads no file.
Returns:
AuthConfig: resolved configuration.
Raises:
RuntimeError: if required settings are missing for the chosen mode.
"""
if table is None:
table = read_daemon_table(mirage_home()) if env is None else {}
e = _merge_config_table(env if env is not None else os.environ, table)
raw_mode = (e.get(ENV_AUTH_MODE, "")
or AuthMode.LOCAL.value).strip().lower()
try:
mode = AuthMode(raw_mode)
except ValueError as exc:
valid = ", ".join(m.value for m in AuthMode)
raise RuntimeError(
f"{ENV_AUTH_MODE} must be one of ({valid}), got {raw_mode!r}"
) from exc
if mode == AuthMode.LOCAL:
return AuthConfig(
mode=mode,
local_token=resolve_local_token(env=e, token_file=token_file),
)
if mode == AuthMode.TOKEN:
token = e.get(ENV_AUTH_TOKEN, "").strip()
if not token:
raise RuntimeError(
f"mode=token requires {ENV_AUTH_TOKEN} to be set")
return AuthConfig(mode=mode, bearer_token=token)
key = _read_jwt_key(e)
alg = e.get(ENV_JWT_ALG, "").strip()
if not alg:
raise RuntimeError(f"mode=jwt requires {ENV_JWT_ALG} (e.g. RS256)")
issuer = (e.get(ENV_JWT_ISSUER) or "").strip() or None
audience = (e.get(ENV_JWT_AUDIENCE) or "").strip() or None
azp = _parse_csv(e.get(ENV_JWT_AUTHORIZED_PARTIES, ""))
skew_raw = (e.get(ENV_JWT_CLOCK_SKEW) or "").strip()
skew = int(skew_raw) if skew_raw else DEFAULT_CLOCK_SKEW_SECONDS
return AuthConfig(
mode=mode,
jwt=JWTConfig(
key=key,
algorithm=alg,
issuer=issuer,
audience=audience,
authorized_parties=azp,
clock_skew_seconds=skew,
),
)
+73
View File
@@ -0,0 +1,73 @@
# ========= 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 logging
from typing import Any
import jwt as pyjwt
from mirage.server.auth.config import JWTConfig
logger = logging.getLogger(__name__)
class JWTVerificationError(Exception):
pass
def verify_jwt(token: str, cfg: JWTConfig) -> dict[str, Any]:
"""Verify a JWT against ``cfg`` and return its claims on success.
Performs signature verification, algorithm pinning, mandatory
``exp`` check, and (when configured) ``iss``/``aud``/``azp``
checks. ``typ`` header, if present, must be ``"JWT"``.
Args:
token (str): the raw bearer value (already stripped of any
``Bearer `` prefix).
cfg (JWTConfig): verification parameters.
Returns:
dict[str, Any]: validated claims.
Raises:
JWTVerificationError: any failure (signature, algorithm,
``exp``, ``iss``, ``aud``, ``azp``, ``typ``).
"""
try:
claims = pyjwt.decode(
token,
cfg.key,
algorithms=[cfg.algorithm],
audience=cfg.audience,
issuer=cfg.issuer,
options={"require": ["exp"]},
leeway=cfg.clock_skew_seconds,
)
except pyjwt.PyJWTError as e:
raise JWTVerificationError(f"JWT rejected: {e}") from e
try:
header = pyjwt.get_unverified_header(token)
except pyjwt.PyJWTError as e:
raise JWTVerificationError(f"JWT header unreadable: {e}") from e
typ = header.get("typ")
if typ is not None and typ != "JWT":
raise JWTVerificationError(
f"JWT typ header must be 'JWT' when present, got {typ!r}")
if cfg.authorized_parties:
azp = claims.get("azp")
if azp not in cfg.authorized_parties:
raise JWTVerificationError(
f"JWT azp {azp!r} not in authorized_parties")
return claims
+101
View File
@@ -0,0 +1,101 @@
# ========= 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 hmac
import logging
import re
from starlette.responses import PlainTextResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from mirage.server.auth.config import AuthConfig, AuthMode
from mirage.server.auth.jwt import JWTVerificationError, verify_jwt
logger = logging.getLogger(__name__)
BEARER_PREFIX = "Bearer "
HEALTH_PATHS: frozenset[str] = frozenset({"/v1/health"})
_JWT_SHAPE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
class AuthMiddleware:
"""ASGI middleware enforcing the daemon's auth mode.
Args:
app (ASGIApp): downstream ASGI app.
config (AuthConfig): resolved auth configuration.
"""
def __init__(self, app: ASGIApp, config: AuthConfig) -> None:
self.app = app
self.config = config
async def __call__(self, scope: Scope, receive: Receive,
send: Send) -> None:
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
if scope.get("path") in HEALTH_PATHS:
await self.app(scope, receive, send)
return
cfg = self.config
if cfg.mode == AuthMode.LOCAL and cfg.local_token is None:
await self.app(scope, receive, send)
return
token = self._extract_bearer(scope)
if token is None:
await self._unauthorized(scope, receive, send,
"missing bearer token")
return
if self.config.mode == AuthMode.JWT:
if not _JWT_SHAPE.match(token):
await self._unauthorized(scope, receive, send,
"token shape is not a JWT")
return
try:
verify_jwt(token, self.config.jwt)
except JWTVerificationError as e:
logger.debug("JWT rejected: %s", e)
await self._unauthorized(scope, receive, send, str(e))
return
await self.app(scope, receive, send)
return
expected = (self.config.local_token if self.config.mode
== AuthMode.LOCAL else self.config.bearer_token)
if expected is None or not hmac.compare_digest(token, expected):
await self._unauthorized(scope, receive, send, "bearer mismatch")
return
await self.app(scope, receive, send)
@staticmethod
def _extract_bearer(scope: Scope) -> str | None:
headers = dict(scope.get("headers") or [])
raw = headers.get(b"authorization", b"").decode("latin-1")
if not raw.startswith(BEARER_PREFIX):
return None
value = raw[len(BEARER_PREFIX):].strip()
return value or None
async def _unauthorized(self, scope: Scope, receive: Receive, send: Send,
reason: str) -> None:
client = scope.get("client") or ("?", 0)
logger.warning("rejecting request from %s:%s: %s", client[0],
client[1], reason)
response = PlainTextResponse("Unauthorized",
status_code=401,
headers={"WWW-Authenticate": "Bearer"})
await response(scope, receive, send)
+57
View File
@@ -0,0 +1,57 @@
# ========= 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 os
import secrets
from pathlib import Path
from mirage.server.paths import mirage_home
def default_token_file() -> Path:
"""Default auth token location, under :func:`mirage_home`."""
return mirage_home() / "auth_token"
def read_token_file(path: Path) -> str | None:
"""Read the token file if present, returning None when missing.
Args:
path (Path): location of the token file.
Returns:
str | None: stripped file contents, or None if the file does not exist.
"""
if not path.exists():
return None
return path.read_text().strip()
def ensure_token_file(path: Path) -> str:
"""Read the token file if present, else mint and persist a fresh one.
Args:
path (Path): location of the token file.
Returns:
str: the resolved bearer token.
"""
existing = read_token_file(path)
if existing is not None:
return existing
path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32)
path.write_text(token)
os.chmod(path, 0o600)
return token
+88
View File
@@ -0,0 +1,88 @@
# ========= 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. =========
from typing import Any
from mirage import Workspace
from mirage.resource.history import HISTORY_PREFIX
from mirage.resource.registry import build_resource
from mirage.workspace.snapshot import requires_resource_override, to_state_dict
from mirage.workspace.snapshot.utils import norm_mount_prefix
def _build_override_resources(override: dict[str, Any] | None) -> dict:
if not override:
return {}
if "mounts" not in override:
return {}
out: dict = {}
for prefix, block in override["mounts"].items():
if not isinstance(block, dict):
continue
resource_name = block.get("resource")
config = block.get("config") or {}
if resource_name is None:
continue
out[norm_mount_prefix(prefix)] = build_resource(resource_name, config)
return out
def _existing_redacted_resources(ws: Workspace, state: dict,
skip: set[str]) -> dict:
auto_prefixes = {"/dev/", norm_mount_prefix(HISTORY_PREFIX)}
prefix_to_resource = {
m.prefix: m.resource
for m in ws._registry.mounts() if m.prefix not in auto_prefixes
}
out: dict = {}
for m in state["mounts"]:
prefix = m["prefix"]
if norm_mount_prefix(prefix) in skip:
continue
if requires_resource_override(m) and prefix in prefix_to_resource:
out[prefix] = prefix_to_resource[prefix]
return out
async def clone_workspace_with_override(src_ws: Workspace,
override: dict[str, Any]
| None) -> Workspace:
"""Snapshot ``src_ws`` and rebuild a fresh workspace from state.
Behavior:
* Local resources (RAM, Disk) are reconstructed fresh, so the
clone's writes never touch the original's data.
* Remote resources (S3, Redis, GDrive, ...) that redact secrets
or connection material are reused from the original by default
-- they share connection pools and bucket data.
* If ``override`` supplies a fresh resource for a prefix, that
resource replaces the reused one -- e.g. point the clone at
a different S3 bucket.
Args:
src_ws (Workspace): the source workspace.
override (dict[str, Any] | None): partial workspace config
with ``mounts: {<prefix>: {resource, config}}`` entries to
swap.
Returns:
Workspace: a new, independent workspace.
"""
state = await to_state_dict(src_ws)
override_resources = _build_override_resources(override)
existing = _existing_redacted_resources(src_ws,
state,
skip=set(override_resources))
merged = {**existing, **override_resources}
return await Workspace._from_state(state, resources=merged)
+22
View File
@@ -0,0 +1,22 @@
# ========= 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 os
from mirage.server.app import build_app
from mirage.server.env import ENV_IDLE_GRACE_SECONDS
_idle_grace = float(os.environ.get(ENV_IDLE_GRACE_SECONDS, "30"))
app = build_app(idle_grace_seconds=_idle_grace)
+90
View File
@@ -0,0 +1,90 @@
# ========= 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 tomllib
from pathlib import Path
ALLOWED_KEYS = frozenset({
"url",
"socket",
"auth_token",
"auth_mode",
"allowed_hosts",
"jwt_alg",
"jwt_issuer",
"jwt_audience",
"jwt_pubkey_file",
"jwt_clock_skew",
"jwt_authorized_parties",
"idle_grace_seconds",
"port",
"pid_file",
"version_root",
"snapshot_root",
})
NUMERIC_KEYS = frozenset({"idle_grace_seconds", "jwt_clock_skew", "port"})
class DaemonConfigError(Exception):
"""Raised when config.toml's ``[daemon]`` table is unusable."""
def validate_daemon_table(table: dict) -> None:
"""Reject unknown keys or wrong-typed values in a ``[daemon]`` table.
Args:
table (dict): parsed ``[daemon]`` table.
Raises:
DaemonConfigError: naming every offending key.
"""
unknown = sorted(set(table) - ALLOWED_KEYS)
if unknown:
raise DaemonConfigError(
"config.toml: the following [daemon] keys don't match any "
f"configuration option: {', '.join(unknown)}")
bad_types = sorted(
k for k, v in table.items()
if (k in NUMERIC_KEYS and not isinstance(v, (int, float))) or (
k not in NUMERIC_KEYS and not isinstance(v, str)))
if bad_types:
raise DaemonConfigError(
"config.toml: the following [daemon] keys have the wrong "
f"type: {', '.join(bad_types)}")
def read_daemon_table(home: Path) -> dict:
"""Read the ``[daemon]`` table from ``home/config.toml``.
Args:
home (Path): the ``.mirage`` base directory. The config file is
``home/config.toml``.
Returns:
dict: the ``[daemon]`` table, or ``{}`` if the file or table is
absent.
Raises:
DaemonConfigError: the file exists but is not valid TOML.
"""
path = home / "config.toml"
if not path.exists():
return {}
try:
with open(path, "rb") as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise DaemonConfigError(f"malformed {path}: {e}") from e
table = data.get("daemon", {})
return table if isinstance(table, dict) else {}
+21
View File
@@ -0,0 +1,21 @@
# ========= 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. =========
ENV_ALLOWED_HOSTS = "MIRAGE_ALLOWED_HOSTS"
ENV_IDLE_GRACE_SECONDS = "MIRAGE_IDLE_GRACE_SECONDS"
ENV_DAEMON_PORT = "MIRAGE_DAEMON_PORT"
ENV_VERSION_ROOT = "MIRAGE_VERSION_ROOT"
ENV_SNAPSHOT_ROOT = "MIRAGE_SNAPSHOT_ROOT"
ENV_HOME = "MIRAGE_HOME"
ENV_PID_FILE = "MIRAGE_PID_FILE"
+141
View File
@@ -0,0 +1,141 @@
# ========= 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 logging
import os
from collections.abc import Iterable
from starlette.responses import PlainTextResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from mirage.server.daemon_config import read_daemon_table
from mirage.server.env import ENV_ALLOWED_HOSTS
from mirage.server.host_validation_constants import (DEFAULT_ALLOWED_HOSTS,
HOST_PATTERN)
from mirage.server.paths import mirage_home
logger = logging.getLogger(__name__)
def parse_allowed_hosts(value: str | None) -> list[str]:
"""Parse a CSV ``MIRAGE_ALLOWED_HOSTS`` value into a host list.
Empty / missing values fall back to ``DEFAULT_ALLOWED_HOSTS``.
Args:
value (str | None): raw env var value.
Returns:
list[str]: parsed host list.
"""
if value is None:
return list(DEFAULT_ALLOWED_HOSTS)
items = [h.strip() for h in value.split(",") if h.strip()]
return items or list(DEFAULT_ALLOWED_HOSTS)
def resolve_allowed_hosts(
allowed_hosts: Iterable[str] | None = None) -> list[str]:
"""Resolve allowed hosts from explicit arg or env var.
Args:
allowed_hosts (Iterable[str] | None): explicit list. If
``None``, falls back to ``$MIRAGE_ALLOWED_HOSTS`` env var,
then the ``allowed_hosts`` key in ``config.toml``
``[daemon]`` (comma-separated), then
``DEFAULT_ALLOWED_HOSTS``.
Returns:
list[str]: resolved host list.
"""
if allowed_hosts is not None:
return list(allowed_hosts)
raw = os.environ.get(ENV_ALLOWED_HOSTS)
if not raw:
from_config = read_daemon_table(mirage_home()).get("allowed_hosts")
raw = str(from_config) if from_config else None
return parse_allowed_hosts(raw)
def strip_port(raw_host: str) -> str:
"""Strip the port (and IPv6 brackets) from a Host header value.
Returns the bare host only when the value matches one of
``host`` / ``host:digits`` / ``[host]`` / ``[host]:digits``; any
other (malformed) input is returned unchanged so the allowlist
comparison fails closed.
Args:
raw_host (str): raw Host header value.
Returns:
str: bare host, or the raw input when malformed.
"""
match = HOST_PATTERN.match(raw_host)
if match is None:
return raw_host
return match.group(1) or match.group(2) or raw_host
def is_host_allowed(raw_host: str | None, allowed: list[str]) -> bool:
"""Decide whether a Host header value is in the allowlist.
Args:
raw_host (str | None): raw Host header value.
allowed (list[str]): allowlist; ``"*"`` accepts any host.
Returns:
bool: True when the port-stripped host is allowed.
"""
if "*" in allowed:
return True
if not raw_host:
return False
return strip_port(raw_host) in allowed
class HostHeaderMiddleware:
"""ASGI middleware that 400s requests with disallowed Host headers.
Replaces Starlette's TrustedHostMiddleware so we can log on
rejection. Port is stripped before comparison (parity with
Starlette).
Args:
app (ASGIApp): downstream ASGI app.
allowed_hosts (list[str]): exact hosts to accept. ``"*"`` in
the list disables enforcement.
"""
def __init__(self, app: ASGIApp, allowed_hosts: list[str]) -> None:
self.app = app
self.allowed_hosts = allowed_hosts
self.allow_any = "*" in allowed_hosts
async def __call__(self, scope: Scope, receive: Receive,
send: Send) -> None:
if scope["type"] not in ("http", "websocket") or self.allow_any:
await self.app(scope, receive, send)
return
headers = dict(scope.get("headers") or [])
raw_host = headers.get(b"host", b"").decode("latin-1")
if is_host_allowed(raw_host, self.allowed_hosts):
await self.app(scope, receive, send)
return
client = scope.get("client") or ("?", 0)
logger.warning(
"rejecting request from %s:%s: Host=%r not in allowlist %s",
client[0], client[1], raw_host, self.allowed_hosts)
response = PlainTextResponse("Invalid host header", status_code=400)
await response(scope, receive, send)
@@ -0,0 +1,19 @@
# ========= 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 re
DEFAULT_ALLOWED_HOSTS: tuple[str, ...] = ("127.0.0.1", "localhost", "::1")
HOST_PATTERN = re.compile(r"^(?:\[([^\]]+)\]|([^:]+))(?::\d+)?$")
+44
View File
@@ -0,0 +1,44 @@
# ========= 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. =========
from typing import Any
from mirage.io.types import IOResult
async def io_result_to_dict(result: IOResult | object) -> dict[str, Any]:
"""Materialize an IOResult into a JSON-friendly dict.
Falls back to ``result.model_dump()`` for ``ProvisionResult`` (which
uses pydantic), so the same helper handles both ``mirage provision``
and normal execute outputs.
Args:
result (IOResult | object): the workspace.execute return value.
Returns:
dict[str, Any]: serializable response payload.
"""
if isinstance(result, IOResult):
stdout = await result.materialize_stdout()
stderr = await result.materialize_stderr()
return {
"kind": "io",
"exit_code": result.exit_code,
"stdout": stdout.decode(errors="replace"),
"stderr": stderr.decode(errors="replace"),
}
if hasattr(result, "model_dump"):
return {"kind": "provision", **result.model_dump()}
return {"kind": "raw", "value": str(result)}
+156
View File
@@ -0,0 +1,156 @@
# ========= 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 concurrent.futures
import functools
import logging
import secrets
import time
from enum import Enum
from typing import Any, Awaitable, Callable
logger = logging.getLogger(__name__)
class JobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
CANCELED = "canceled"
def new_job_id() -> str:
"""Mint a fresh job id of the form ``job_<16 hex chars>``."""
return f"job_{secrets.token_hex(8)}"
class JobEntry:
"""One in-flight or completed daemon-level execute job."""
def __init__(self, job_id: str, workspace_id: str, command: str) -> None:
self.id = job_id
self.workspace_id = workspace_id
self.command = command
self.status: JobStatus = JobStatus.PENDING
self.result: Any = None
self.error: str | None = None
self.submitted_at: float = time.time()
self.started_at: float | None = None
self.finished_at: float | None = None
self._future: concurrent.futures.Future | None = None
self._done_event: asyncio.Event = asyncio.Event()
class JobTable:
"""Daemon-wide table of execute jobs.
Tracks both sync and background jobs so the CLI can query
progress, wait, and cancel uniformly. Sync calls register the job
in pending/running and complete it before returning.
"""
def __init__(self) -> None:
self._jobs: dict[str, JobEntry] = {}
def __contains__(self, job_id: str) -> bool:
return job_id in self._jobs
def get(self, job_id: str) -> JobEntry:
if job_id not in self._jobs:
raise KeyError(job_id)
return self._jobs[job_id]
def list(self, workspace_id: str | None = None) -> list[JobEntry]:
if workspace_id is None:
return list(self._jobs.values())
return [
j for j in self._jobs.values() if j.workspace_id == workspace_id
]
def submit(self, workspace_id: str, command: str,
schedule: Callable[[Awaitable], concurrent.futures.Future],
coro_factory: Callable[[], Awaitable]) -> JobEntry:
"""Register a job and start running it on the workspace loop.
Args:
workspace_id (str): workspace this job belongs to.
command (str): user-visible command string for display.
schedule (Callable): function that takes a coroutine and
returns a ``concurrent.futures.Future`` representing
its execution on the workspace loop. Typically
``functools.partial(asyncio.run_coroutine_threadsafe,
loop=runner.loop)`` -- but the runner's
``call``-equivalent is fine too.
coro_factory (Callable): zero-arg callable that builds the
coroutine to schedule. Called once.
Returns:
JobEntry: the registered entry. Inspect ``entry._future``
to await or cancel.
"""
job_id = new_job_id()
entry = JobEntry(job_id, workspace_id, command)
self._jobs[job_id] = entry
coro = coro_factory()
fut = schedule(coro)
entry._future = fut
entry.status = JobStatus.RUNNING
entry.started_at = time.time()
loop = asyncio.get_running_loop()
callback = functools.partial(self._dispatch_done, entry, loop)
fut.add_done_callback(callback)
return entry
def _dispatch_done(self, entry: JobEntry, loop: asyncio.AbstractEventLoop,
fut: concurrent.futures.Future) -> None:
loop.call_soon_threadsafe(self._on_done, entry, fut)
def _on_done(self, entry: JobEntry,
fut: concurrent.futures.Future) -> None:
entry.finished_at = time.time()
if fut.cancelled():
entry.status = JobStatus.CANCELED
else:
exc = fut.exception()
if exc is not None:
entry.status = JobStatus.FAILED
entry.error = f"{type(exc).__name__}: {exc}"
else:
entry.status = JobStatus.DONE
entry.result = fut.result()
entry._done_event.set()
async def wait(self,
job_id: str,
timeout: float | None = None) -> JobEntry:
entry = self.get(job_id)
if entry.status in (JobStatus.DONE, JobStatus.FAILED,
JobStatus.CANCELED):
return entry
try:
await asyncio.wait_for(entry._done_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
return entry
return entry
def cancel(self, job_id: str) -> bool:
entry = self.get(job_id)
if entry.status in (JobStatus.DONE, JobStatus.FAILED,
JobStatus.CANCELED):
return False
if entry._future is None:
return False
return entry._future.cancel()
+155
View File
@@ -0,0 +1,155 @@
# ========= 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 os
import re
from pathlib import Path
from mirage.server.daemon_config import read_daemon_table
from mirage.server.env import (ENV_HOME, ENV_PID_FILE, ENV_SNAPSHOT_ROOT,
ENV_VERSION_ROOT)
_SAFE_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _absolute(value: str | Path) -> Path:
return Path(os.path.abspath(value))
def mirage_home() -> Path:
"""Resolve the base directory backing the ``.mirage`` data tree.
Priority: ``$MIRAGE_HOME`` if set, else ``~/.mirage``. A relative
override is absolutized against the current working directory so
the daemon and later CLI invocations agree on one location.
Returns:
Path: absolute base directory for the pid file, log file,
auth token, config, repos, and snapshots.
"""
override = os.environ.get(ENV_HOME)
return _absolute(override) if override else Path.home() / ".mirage"
def pid_file_path(explicit: str | Path | None = None) -> Path:
"""Resolve the daemon pid file location.
Priority: ``explicit`` argument, then ``$MIRAGE_PID_FILE``, then the
``pid_file`` key in ``config.toml`` ``[daemon]``, then ``daemon.pid``
under :func:`mirage_home`.
Args:
explicit (str | Path | None): caller-supplied override.
Returns:
Path: the resolved absolute pid file path.
"""
if explicit is not None:
return _absolute(explicit)
override = os.environ.get(ENV_PID_FILE)
if override:
return _absolute(override)
home = mirage_home()
from_config = read_daemon_table(home).get("pid_file")
if from_config:
return _absolute(from_config)
return home / "daemon.pid"
def version_root_path(explicit: str | Path | None = None) -> Path:
"""Resolve the git repos root.
Priority: ``explicit`` argument, then ``$MIRAGE_VERSION_ROOT``, then the
``version_root`` key in ``config.toml`` ``[daemon]``, then ``repos``
under :func:`mirage_home`.
Args:
explicit (str | Path | None): caller-supplied override.
Returns:
Path: the resolved absolute repos root.
"""
if explicit is not None:
return _absolute(explicit)
override = os.environ.get(ENV_VERSION_ROOT)
if override:
return _absolute(override)
home = mirage_home()
from_config = read_daemon_table(home).get("version_root")
if from_config:
return _absolute(from_config)
return home / "repos"
def snapshot_root_path(explicit: str | Path | None = None) -> Path:
"""Resolve the snapshot root.
Priority: ``explicit`` argument, then ``$MIRAGE_SNAPSHOT_ROOT``, then the
``snapshot_root`` key in ``config.toml`` ``[daemon]``, then ``snapshots``
under :func:`mirage_home`.
Args:
explicit (str | Path | None): caller-supplied override.
Returns:
Path: the resolved absolute snapshot root.
"""
if explicit is not None:
return _absolute(explicit)
override = os.environ.get(ENV_SNAPSHOT_ROOT)
if override:
return _absolute(override)
home = mirage_home()
from_config = read_daemon_table(home).get("snapshot_root")
if from_config:
return _absolute(from_config)
return home / "snapshots"
class PathOutsideRootError(Exception):
pass
def resolve_within_root(root: str | Path, user_path: str) -> Path:
"""Resolve user_path against root and assert it stays within root.
Args:
root (str | Path): the trusted directory paths are confined to.
user_path (str): a request-supplied path, relative or absolute.
Returns:
Path: the resolved absolute path, guaranteed to be root itself
or a descendant of root.
"""
resolved_root = os.path.realpath(str(root))
resolved = os.path.realpath(os.path.join(resolved_root, user_path))
if os.path.commonpath([resolved_root, resolved]) != resolved_root:
raise PathOutsideRootError(
f"path escapes the configured root: {user_path}")
return Path(resolved)
def validate_path_segment(segment: str) -> str:
"""Assert segment is a single safe path component.
Args:
segment (str): a request-supplied identifier used as one path
component (no separators, not . or ..).
Returns:
str: the validated segment.
"""
if segment in (".", "..") or _SAFE_SEGMENT_RE.match(segment) is None:
raise PathOutsideRootError(f"invalid path segment: {segment}")
return segment
+164
View File
@@ -0,0 +1,164 @@
# ========= 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()
+13
View File
@@ -0,0 +1,13 @@
# ========= 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. =========
+163
View File
@@ -0,0 +1,163 @@
# ========= 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 functools
import json
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request, Response
from pydantic import BaseModel
from mirage.server.io_serde import io_result_to_dict
from mirage.server.jobs import JobEntry, JobStatus
router = APIRouter(prefix="/v1/workspaces/{workspace_id}/execute")
class ExecuteRequest(BaseModel):
command: str
session_id: str | None = None
provision: bool = False
agent_id: str | None = None
class BackgroundResponse(BaseModel):
job_id: str
workspace_id: str
submitted_at: float
def _require_entry(request: Request, workspace_id: str):
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
return registry.get(workspace_id)
def _build_execute_kwargs(req: ExecuteRequest, stdin: bytes | None) -> dict:
kwargs: dict[str, Any] = {
"command": req.command,
"provision": req.provision,
}
if req.session_id is not None:
kwargs["session_id"] = req.session_id
if req.agent_id is not None:
kwargs["agent_id"] = req.agent_id
if stdin is not None:
kwargs["stdin"] = stdin
return kwargs
def _make_coro_factory(runner, kwargs: dict):
return functools.partial(_invoke_execute, runner, kwargs)
async def _invoke_execute(runner, kwargs: dict):
return await runner.ws.execute(**kwargs)
def _schedule_on_runner(runner, coro):
return asyncio.run_coroutine_threadsafe(coro, runner.loop)
def _job_to_dict(entry: JobEntry,
result_dict: dict | None = None) -> dict[str, Any]:
return {
"job_id": entry.id,
"workspace_id": entry.workspace_id,
"command": entry.command,
"status": entry.status.value,
"submitted_at": entry.submitted_at,
"started_at": entry.started_at,
"finished_at": entry.finished_at,
"result": result_dict,
"error": entry.error,
}
@router.post("")
async def execute(
workspace_id: str,
request: Request,
background: bool = Query(False),
) -> Response:
entry = _require_entry(request, workspace_id)
job_table = request.app.state.jobs
content_type = request.headers.get("content-type", "")
req_obj, stdin_bytes = await _parse_execute_body(request, content_type)
schedule = functools.partial(_schedule_on_runner, entry.runner)
job = job_table.submit(
workspace_id=workspace_id,
command=req_obj.command,
schedule=schedule,
coro_factory=_make_coro_factory(
entry.runner,
_build_execute_kwargs(req_obj, stdin_bytes),
),
)
if background:
return Response(
content=BackgroundResponse(
job_id=job.id,
workspace_id=workspace_id,
submitted_at=job.submitted_at,
).model_dump_json(),
media_type="application/json",
status_code=202,
headers={"X-Mirage-Job-Id": job.id},
)
await job_table.wait(job.id)
if job.status == JobStatus.CANCELED:
raise HTTPException(status_code=499, detail="job canceled")
if job.status == JobStatus.FAILED:
raise HTTPException(status_code=500,
detail=job.error or "execute failed")
result_dict = await io_result_to_dict(job.result)
return Response(
content=json.dumps(result_dict),
media_type="application/json",
status_code=200,
headers={"X-Mirage-Job-Id": job.id},
)
async def _parse_execute_body(
request: Request,
content_type: str) -> tuple[ExecuteRequest, bytes | None]:
if content_type.startswith("multipart/"):
form = await request.form()
request_part = form.get("request")
if request_part is None:
raise HTTPException(status_code=400,
detail="multipart body missing 'request' part")
if hasattr(request_part, "read"):
req_text = (await request_part.read()).decode("utf-8")
else:
req_text = str(request_part)
try:
req_obj = ExecuteRequest.model_validate(json.loads(req_text))
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=400,
detail=f"bad request part: {e}")
stdin_part = form.get("stdin")
stdin_bytes: bytes | None = None
if stdin_part is not None:
if hasattr(stdin_part, "read"):
stdin_bytes = await stdin_part.read()
else:
stdin_bytes = str(stdin_part).encode("utf-8")
return req_obj, stdin_bytes
body = await request.json()
return ExecuteRequest.model_validate(body), None
+50
View File
@@ -0,0 +1,50 @@
# ========= 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 os
import time
from fastapi import APIRouter, Request
from pydantic import BaseModel
from mirage.server.schemas import HealthResponse
router = APIRouter()
class ShutdownResponse(BaseModel):
status: str
pid: int
@router.get("/v1/health", response_model=HealthResponse)
async def health(request: Request) -> HealthResponse:
started_at = request.app.state.started_at
return HealthResponse(
status="ok",
workspaces=len(request.app.state.registry),
uptime_s=round(time.time() - started_at, 3),
)
@router.post("/v1/shutdown", response_model=ShutdownResponse)
async def shutdown(request: Request) -> ShutdownResponse:
"""Trip the exit event so the daemon shuts down gracefully.
The ``_watch_exit`` background task in the lifespan picks this up
and sends SIGTERM to the process. uvicorn handles the rest of the
shutdown sequence (close connections, run lifespan finally block).
"""
request.app.state.exit_event.set()
return ShutdownResponse(status="shutting_down", pid=os.getpid())
+117
View File
@@ -0,0 +1,117 @@
# ========= 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. =========
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel
from mirage.server.io_serde import io_result_to_dict
from mirage.server.jobs import JobEntry
router = APIRouter(prefix="/v1/jobs")
class JobBrief(BaseModel):
job_id: str
workspace_id: str
command: str
status: str
submitted_at: float
started_at: float | None = None
finished_at: float | None = None
class JobDetail(JobBrief):
result: dict[str, Any] | None = None
error: str | None = None
class WaitRequest(BaseModel):
timeout_s: float | None = None
class CancelResponse(BaseModel):
job_id: str
canceled: bool
def _to_brief(entry: JobEntry) -> JobBrief:
return JobBrief(
job_id=entry.id,
workspace_id=entry.workspace_id,
command=entry.command,
status=entry.status.value,
submitted_at=entry.submitted_at,
started_at=entry.started_at,
finished_at=entry.finished_at,
)
async def _to_detail(entry: JobEntry) -> JobDetail:
result_dict: dict[str, Any] | None = None
if entry.result is not None:
result_dict = await io_result_to_dict(entry.result)
return JobDetail(
job_id=entry.id,
workspace_id=entry.workspace_id,
command=entry.command,
status=entry.status.value,
submitted_at=entry.submitted_at,
started_at=entry.started_at,
finished_at=entry.finished_at,
result=result_dict,
error=entry.error,
)
def _require_job(request: Request, job_id: str) -> JobEntry:
table = request.app.state.jobs
if job_id not in table:
raise HTTPException(status_code=404, detail="job not found")
return table.get(job_id)
@router.get("", response_model=list[JobBrief])
async def list_jobs(
request: Request, workspace_id: str | None = Query(None)
) -> list[JobBrief]: # noqa: E125
return [
_to_brief(j)
for j in request.app.state.jobs.list(workspace_id=workspace_id)
]
@router.get("/{job_id}", response_model=JobDetail)
async def get_job(job_id: str, request: Request) -> JobDetail:
return await _to_detail(_require_job(request, job_id))
@router.post("/{job_id}/wait", response_model=JobDetail)
async def wait_job(job_id: str, req: WaitRequest,
request: Request) -> JobDetail:
table = request.app.state.jobs
if job_id not in table:
raise HTTPException(status_code=404, detail="job not found")
entry = await table.wait(job_id, timeout=req.timeout_s)
return await _to_detail(entry)
@router.delete("/{job_id}", response_model=CancelResponse)
async def cancel_job(job_id: str, request: Request) -> CancelResponse:
table = request.app.state.jobs
if job_id not in table:
raise HTTPException(status_code=404, detail="job not found")
canceled = table.cancel(job_id)
return CancelResponse(job_id=job_id, canceled=canceled)
+74
View File
@@ -0,0 +1,74 @@
# ========= 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. =========
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
router = APIRouter(prefix="/v1/workspaces/{workspace_id}/sessions")
class CreateSessionRequest(BaseModel):
session_id: str | None = None
allowed_mounts: list[str] | None = None
class SessionResponse(BaseModel):
session_id: str
cwd: str
class DeleteSessionResponse(BaseModel):
session_id: str
def _require_entry(request: Request, workspace_id: str):
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
return registry.get(workspace_id)
@router.post("", response_model=SessionResponse, status_code=201)
async def create_session(workspace_id: str, req: CreateSessionRequest,
request: Request) -> SessionResponse:
import secrets
entry = _require_entry(request, workspace_id)
sid = req.session_id or f"sess_{secrets.token_hex(6)}"
if any(s.session_id == sid for s in entry.runner.ws.list_sessions()):
raise HTTPException(status_code=409,
detail=f"session id already exists: {sid!r}")
allowed = (frozenset(req.allowed_mounts) if req.allowed_mounts else None)
sess = entry.runner.ws.create_session(sid, allowed_mounts=allowed)
return SessionResponse(session_id=sess.session_id, cwd=sess.cwd)
@router.get("", response_model=list[SessionResponse])
async def list_sessions(workspace_id: str,
request: Request) -> list[SessionResponse]:
entry = _require_entry(request, workspace_id)
return [
SessionResponse(session_id=s.session_id, cwd=s.cwd)
for s in entry.runner.ws.list_sessions()
]
@router.delete("/{session_id}", response_model=DeleteSessionResponse)
async def delete_session(workspace_id: str, session_id: str,
request: Request) -> DeleteSessionResponse:
entry = _require_entry(request, workspace_id)
if not any(s.session_id == session_id
for s in entry.runner.ws.list_sessions()):
raise HTTPException(status_code=404, detail="session not found")
await entry.runner.call(entry.runner.ws.close_session(session_id))
return DeleteSessionResponse(session_id=session_id)
+170
View File
@@ -0,0 +1,170 @@
# ========= 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. =========
from fastapi import APIRouter, HTTPException, Query, Request
from mirage import Workspace
from mirage.server.clone import clone_workspace_with_override
from mirage.server.summary import make_detail
from mirage.server.version.api import (branch, checkout, commit_state,
diff_live_vs_ref, read_version,
resolve_ref, status_state, version_diff,
version_log)
from mirage.server.version.errors import HeadMovedError, NoSuchBranchError
from mirage.server.version.state_tree import to_state
from mirage.server.version.store import VersionStore
from mirage.workspace.snapshot import to_state_dict
from mirage.server.schemas import ( # isort: skip
BranchRequest, BranchResponse, CheckoutRequest, CloneRequest,
CommitRequest, CommitResponse, DiffResponse, VersionLogItem,
WorkspaceDetail)
router = APIRouter(prefix="/v1")
async def _state_of(ws: Workspace) -> dict:
return await to_state_dict(ws)
@router.post("/workspaces/{workspace_id}/commit",
response_model=CommitResponse)
async def commit_version(workspace_id: str, req: CommitRequest,
request: Request) -> CommitResponse:
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
entry = registry.get(workspace_id)
state = await entry.runner.call(_state_of(entry.runner.ws))
store = await VersionStore.open(request.app.state.version_backend,
workspace_id)
try:
version = await commit_state(store, state, req.branch, req.message)
except HeadMovedError as e:
raise HTTPException(status_code=409, detail=str(e))
except NoSuchBranchError as e:
raise HTTPException(status_code=404, detail=str(e))
return CommitResponse(version=version.decode(), branch=req.branch)
@router.get("/workspaces/{workspace_id}/versions",
response_model=list[VersionLogItem])
async def list_versions(
workspace_id: str, request: Request, branch: str = Query("main")
) -> list[VersionLogItem]: # noqa: E125
store = await VersionStore.open(request.app.state.version_backend,
workspace_id)
if branch not in await store.branches():
return []
entries = await version_log(store, branch)
return [VersionLogItem(id=e["id"], message=e["message"]) for e in entries]
@router.post("/workspaces/{workspace_id}/branch",
response_model=BranchResponse,
status_code=201)
async def create_branch(workspace_id: str, req: BranchRequest,
request: Request) -> BranchResponse:
store = await VersionStore.open(request.app.state.version_backend,
workspace_id)
if req.name in await store.branches():
raise HTTPException(status_code=409,
detail=f"branch already exists: {req.name!r}")
try:
await branch(store, req.name, req.from_branch)
except KeyError:
raise HTTPException(status_code=404,
detail=f"no such branch: {req.from_branch!r}")
head = await store.head(req.name)
return BranchResponse(branch=req.name, version=head.decode())
@router.get("/workspaces/{workspace_id}/diff", response_model=DiffResponse)
async def diff_versions(
workspace_id: str,
request: Request,
a: str | None = Query(None),
b: str | None = Query(None),
branch: str = Query("main"),
) -> DiffResponse: # noqa: E125
store = await VersionStore.open(request.app.state.version_backend,
workspace_id)
state = None
if a is None or b is None:
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
entry = registry.get(workspace_id)
state = await entry.runner.call(_state_of(entry.runner.ws))
try:
if a is not None and b is not None:
changes = await version_diff(store, await resolve_ref(store, a),
await resolve_ref(store, b))
elif a is not None:
changes = await diff_live_vs_ref(store, state, a)
else:
changes = await status_state(store, state, branch)
except KeyError:
raise HTTPException(status_code=404, detail="version not found")
return DiffResponse(**changes)
@router.post("/workspaces/{workspace_id}/checkout",
response_model=WorkspaceDetail)
async def checkout_version(workspace_id: str, req: CheckoutRequest,
request: Request) -> WorkspaceDetail:
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
entry = registry.get(workspace_id)
store = await VersionStore.open(request.app.state.version_backend,
workspace_id)
try:
await entry.runner.call(checkout(store, entry.runner.ws, req.ref))
except KeyError:
raise HTTPException(status_code=404,
detail=f"version not found: {req.ref}")
return await make_detail(entry)
@router.post("/workspaces/clone",
response_model=WorkspaceDetail,
status_code=201)
async def clone_workspace_version(req: CloneRequest,
request: Request) -> WorkspaceDetail:
registry = request.app.state.registry
if req.id is not None and req.id in registry:
raise HTTPException(status_code=409,
detail=f"workspace id already exists: {req.id!r}")
if req.at is not None:
store = await VersionStore.open(request.app.state.version_backend,
req.source_id)
version = await resolve_ref(store, req.at)
try:
entries, meta = await read_version(store, version)
except KeyError:
raise HTTPException(status_code=404,
detail=f"version not found: {req.at}")
ws = await Workspace.from_state(to_state(entries, meta))
else:
if req.source_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
src = registry.get(req.source_id)
ws = await src.runner.call(
clone_workspace_with_override(src.runner.ws, None))
try:
entry = registry.add(ws, workspace_id=req.id)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
return await make_detail(entry)
+166
View File
@@ -0,0 +1,166 @@
# ========= 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. =========
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from mirage import Workspace
from mirage.resource.registry import build_resource
from mirage.server.clone import clone_workspace_with_override
from mirage.server.paths import PathOutsideRootError, resolve_within_root
from mirage.server.summary import make_brief, make_detail
from mirage.workspace.snapshot.utils import norm_mount_prefix
from mirage.server.schemas import ( # isort: skip
CloneWorkspaceRequest, CreateWorkspaceRequest, DeleteWorkspaceResponse,
LoadWorkspaceRequest, SnapshotWorkspaceRequest, SnapshotWorkspaceResponse,
WorkspaceBrief, WorkspaceDetail)
router = APIRouter(prefix="/v1/workspaces")
@router.post("", response_model=WorkspaceDetail, status_code=201)
async def create_workspace(req: CreateWorkspaceRequest,
request: Request) -> WorkspaceDetail:
registry = request.app.state.registry
if req.id is not None and req.id in registry:
raise HTTPException(status_code=409,
detail=f"workspace id already exists: {req.id!r}")
kwargs = req.config.to_workspace_kwargs()
ws = Workspace(**kwargs)
try:
for prefix, target in req.config.fuse_mounts().items():
mountpoint = target if isinstance(target, str) else None
ws.add_fuse_mount(prefix, mountpoint)
entry = registry.add(ws, workspace_id=req.id)
except ValueError as e:
await ws.close()
raise HTTPException(status_code=409, detail=str(e))
except Exception:
await ws.close()
raise
return await make_detail(entry)
@router.get("", response_model=list[WorkspaceBrief])
async def list_workspaces(request: Request) -> list[WorkspaceBrief]:
return [make_brief(e) for e in request.app.state.registry.list()]
@router.get("/{workspace_id}", response_model=WorkspaceDetail)
async def get_workspace(
workspace_id: str, request: Request, verbose: bool = Query(False)
) -> WorkspaceDetail: # noqa: E125
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
return await make_detail(registry.get(workspace_id), verbose=verbose)
@router.delete("/{workspace_id}", response_model=DeleteWorkspaceResponse)
async def delete_workspace(workspace_id: str,
request: Request) -> DeleteWorkspaceResponse:
import time
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
await registry.remove(workspace_id)
return DeleteWorkspaceResponse(id=workspace_id, closed_at=time.time())
@router.post("/{workspace_id}/clone",
response_model=WorkspaceDetail,
status_code=201)
async def clone_workspace(workspace_id: str, req: CloneWorkspaceRequest,
request: Request) -> WorkspaceDetail:
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
if req.id is not None and req.id in registry:
raise HTTPException(status_code=409,
detail=f"workspace id already exists: {req.id!r}")
src_entry = registry.get(workspace_id)
new_ws = await src_entry.runner.call(
clone_workspace_with_override(src_entry.runner.ws, req.override))
try:
entry = registry.add(new_ws, workspace_id=req.id)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
return await make_detail(entry)
@router.post("/{workspace_id}/snapshot",
response_model=SnapshotWorkspaceResponse)
async def snapshot_workspace(workspace_id: str, req: SnapshotWorkspaceRequest,
request: Request) -> SnapshotWorkspaceResponse:
registry = request.app.state.registry
if workspace_id not in registry:
raise HTTPException(status_code=404, detail="workspace not found")
entry = registry.get(workspace_id)
try:
target = resolve_within_root(request.app.state.snapshot_root, req.path)
except PathOutsideRootError as e:
raise HTTPException(status_code=400, detail=str(e))
target.parent.mkdir(parents=True, exist_ok=True)
await entry.runner.call(_run_snapshot(entry.runner.ws, str(target)))
return SnapshotWorkspaceResponse(id=workspace_id,
path=str(target),
size=target.stat().st_size)
async def _run_snapshot(ws: Workspace, target: str) -> None:
await ws.snapshot(target)
@router.post("/load", response_model=WorkspaceDetail, status_code=201)
async def load_workspace(req: LoadWorkspaceRequest,
request: Request) -> WorkspaceDetail:
registry = request.app.state.registry
try:
safe_path = resolve_within_root(request.app.state.snapshot_root,
req.path)
except PathOutsideRootError as e:
raise HTTPException(status_code=400, detail=str(e))
if req.id is not None and req.id in registry:
raise HTTPException(status_code=409,
detail=f"workspace id already exists: {req.id!r}")
resources = _build_load_resources(req.override)
try:
ws = await Workspace.load(str(safe_path), resources=resources)
except FileNotFoundError:
raise HTTPException(status_code=400,
detail=f"snapshot not found: {req.path}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
try:
entry = registry.add(ws, workspace_id=req.id)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
return await make_detail(entry)
def _build_load_resources(override: dict[str, Any] | None) -> dict | None:
if not override or "mounts" not in override:
return None
out: dict = {}
for prefix, block in override["mounts"].items():
if not isinstance(block, dict):
continue
resource_name = block.get("resource")
config = block.get("config") or {}
if resource_name is None:
continue
out[norm_mount_prefix(prefix)] = build_resource(resource_name, config)
return out or None
+48
View File
@@ -0,0 +1,48 @@
# ========= 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. =========
from mirage.server.schemas.common import MountSummary, SessionSummary
from mirage.server.schemas.health import HealthResponse
from mirage.server.schemas.versions import (BranchRequest, BranchResponse,
CheckoutRequest, CloneRequest,
CommitRequest, CommitResponse,
DiffResponse, VersionLogItem)
from mirage.server.schemas.workspaces import ( # isort: skip
CloneWorkspaceRequest, CreateWorkspaceRequest, DeleteWorkspaceResponse,
LoadWorkspaceRequest, SnapshotWorkspaceRequest, SnapshotWorkspaceResponse,
WorkspaceBrief, WorkspaceDetail, WorkspaceInternals)
__all__ = [
"MountSummary",
"SessionSummary",
"HealthResponse",
"WorkspaceInternals",
"WorkspaceBrief",
"WorkspaceDetail",
"CreateWorkspaceRequest",
"CloneWorkspaceRequest",
"SnapshotWorkspaceRequest",
"SnapshotWorkspaceResponse",
"LoadWorkspaceRequest",
"DeleteWorkspaceResponse",
"CommitRequest",
"CommitResponse",
"VersionLogItem",
"CheckoutRequest",
"CloneRequest",
"DiffResponse",
"BranchRequest",
"BranchResponse",
]
+27
View File
@@ -0,0 +1,27 @@
# ========= 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. =========
from pydantic import BaseModel
class MountSummary(BaseModel):
prefix: str
resource: str
mode: str
description: str = ""
class SessionSummary(BaseModel):
session_id: str
cwd: str
+21
View File
@@ -0,0 +1,21 @@
# ========= 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. =========
from pydantic import BaseModel
class HealthResponse(BaseModel):
status: str = "ok"
workspaces: int
uptime_s: float
+64
View File
@@ -0,0 +1,64 @@
# ========= 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. =========
from pydantic import BaseModel, ConfigDict
class CommitRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
branch: str = "main"
message: str = ""
class CommitResponse(BaseModel):
version: str
branch: str
class VersionLogItem(BaseModel):
id: str
message: str
class CheckoutRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
ref: str
class CloneRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str
at: str | None = None
id: str | None = None
class DiffResponse(BaseModel):
added: list[str]
modified: list[str]
deleted: list[str]
class BranchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
from_branch: str = "main"
class BranchResponse(BaseModel):
branch: str
version: str
@@ -0,0 +1,84 @@
# ========= 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. =========
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from mirage.config import WorkspaceConfig
from mirage.server.schemas.common import MountSummary, SessionSummary
class WorkspaceInternals(BaseModel):
cache_bytes: int | None
cache_entries: int | None
history_length: int
in_flight_jobs: int
class WorkspaceBrief(BaseModel):
id: str
mode: str
mount_count: int
session_count: int
created_at: float
class WorkspaceDetail(BaseModel):
id: str
mode: str
created_at: float
fuse_mountpoints: dict[str, str] = Field(default_factory=dict)
sessions: list[SessionSummary] = Field(default_factory=list)
mounts: list[MountSummary] = Field(default_factory=list)
internals: WorkspaceInternals | None = None
class CreateWorkspaceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
config: WorkspaceConfig
id: str | None = None
class CloneWorkspaceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str | None = None
override: dict[str, Any] | None = None
class SnapshotWorkspaceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
path: str
class SnapshotWorkspaceResponse(BaseModel):
id: str
path: str
size: int
class LoadWorkspaceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
path: str
id: str | None = None
override: dict[str, Any] | None = None
class DeleteWorkspaceResponse(BaseModel):
id: str
closed_at: float
+91
View File
@@ -0,0 +1,91 @@
# ========= 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. =========
from mirage import Workspace
from mirage.resource.history import HISTORY_PREFIX
from mirage.server.registry import WorkspaceEntry
from mirage.server.schemas import (MountSummary, SessionSummary,
WorkspaceBrief, WorkspaceDetail,
WorkspaceInternals)
from mirage.workspace.snapshot.utils import norm_mount_prefix
_AUTO_PREFIXES = {"/dev/", norm_mount_prefix(HISTORY_PREFIX)}
_DESCRIPTION_MAX = 120
def _is_auto_prefix(prefix: str) -> bool:
return prefix in _AUTO_PREFIXES
def _mount_description(resource) -> str:
raw = getattr(resource, "PROMPT", "") or ""
if len(raw) <= _DESCRIPTION_MAX:
return raw
return raw[:_DESCRIPTION_MAX - 1].rstrip() + "\u2026"
def _user_mounts(ws: Workspace):
return [m for m in ws._registry.mounts() if not _is_auto_prefix(m.prefix)]
async def _build_internals(ws: Workspace) -> WorkspaceInternals:
cache = ws.cache
history_len = len(await ws.history())
return WorkspaceInternals(
cache_bytes=cache.cache_size,
cache_entries=cache.cache_entries,
history_length=history_len,
in_flight_jobs=len(ws.job_table.list_jobs()),
)
def make_brief(entry: WorkspaceEntry) -> WorkspaceBrief:
ws = entry.runner.ws
user_mounts = _user_mounts(ws)
workspace_mode = (user_mounts[0].mode.value if user_mounts else "read")
return WorkspaceBrief(
id=entry.id,
mode=workspace_mode,
mount_count=len(user_mounts),
session_count=len(ws.list_sessions()),
created_at=entry.created_at,
)
async def make_detail(entry: WorkspaceEntry,
verbose: bool = False) -> WorkspaceDetail:
ws = entry.runner.ws
user_mounts = _user_mounts(ws)
workspace_mode = (user_mounts[0].mode.value if user_mounts else "read")
mounts = [
MountSummary(
prefix=m.prefix,
resource=m.resource.name,
mode=m.mode.value,
description=_mount_description(m.resource),
) for m in user_mounts
]
sessions = [
SessionSummary(session_id=s.session_id, cwd=s.cwd)
for s in ws.list_sessions()
]
return WorkspaceDetail(
id=entry.id,
mode=workspace_mode,
created_at=entry.created_at,
fuse_mountpoints=ws.fuse_mountpoints,
mounts=mounts,
sessions=sessions,
internals=await _build_internals(ws) if verbose else None,
)
+13
View File
@@ -0,0 +1,13 @@
# ========= 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. =========
+152
View File
@@ -0,0 +1,152 @@
# ========= 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. =========
from mirage.server.version.errors import NoSuchBranchError
from mirage.server.version.state_tree import (CACHE_PREFIX, META_PATH,
blob_to_meta, meta_to_blob,
to_state, tree_inputs_from_state)
from mirage.server.version.store import VersionStore
from mirage.types import DriftPolicy, StateKey
from mirage.workspace.snapshot import (apply_state_dict, install_fingerprints,
to_state_dict)
async def snapshot_tree(store: VersionStore, ws) -> bytes:
return await snapshot_tree_from_state(store, await to_state_dict(ws))
async def snapshot_tree_from_state(store: VersionStore, state: dict) -> bytes:
entries, meta = tree_inputs_from_state(state)
tree_entries: dict[str, bytes] = {}
for path, data in entries.items():
tree_entries[path] = await store.write_blob(data)
tree_entries[META_PATH] = await store.write_blob(meta_to_blob(meta))
return await store.write_tree(tree_entries)
async def commit(store: VersionStore,
ws,
branch: str = "main",
message: str = "") -> bytes:
return await commit_state(store, await to_state_dict(ws), branch, message)
async def commit_state(store: VersionStore,
state: dict,
branch: str = "main",
message: str = "") -> bytes:
tree = await snapshot_tree_from_state(store, state)
branches = await store.branches()
parents: list[bytes] = []
if branch in branches:
parents = [await store.head(branch)]
elif branches:
raise NoSuchBranchError(branch)
return await store.commit(tree, parents, branch, message)
async def branch(store: VersionStore,
name: str,
from_branch: str = "main") -> None:
head = await store.head(from_branch)
await store.set_branch(name, head)
async def read_version(store: VersionStore,
version: bytes) -> tuple[dict[str, bytes], dict]:
tree = (await store.read_commit(version)).tree
contents = await store.read_tree(tree)
meta_oid = contents.pop(META_PATH, None)
meta = (blob_to_meta(await store.read_blob(meta_oid))
if meta_oid is not None else {
"mounts": []
})
entries: dict[str, bytes] = {}
for path, oid in contents.items():
entries[path] = await store.read_blob(oid)
return entries, meta
async def resolve_ref(store: VersionStore, ref) -> bytes:
if isinstance(ref, str):
if ref in await store.branches():
return await store.head(ref)
return ref.encode()
return ref
async def checkout(store: VersionStore,
ws,
ref,
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> None:
version = await resolve_ref(store, ref)
entries, meta = await read_version(store, version)
state = to_state(entries, meta)
await ws._cache.clear()
await apply_state_dict(ws, state)
install_fingerprints(ws,
state.get(StateKey.FINGERPRINTS) or [], drift_policy)
def _strip_meta(changes: dict[str, list[str]]) -> dict[str, list[str]]:
return {
kind: [
p for p in paths
if p != META_PATH and not p.startswith(CACHE_PREFIX)
]
for kind, paths in changes.items()
}
async def version_log(store: VersionStore, branch: str) -> list[dict]:
out: list[dict] = []
for oid in await store.log(branch):
commit_obj = await store.read_commit(oid)
out.append({
"id": oid.decode(),
"message": commit_obj.message.decode(),
})
return out
async def version_diff(store: VersionStore, version_a: bytes,
version_b: bytes) -> dict[str, list[str]]:
tree_a = (await store.read_commit(version_a)).tree
tree_b = (await store.read_commit(version_b)).tree
return _strip_meta(await store.diff(tree_a, tree_b))
async def diff_live_vs_ref(store: VersionStore, state: dict,
ref) -> dict[str, list[str]]:
live_tree = await snapshot_tree_from_state(store, state)
version = await resolve_ref(store, ref)
ref_tree = (await store.read_commit(version)).tree
return _strip_meta(await store.diff(ref_tree, live_tree))
async def status(store: VersionStore,
ws,
branch: str = "main") -> dict[str, list[str]]:
return await status_state(store, await to_state_dict(ws), branch)
async def status_state(store: VersionStore,
state: dict,
branch: str = "main") -> dict[str, list[str]]:
live_tree = await snapshot_tree_from_state(store, state)
if branch in await store.branches():
head_tree = (await store.read_commit(await store.head(branch))).tree
else:
head_tree = await store.write_tree({})
return _strip_meta(await store.diff(head_tree, live_tree))
+40
View File
@@ -0,0 +1,40 @@
# ========= 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. =========
from pathlib import Path
from typing import Protocol
from dulwich.repo import Repo
from mirage.server.paths import resolve_within_root, validate_path_segment
class VersionBackend(Protocol):
def open_repo(self, workspace_id: str) -> Repo:
...
class LocalBackend:
def __init__(self, root: str | Path) -> None:
self._root = Path(root)
def open_repo(self, workspace_id: str) -> Repo:
path = resolve_within_root(self._root,
validate_path_segment(workspace_id))
if (path / "objects").is_dir():
return Repo(str(path))
path.mkdir(parents=True, exist_ok=True)
return Repo.init_bare(str(path))
+30
View File
@@ -0,0 +1,30 @@
# ========= 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. =========
class HeadMovedError(Exception):
def __init__(self, branch: str) -> None:
self.branch = branch
super().__init__(
f"branch {branch!r} moved since this commit was prepared; "
"refusing to overwrite (re-read the head and retry)")
class NoSuchBranchError(Exception):
def __init__(self, branch: str) -> None:
self.branch = branch
super().__init__(f"no branch {branch!r}; create it first with "
"`mirage workspace branch`")
+172
View File
@@ -0,0 +1,172 @@
# ========= 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 json
from mirage.types import CacheKey, MountKey, ResourceStateKey, StateKey
from mirage.workspace.snapshot.state import to_state_dict
from mirage.workspace.snapshot.tar_io import _json_default
from mirage.workspace.snapshot.utils import FORMAT_VERSION
META_PATH = ".mirage-meta.json"
CACHE_PREFIX = ".mirage-cache/"
def _is_reserved(tree_path: str) -> bool:
return tree_path == META_PATH or tree_path.startswith(CACHE_PREFIX)
def _tree_path(prefix: str, rel: str) -> str:
p = prefix.strip("/")
r = rel.lstrip("/")
return f"{p}/{r}" if p else r
def _rel_path(prefix: str, tree_path: str) -> str:
p = prefix.strip("/")
rest = tree_path[len(p) + 1:] if p else tree_path
return "/" + rest
def _belongs(tree_prefix: str, tree_path: str) -> bool:
if not tree_prefix:
return True
return tree_path == tree_prefix or tree_path.startswith(tree_prefix + "/")
def meta_to_blob(meta: dict) -> bytes:
return json.dumps(meta, default=_json_default).encode("utf-8")
def blob_to_meta(data: bytes) -> dict:
return json.loads(data.decode("utf-8"))
async def to_tree_inputs(ws) -> tuple[dict[str, bytes], dict]:
return tree_inputs_from_state(await to_state_dict(ws))
def tree_inputs_from_state(state: dict) -> tuple[dict[str, bytes], dict]:
entries: dict[str, bytes] = {}
mounts_meta: list[dict] = []
for mount in state[StateKey.MOUNTS]:
prefix = mount[MountKey.PREFIX]
resource_state = dict(mount[MountKey.RESOURCE_STATE])
files = resource_state.pop(ResourceStateKey.FILES, {})
for rel, data in files.items():
entries[_tree_path(prefix, rel)] = data
mounts_meta.append({
MountKey.INDEX:
mount[MountKey.INDEX],
MountKey.PREFIX:
prefix,
MountKey.MODE:
mount[MountKey.MODE],
MountKey.CONSISTENCY:
mount[MountKey.CONSISTENCY],
MountKey.RESOURCE_CLASS:
mount[MountKey.RESOURCE_CLASS],
MountKey.RESOURCE_STATE:
resource_state,
})
cache = state[StateKey.CACHE]
config = {
StateKey.MIRAGE_VERSION: state[StateKey.MIRAGE_VERSION],
StateKey.DEFAULT_SESSION_ID: state[StateKey.DEFAULT_SESSION_ID],
StateKey.DEFAULT_AGENT_ID: state[StateKey.DEFAULT_AGENT_ID],
StateKey.CURRENT_AGENT_ID: state[StateKey.CURRENT_AGENT_ID],
CacheKey.LIMIT: cache[CacheKey.LIMIT],
CacheKey.MAX_DRAIN_BYTES: cache[CacheKey.MAX_DRAIN_BYTES],
}
cache_meta: list[dict] = []
for i, entry in enumerate(cache[CacheKey.ENTRIES]):
ref = f"{CACHE_PREFIX}{i}"
entries[ref] = entry[CacheKey.DATA]
cache_meta.append({
CacheKey.KEY: entry[CacheKey.KEY],
CacheKey.FINGERPRINT: entry.get(CacheKey.FINGERPRINT),
CacheKey.TTL: entry.get(CacheKey.TTL),
CacheKey.CACHED_AT: entry.get(CacheKey.CACHED_AT),
CacheKey.SIZE: entry.get(CacheKey.SIZE),
"ref": ref,
})
meta = {
"mounts": mounts_meta,
"config": config,
"cache": cache_meta,
"fingerprints": state.get(StateKey.FINGERPRINTS) or [],
"sessions": state.get(StateKey.SESSIONS) or [],
}
return entries, meta
def to_state(entries: dict[str, bytes], meta: dict) -> dict:
mounts: list[dict] = []
for mount in meta["mounts"]:
prefix = mount[MountKey.PREFIX]
tree_prefix = prefix.strip("/")
resource_state = dict(mount[MountKey.RESOURCE_STATE])
files: dict[str, bytes] = {}
for tree_path, data in entries.items():
if _is_reserved(tree_path):
continue
if _belongs(tree_prefix, tree_path):
files[_rel_path(prefix, tree_path)] = data
resource_state[ResourceStateKey.FILES] = files
mounts.append({
MountKey.INDEX: mount[MountKey.INDEX],
MountKey.PREFIX: prefix,
MountKey.MODE: mount[MountKey.MODE],
MountKey.CONSISTENCY: mount[MountKey.CONSISTENCY],
MountKey.RESOURCE_CLASS: mount[MountKey.RESOURCE_CLASS],
MountKey.RESOURCE_STATE: resource_state,
})
config = meta.get("config", {})
cache_entries: list[dict] = []
for c in meta.get("cache", []):
cache_entries.append({
CacheKey.KEY: c[CacheKey.KEY],
CacheKey.DATA: entries[c["ref"]],
CacheKey.FINGERPRINT: c.get(CacheKey.FINGERPRINT),
CacheKey.TTL: c.get(CacheKey.TTL),
CacheKey.CACHED_AT: c.get(CacheKey.CACHED_AT),
CacheKey.SIZE: c.get(CacheKey.SIZE),
})
return {
StateKey.VERSION:
FORMAT_VERSION,
StateKey.MIRAGE_VERSION:
config.get(StateKey.MIRAGE_VERSION, "unknown"),
StateKey.MOUNTS:
mounts,
StateKey.SESSIONS:
meta.get("sessions", []),
StateKey.DEFAULT_SESSION_ID:
config.get(StateKey.DEFAULT_SESSION_ID, "default"),
StateKey.DEFAULT_AGENT_ID:
config.get(StateKey.DEFAULT_AGENT_ID, "default"),
StateKey.CURRENT_AGENT_ID:
config.get(StateKey.CURRENT_AGENT_ID, "default"),
StateKey.CACHE: {
CacheKey.LIMIT: config.get(CacheKey.LIMIT, "512MB"),
CacheKey.MAX_DRAIN_BYTES: config.get(CacheKey.MAX_DRAIN_BYTES),
CacheKey.ENTRIES: cache_entries,
},
StateKey.HISTORY:
None,
StateKey.JOBS: [],
StateKey.FINGERPRINTS:
meta.get("fingerprints", []),
StateKey.LIVE_ONLY_MOUNTS: [],
}
+181
View File
@@ -0,0 +1,181 @@
# ========= 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 stat
import time
from dulwich.diff_tree import (CHANGE_ADD, CHANGE_DELETE, CHANGE_MODIFY,
tree_changes)
from dulwich.objects import Blob, Commit, Tree
from dulwich.repo import Repo
from mirage.server.version.backend import VersionBackend
from mirage.server.version.errors import HeadMovedError
FILE_MODE = 0o100644
DIR_MODE = 0o40000
AUTHOR = b"mirage <mirage@local>"
def _add_blob(repo: Repo, data: bytes) -> bytes:
blob = Blob.from_string(data)
repo.object_store.add_object(blob)
return blob.id
def _read_blob(repo: Repo, oid: bytes) -> bytes:
return repo.object_store[oid].as_raw_string()
def _build_tree(repo: Repo, entries: dict[str, bytes]) -> bytes:
tree = Tree()
subdirs: dict[str, dict[str, bytes]] = {}
for path, oid in entries.items():
if "/" in path:
head, rest = path.split("/", 1)
subdirs.setdefault(head, {})[rest] = oid
else:
tree.add(path.encode(), FILE_MODE, oid)
for name, sub in subdirs.items():
tree.add(name.encode(), DIR_MODE, _build_tree(repo, sub))
repo.object_store.add_object(tree)
return tree.id
def _read_tree(repo: Repo, oid: bytes, prefix: str = "") -> dict[str, bytes]:
out: dict[str, bytes] = {}
for name, mode, sha in repo.object_store[oid].items():
rel = name.decode()
full = f"{prefix}/{rel}" if prefix else rel
if stat.S_ISDIR(mode):
out.update(_read_tree(repo, sha, full))
else:
out[full] = sha
return out
def _commit(repo: Repo, tree_oid: bytes, parents: list[bytes], branch: str,
message: str) -> bytes:
commit = Commit()
commit.tree = tree_oid
commit.parents = list(parents)
commit.author = commit.committer = AUTHOR
now = int(time.time())
commit.author_time = commit.commit_time = now
commit.author_timezone = commit.commit_timezone = 0
commit.encoding = b"UTF-8"
commit.message = message.encode()
repo.object_store.add_object(commit)
ref = b"refs/heads/" + branch.encode()
expected_old = parents[0] if parents else None
if expected_old is None:
ok = repo.refs.add_if_new(ref, commit.id)
else:
ok = repo.refs.set_if_equals(ref, expected_old, commit.id)
if not ok:
raise HeadMovedError(branch)
repo.refs.set_symbolic_ref(b"HEAD", ref)
return commit.id
def _head(repo: Repo, branch: str) -> bytes:
return repo.refs[b"refs/heads/" + branch.encode()]
def _set_branch(repo: Repo, name: str, oid: bytes) -> None:
repo.refs[b"refs/heads/" + name.encode()] = oid
def _read_commit(repo: Repo, oid: bytes) -> Commit:
return repo.object_store[oid]
def _branches(repo: Repo) -> list[str]:
prefix = b"refs/heads/"
names = [
name[len(prefix):].decode() for name in repo.get_refs()
if name.startswith(prefix)
]
return sorted(names)
def _log(repo: Repo, branch: str) -> list[bytes]:
head = repo.refs[b"refs/heads/" + branch.encode()]
return [entry.commit.id for entry in repo.get_walker(include=[head])]
def _diff(repo: Repo, tree_a: bytes, tree_b: bytes) -> dict[str, list[str]]:
added: list[str] = []
modified: list[str] = []
deleted: list[str] = []
for change in tree_changes(repo.object_store, tree_a, tree_b):
if change.type == CHANGE_ADD:
added.append(change.new.path.decode())
elif change.type == CHANGE_DELETE:
deleted.append(change.old.path.decode())
elif change.type == CHANGE_MODIFY:
modified.append(change.new.path.decode())
return {
"added": sorted(added),
"modified": sorted(modified),
"deleted": sorted(deleted),
}
class VersionStore:
def __init__(self, repo: Repo) -> None:
self._repo = repo
@classmethod
async def open(cls, backend: VersionBackend,
workspace_id: str) -> "VersionStore":
repo = await asyncio.to_thread(backend.open_repo, workspace_id)
return cls(repo)
async def write_blob(self, data: bytes) -> bytes:
return await asyncio.to_thread(_add_blob, self._repo, data)
async def read_blob(self, oid: bytes) -> bytes:
return await asyncio.to_thread(_read_blob, self._repo, oid)
async def write_tree(self, entries: dict[str, bytes]) -> bytes:
return await asyncio.to_thread(_build_tree, self._repo, entries)
async def read_tree(self, oid: bytes) -> dict[str, bytes]:
return await asyncio.to_thread(_read_tree, self._repo, oid)
async def commit(self, tree_oid: bytes, parents: list[bytes], branch: str,
message: str) -> bytes:
return await asyncio.to_thread(_commit, self._repo, tree_oid, parents,
branch, message)
async def head(self, branch: str) -> bytes:
return await asyncio.to_thread(_head, self._repo, branch)
async def set_branch(self, name: str, oid: bytes) -> None:
await asyncio.to_thread(_set_branch, self._repo, name, oid)
async def read_commit(self, oid: bytes) -> Commit:
return await asyncio.to_thread(_read_commit, self._repo, oid)
async def branches(self) -> list[str]:
return await asyncio.to_thread(_branches, self._repo)
async def log(self, branch: str) -> list[bytes]:
return await asyncio.to_thread(_log, self._repo, branch)
async def diff(self, tree_a: bytes, tree_b: bytes) -> dict[str, list[str]]:
return await asyncio.to_thread(_diff, self._repo, tree_a, tree_b)