     1→import os
     2→import signal
     3→import subprocess
     4→import sys
     5→import time
     6→import uuid
     7→from datetime import datetime, timezone
     8→
     9→from pydantic import BaseModel, ConfigDict, Field
    10→
    11→from mirage.backend.loader import load_backend_class
    12→from mirage.cli.config import parse_mount_uri, split_mount_spec
    13→from mirage.commands import COMMANDS, _resolve
    14→from mirage.commands.builtin import nav
    15→from mirage.commands.custom.registry import CommandRegistry, ExternalCommand
    16→from mirage.fuse import FUSE_OPS
    17→from mirage.fuse.loader import build_registry_from_config, load_fuse_op_class
    18→from mirage.fuse.registry import FuseOpRegistry
    19→from mirage.observe.entries import (CommandEntry, CommandResult, DispatchInfo,
    20→                                    ParsedCommand, SessionEntry, WorkspaceInfo)
    21→from mirage.observe.session_log import get_or_create_log
    22→from mirage.runtime.base import _BLOCKED, CommandNotAllowedError
    23→from mirage.server.service.base import MirageService
    24→from mirage.server.service.cache import FileCache
    25→from mirage.types import (CommandStats, ConsistencyPolicy, FileStat, MountMode,
    26→                          RuntimeType, SyncPolicy)
    27→from mirage.workspace import Workspace
    28→
    29→
    30→class WorkspaceSession(BaseModel):
    31→    model_config = ConfigDict(arbitrary_types_allowed=True)
    32→
    33→    id: str
    34→    ws: Workspace | None
    35→    registry: CommandRegistry | None
    36→    cwd: str = "/"
    37→    fuse_mounted: bool = False
    38→    mode: MountMode = MountMode.WRITE
    39→    mountpoint: str | None = None
    40→    pid: int | None = None
    41→    mounts: list[dict] = Field(default_factory=list)
    42→    runtime_type: RuntimeType | None = None
    43→    fuse_registry: FuseOpRegistry = Field(default_factory=FuseOpRegistry)
    44→    command_stats: CommandStats = Field(default_factory=CommandStats)
    45→    created_at: float = Field(default_factory=time.monotonic)
    46→    reported_status: dict | None = None
    47→    reported_transfers: dict | None = None
    48→
    49→
    50→def _unmount_fuse(mountpoint: str, pid: int | None) -> None:
    51→    if sys.platform == "darwin":
    52→        subprocess.run(["diskutil", "unmount", "force", mountpoint],
    53→                       capture_output=True)
    54→    else:
    55→        subprocess.run(["fusermount", "-u", mountpoint], capture_output=True)
    56→    if pid:
    57→        try:
    58→            os.kill(pid, signal.SIGTERM)
    59→        except ProcessLookupError:
    60→            pass
    61→
    62→
    63→class LocalService(MirageService):
    64→
    65→    def __init__(self) -> None:
    66→        self._sessions: dict[str, WorkspaceSession] = {}
    67→        self._file_cache = FileCache()
    68→
    69→    def _get_session(self, ws_id: str) -> WorkspaceSession:
    70→        session = self._sessions.get(ws_id)
    71→        if session is None:
    72→            raise KeyError(f"workspace {ws_id!r} not found")
    73→        if session.fuse_mounted:
    74→            raise ValueError("not supported for fuse workspaces")
    75→        return session
    76→
    77→    def create_workspace(self, config: dict) -> str:
    78→        mode = config.get("mode", "write")
    79→        consistency = config.get("consistency", "cached")
    80→        default_mode = MountMode(mode)
    81→        default_consistency = ConsistencyPolicy(consistency)
    82→        mounts = config.get("mounts", {})
    83→
    84→        backends: dict = {}
    85→        for prefix, value in mounts.items():
    86→            if isinstance(value, str):
    87→                backends[prefix] = parse_mount_uri(value)
    88→            else:
    89→                backend_spec = value.get("backend")
    90→                if backend_spec:
    91→                    cls = load_backend_class(backend_spec)
    92→                    backend = cls(**(value.get("args") or {}))
    93→                else:
    94→                    backend = parse_mount_uri(value.get("uri") or "")
    95→                mount_mode = (MountMode(value["mode"])
    96→                              if value.get("mode") else default_mode)
    97→                mount_consistency = (ConsistencyPolicy(value["consistency"])
    98→                                     if value.get("consistency") else
    99→                                     default_consistency)
   100→                if (mount_mode != default_mode
   101→                        or mount_consistency != default_consistency):
   102→                    backends[prefix] = (backend, mount_mode, mount_consistency)
   103→                else:
   104→                    backends[prefix] = backend
   105→
   106→        ws = Workspace(
   107→            backends,
   108→            sync_policy=SyncPolicy(config.get("sync_policy", "staged")),
   109→            mode=default_mode,
   110→            consistency=default_consistency,
   111→            ttl_seconds=config.get("ttl_seconds", 300),
   112→        )
   113→
   114→        mounts_info: list[dict] = []
   115→        for prefix, value in mounts.items():
   116→            if isinstance(value, str):
   117→                uri = value
   118→            else:
   119→                uri = value.get("uri") or ""
   120→                if not uri and value.get("backend"):
   121→                    uri = f"{value['backend']}://"
   122→            mounts_info.append({"prefix": prefix, "uri": uri})
   123→
   124→        ws_id = str(uuid.uuid4())[:8]
   125→        registry = CommandRegistry()
   126→        fuse_ops_config = config.get("fuse_ops") or {}
   127→        fuse_registry = build_registry_from_config(fuse_ops_config)
   128→        runtime = config.get("runtime")
   129→        rt_type = RuntimeType(runtime) if runtime else None
   130→
   131→        self._sessions[ws_id] = WorkspaceSession(
   132→            id=ws_id,
   133→            ws=ws,
   134→            registry=registry,
   135→            mode=mode,
   136→            mounts=mounts_info,
   137→            runtime_type=rt_type,
   138→            fuse_registry=fuse_registry,
   139→        )
   140→
   141→        log = get_or_create_log(ws_id)
   142→        mount_info = {}
   143→        for m in ws._registry.mounts():
   144→            mount_info[m.prefix] = {
   145→                "type": type(m.backend).__name__,
   146→                "mode": m.mode.value,
   147→            }
   148→        session_entry = SessionEntry(
   149→            timestamp=datetime.now(timezone.utc).isoformat(),
   150→            workspace=WorkspaceInfo(
   151→                mounts=mount_info,
   152→                cache_limit=str(ws._cache.limit),
   153→                runtime_type=rt_type.value if rt_type else None,
   154→            ),
   155→            commands=registry.list_commands(),
   156→        )
   157→        log.write_session(session_entry)
   158→        return ws_id
   159→
   160→    def delete_workspace(self, ws_id: str) -> None:
   161→        session = self._sessions.get(ws_id)
   162→        if session is None:
   163→            raise KeyError(f"workspace {ws_id!r} not found")
   164→        if session.fuse_mounted and session.mountpoint:
   165→            _unmount_fuse(session.mountpoint, session.pid)
   166→        self._file_cache.clear(ws_id)
   167→        del self._sessions[ws_id]
   168→
   169→    def list_workspaces(self) -> list[dict]:
   170→        return [{
   171→            "id": s.id,
   172→            "fuse_mounted": s.fuse_mounted,
   173→            "mode": s.mode,
   174→            "mountpoint": s.mountpoint,
   175→            "mounts": s.mounts,
   176→            "runtime": s.runtime_type.value if s.runtime_type else None,
   177→        } for s in self._sessions.values()]
   178→
   179→    def exec(
   180→        self,
   181→        ws_id: str,
   182→        command: str,
   183→        cwd: str | None = None,
   184→        stdin: bytes | None = None,
   185→        input_files: dict[str, tuple[str, bytes | None]] | None = None,
   186→    ) -> tuple[bool, str, dict[str, bytes] | None, dict[str, str] | None]:
   187→        session = self._get_session(ws_id)
   188→        if cwd is not None:
   189→            session.cwd = cwd
   190→
   191→        cache_status: dict[str, str] = {}
   192→        if input_files:
   193→            for path, (checksum, data) in input_files.items():
   194→                hit, _ = self._file_cache.get(ws_id, path, checksum)
   195→                if hit:
   196→                    cache_status[path] = "hit"
   197→                elif data is not None:
   198→                    self._file_cache.put(ws_id, path, checksum, data)
   199→                    session.ws.write(path, data)
   200→                    cache_status[path] = "miss"
   201→                else:
   202→                    return (False, f"cache miss: {path}", None, None)
   203→
   204→        cmd_name = command.strip().split()[0] if command.strip() else ""
   205→        session.command_stats.total_commands += 1
   206→        session.command_stats.command_counts[cmd_name] = (
   207→            session.command_stats.command_counts.get(cmd_name, 0) + 1)
   208→        session.command_stats.last_command = command
   209→        session.command_stats.last_timestamp = datetime.now(
   210→            timezone.utc).isoformat()
   211→        t0 = time.monotonic()
   212→        try:
   213→            if session.runtime_type is not None and cmd_name in _BLOCKED:
   214→                raise CommandNotAllowedError(
   215→                    f"{cmd_name!r} is not allowed in this runtime")
   216→            output = session.registry.run(session.ws,
   217→                                          command,
   218→                                          cwd=session.cwd,
   219→                                          stdin=stdin)
   220→            duration_ms = int((time.monotonic() - t0) * 1000)
   221→            if command.strip().startswith("cd "):
   222→                parts = command.strip().split(None, 1)
   223→                if len(parts) > 1:
   224→                    session.cwd = nav.cd(session.ws, session.cwd,
   225→                                         _resolve(session.cwd, parts[1]))
   226→            args = command.strip().split()[1:] if command.strip() else []
   227→            source = "builtin" if cmd_name in COMMANDS else "external"
   228→            log = get_or_create_log(ws_id)
   229→            entry = CommandEntry(
   230→                timestamp=datetime.now(timezone.utc).isoformat(),
   231→                raw=command,
   232→                parsed=ParsedCommand(
   233→                    pipeline=[command],
   234→                    args={cmd_name: args},
   235→                ),
   236→                dispatch=[
   237→                    DispatchInfo(
   238→                        command=cmd_name,
   239→                        handler=f"_{cmd_name}"
   240→                        if source == "builtin" else cmd_name,
   241→                        source=source,
   242→                    )
   243→                ],
   244→                result=CommandResult(
   245→                    exit_code=0,
   246→                    stdout_bytes=len(output.encode()) if output else 0,
   247→                    duration_ms=duration_ms,
   248→                ),
   249→            )
   250→            log.write_command(entry)
   251→
   252→            return (True, output, None, cache_status or None)
   253→        except Exception as e:
   254→            duration_ms = int((time.monotonic() - t0) * 1000)
   255→            session.command_stats.total_errors += 1
   256→            log = get_or_create_log(ws_id)
   257→            entry = CommandEntry(
   258→                timestamp=datetime.now(timezone.utc).isoformat(),
   259→                raw=command,
   260→                result=CommandResult(exit_code=1,
   261→                                     stdout_bytes=0,
   262→                                     duration_ms=duration_ms),
   263→            )
   264→            log.write_command(entry)
   265→            return (False, str(e), None, None)
   266→
   267→    def list_commands(self, ws_id: str) -> list[str]:
   268→        session = self._get_session(ws_id)
   269→        return session.registry.list_commands()
   270→
   271→    def register_command(self,
   272→                         ws_id: str,
   273→                         name: str,
   274→                         exec_str: str,
   275→                         description: str = "") -> None:
   276→        session = self._get_session(ws_id)
   277→        session.registry.register(
   278→            ExternalCommand(name=name, exec=exec_str, description=description))
   279→
   280→    def sync(self, ws_id: str) -> list[dict]:
   281→        session = self._get_session(ws_id)
   282→        results = session.ws.sync()
   283→        return [{"path": r.path, "status": r.status} for r in results]
   284→
   285→    def status(self, ws_id: str) -> list[dict]:
   286→        session = self._get_session(ws_id)
   287→        inodes = session.ws.status()
   288→        return [{
   289→            "path": i.virtual_path,
   290→            "dirty": i.dirty,
   291→            "size": i.size
   292→        } for i in inodes]
   293→
   294→    # ── File I/O (backend-level) ─────────────────────────────────────────
   295→
   296→    def io_read(self, ws_id: str, path: str, offset: int, size: int) -> bytes:
   297→        session = self._get_session(ws_id)
   298→        backend, backend_path, _ = session.ws._registry.resolve(path)
   299→        return backend.read(backend_path, offset, size)
   300→
   301→    def io_write(self, ws_id: str, path: str, offset: int, data: bytes) -> int:
   302→        session = self._get_session(ws_id)
   303→        backend, backend_path, _ = session.ws._registry.resolve(path)
   304→        return backend.write(backend_path, offset, data)
   305→
   306→    def io_stat(self, ws_id: str, path: str) -> FileStat:
   307→        session = self._get_session(ws_id)
   308→        backend, backend_path, _ = session.ws._registry.resolve(path)
   309→        return backend.stat(backend_path)
   310→
   311→    def io_readdir(self,
   312→                   ws_id: str,
   313→                   path: str,
   314→                   cursor: str | None = None) -> tuple[list[str], str | None]:
   315→        session = self._get_session(ws_id)
   316→        backend, backend_path, _ = session.ws._registry.resolve(path)
   317→        return backend.readdir(backend_path, cursor)
   318→
   319→    def io_create(self, ws_id: str, path: str) -> None:
   320→        session = self._get_session(ws_id)
   321→        backend, backend_path, _ = session.ws._registry.resolve(path)
   322→        backend.create(backend_path)
   323→
   324→    def io_unlink(self, ws_id: str, path: str) -> None:
   325→        session = self._get_session(ws_id)
   326→        backend, backend_path, _ = session.ws._registry.resolve(path)
   327→        backend.unlink(backend_path)
   328→
   329→    def io_mkdir(self, ws_id: str, path: str) -> None:
   330→        session = self._get_session(ws_id)
   331→        backend, backend_path, _ = session.ws._registry.resolve(path)
   332→        backend.mkdir(backend_path)
   333→
   334→    def io_rmdir(self, ws_id: str, path: str) -> None:
   335→        session = self._get_session(ws_id)
   336→        backend, backend_path, _ = session.ws._registry.resolve(path)
   337→        backend.rmdir(backend_path)
   338→
   339→    def io_rename(self, ws_id: str, src: str, dst: str) -> None:
   340→        session = self._get_session(ws_id)
   341→        backend, src_path, _ = session.ws._registry.resolve(src)
   342→        _, dst_path, _ = session.ws._registry.resolve(dst)
   343→        backend.rename(src_path, dst_path)
   344→
   345→    def io_truncate(self, ws_id: str, path: str, size: int) -> None:
   346→        session = self._get_session(ws_id)
   347→        backend, backend_path, _ = session.ws._registry.resolve(path)
   348→        backend.truncate(backend_path, size)
   349→
   350→    def io_fuse_cat(self, ws_id: str, path: str) -> bytes:
   351→        session = self._get_session(ws_id)
   352→        st = session.ws.stat(path)
   353→        op = session.fuse_registry.resolve(path, st.size or 0, session.ws)
   354→        ctx = op.on_open(session.ws, path, st.size or 0)
   355→        return op.read(session.ws, path, 0, st.size or 0, ctx)
   356→
   357→    # ── FuseOp management ────────────────────────────────────────────────
   358→
   359→    def list_fuse_ops(self, ws_id: str) -> list[dict]:
   360→        session = self._get_session(ws_id)
   361→        return session.fuse_registry.list_ops()
   362→
   363→    def register_fuse_op(self, ws_id: str, pattern: str, handler: str) -> None:
   364→        session = self._get_session(ws_id)
   365→        op = FUSE_OPS.get(handler)
   366→        if op is None:
   367→            try:
   368→                cls = load_fuse_op_class(handler)
   369→                op = cls()
   370→            except (ValueError, Exception) as e:
   371→                raise ValueError(f"unknown handler: {handler} ({e})")
   372→        session.fuse_registry.register(pattern, op)
   373→
   374→    # ── FUSE-specific (not part of MirageService ABC) ────────────────────
   375→
   376→    def register_fuse(self,
   377→                      mountpoint: str,
   378→                      pid: int,
   379→                      mount_specs: list[str] | None = None,
   380→                      runtime: str | None = None) -> str:
   381→        fuse_mounts: list[dict] = []
   382→        for spec in (mount_specs or []):
   383→            try:
   384→                uri, prefix = split_mount_spec(spec)
   385→                fuse_mounts.append({"prefix": prefix, "uri": uri})
   386→            except ValueError:
   387→                pass
   388→        ws_id = str(uuid.uuid4())[:8]
   389→        rt_type = RuntimeType(runtime) if runtime else None
   390→        self._sessions[ws_id] = WorkspaceSession(
   391→            id=ws_id,
   392→            ws=None,
   393→            registry=None,
   394→            fuse_mounted=True,
   395→            mountpoint=mountpoint,
   396→            pid=pid,
   397→            mounts=fuse_mounts,
   398→            runtime_type=rt_type,
   399→        )
   400→        mount_info = {m["prefix"]: {"type": m["uri"]} for m in fuse_mounts}
   401→        log = get_or_create_log(ws_id)
   402→        session_entry = SessionEntry(
   403→            timestamp=datetime.now(timezone.utc).isoformat(),
   404→            workspace=WorkspaceInfo(
   405→                mounts=mount_info,
   406→                runtime_type=rt_type.value if rt_type else None,
   407→            ),
   408→        )
   409→        log.write_session(session_entry)
   410→        return ws_id
   411→
   412→    def report_fuse_stats(self,
   413→                          mountpoint: str,
   414→                          status: dict,
   415→                          transfers: dict | None = None,
   416→                          ops: list[dict] | None = None) -> bool:
   417→        for s in self._sessions.values():
   418→            if s.fuse_mounted and s.mountpoint == mountpoint:
   419→                s.reported_status = status
   420→                s.reported_transfers = transfers or {}
   421→                if ops:
   422→                    log = get_or_create_log(s.id)
   423→                    for op in ops:
   424→                        log._append(op)
   425→                return True
   426→        return False
   427→


     1→from pydantic import BaseModel
     2→
     3→from mirage.types import ConsistencyPolicy, MountMode, SyncPolicy
     4→
     5→
     6→class MountConfig(BaseModel):
     7→    uri: str | None = None
     8→    backend: str | None = None
     9→    args: dict[str, str] | None = None
    10→    mode: MountMode | None = None
    11→    consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED
    12→
    13→
    14→class CreateWorkspaceRequest(BaseModel):
    15→    mounts: dict[str, str | MountConfig]
    16→    sync_policy: SyncPolicy = SyncPolicy.STAGED
    17→    mode: MountMode = MountMode.WRITE
    18→    consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED
    19→    ttl_seconds: int = 300
    20→    runtime: str | None = None
    21→    fuse_ops: dict[str, str] | None = None
    22→
    23→
    24→class RegisterFuseRequest(BaseModel):
    25→    mountpoint: str
    26→    pid: int
    27→    mount_specs: list[str] = []
    28→    runtime: str | None = None
    29→
    30→
    31→class FilePayloadRequest(BaseModel):
    32→    checksum: str
    33→    data: str | None = None
    34→
    35→
    36→class ExecRequest(BaseModel):
    37→    command: str
    38→    cwd: str | None = None
    39→    stdin: str | None = None
    40→    input_files: dict[str, FilePayloadRequest] | None = None
    41→
    42→
    43→class FuseReportRequest(BaseModel):
    44→    mountpoint: str
    45→    status: dict
    46→    transfers: dict = {}
    47→    ops: list[dict] = []
    48→
    49→
    50→class RegisterCommandRequest(BaseModel):
    51→    name: str
    52→    exec: str
    53→    description: str = ""
    54→
    55→
    56→class RegisterFuseOpRequest(BaseModel):
    57→    pattern: str
    58→    handler: str
    59→
    60→
    61→class ReadRequest(BaseModel):
    62→    path: str
    63→    offset: int
    64→    size: int
    65→
    66→
    67→class WriteRequest(BaseModel):
    68→    path: str
    69→    offset: int
    70→    data: str
    71→
    72→
    73→class PathRequest(BaseModel):
    74→    path: str
    75→
    76→
    77→class ReaddirRequest(BaseModel):
    78→    path: str
    79→    cursor: str | None = None
    80→
    81→
    82→class RenameRequest(BaseModel):
    83→    src: str
    84→    dst: str
    85→
    86→
    87→class TruncateRequest(BaseModel):
    88→    path: str
    89→    size: int
    90→


     1→import os
     2→
     3→import yaml
     4→
     5→from mirage.backend.base import BaseBackend
     6→from mirage.backend.local.local import LocalBackend
     7→from mirage.backend.local.memory import MemoryBackend
     8→from mirage.types import BackendType, ConsistencyPolicy, MountMode
     9→
    10→
    11→def _build_backend(type_name: str, connection: str) -> BaseBackend:
    12→    if type_name == BackendType.MEMORY:
    13→        return MemoryBackend()
    14→    if type_name == BackendType.LOCAL:
    15→        return LocalBackend(root=os.path.abspath(connection))
    16→    if type_name == BackendType.S3:
    17→        from mirage.backend.object_storage.s3 import S3Backend, S3Config
    18→        rest = connection[len("s3://"):]
    19→        bucket = rest.split("/", 1)[0]
    20→        return S3Backend(S3Config(bucket=bucket))
    21→    if type_name == BackendType.R2:
    22→        from mirage.backend.object_storage.r2 import R2Backend, R2Config
    23→        rest = connection[len("r2://"):]
    24→        parts = rest.split("/", 1)
    25→        return R2Backend(
    26→            R2Config(
    27→                account_id=parts[0],
    28→                bucket=parts[1] if len(parts) > 1 else "",
    29→                access_key_id=os.environ.get("R2_ACCESS_KEY_ID", ""),
    30→                secret_access_key=os.environ.get("R2_SECRET_ACCESS_KEY", ""),
    31→            ))
    32→    if type_name in (BackendType.HTTP, BackendType.HTTPS):
    33→        from mirage.backend.network.http import HTTPBackend, HTTPConfig
    34→        return HTTPBackend(HTTPConfig(base_url=connection))
    35→    raise ValueError(f"Unknown backend type: {type_name!r}. "
    36→                     "Use built-ins: memory, local, s3, r2, http")
    37→
    38→
    39→def parse_mount_uri(uri: str) -> BaseBackend:
    40→    if uri == BackendType.MEMORY:
    41→        return MemoryBackend()
    42→    if uri == BackendType.LOCAL:
    43→        return LocalBackend(root=os.path.abspath("."))
    44→
    45→    colon = uri.find(":")
    46→    if colon > 0 and "//" not in uri[:colon + 3]:
    47→        return _build_backend(uri[:colon], uri[colon + 1:])
    48→
    49→    if uri.startswith("s3://"):
    50→        return _build_backend("s3", uri)
    51→    if uri.startswith("r2://"):
    52→        return _build_backend("r2", uri)
    53→    if uri.startswith("http://") or uri.startswith("https://"):
    54→        return _build_backend("http", uri)
    55→
    56→    if "://" in uri:
    57→        scheme = uri.split("://")[0]
    58→        raise ValueError(
    59→            f"Unknown URI scheme {scheme!r}. "
    60→            "Accepted: s3://, r2://, https://. "
    61→            "For simple backends use: memory, local, local:./path")
    62→    return LocalBackend(root=os.path.abspath(uri))
    63→
    64→
    65→def split_mount_spec(spec: str) -> tuple[str, str]:
    66→    """Split 'backend:/path' into (backend, path).
    67→
    68→    Args:
    69→        spec (str): Mount spec like 'memory:/data/' or 's3://bucket:/data/'.
    70→
    71→    Returns:
    72→        tuple[str, str]: (backend, mount_path) pair.
    73→    """
    74→    i = len(spec) - 1
    75→    while i >= 0:
    76→        if spec[i] == ":" and i + 1 < len(spec) and spec[i + 1] == "/":
    77→            if i >= 2 and spec[i - 1:i + 2] == "://":
    78→                i -= 1
    79→                continue
    80→            return spec[:i], spec[i + 1:]
    81→        i -= 1
    82→    raise ValueError(
    83→        f"Invalid mount spec: {spec!r}. "
    84→        "Use format: backend:/path (e.g. memory:/data/, s3://bucket:/)")
    85→
    86→
    87→_MODE_SUFFIXES = ("@read", "@write", "@exec")
    88→
    89→
    90→def parse_mount_uri_with_mode(
    91→    uri: str,
    92→    default_mode: MountMode = MountMode.READ,
    93→) -> tuple[BaseBackend, MountMode]:
    94→    for suffix in _MODE_SUFFIXES:
    95→        if uri.endswith(suffix):
    96→            return parse_mount_uri(uri[:-len(suffix)]), MountMode(suffix[1:])
    97→    return parse_mount_uri(uri), default_mode
    98→
    99→
   100→_CONSISTENCY_SUFFIXES = ("#cached", "#lazy", "#ttl", "#always")
   101→
   102→
   103→def parse_mount_uri_full(
   104→    uri: str,
   105→    default_mode: MountMode = MountMode.READ,
   106→    default_consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED,
   107→) -> tuple[BaseBackend, MountMode, ConsistencyPolicy]:
   108→    consistency = default_consistency
   109→    for suffix in _CONSISTENCY_SUFFIXES:
   110→        if uri.endswith(suffix):
   111→            consistency = ConsistencyPolicy(suffix[1:])
   112→            uri = uri[:-len(suffix)]
   113→            break
   114→    backend, mode = parse_mount_uri_with_mode(uri, default_mode)
   115→    return backend, mode, consistency
   116→
   117→
   118→def parse_mounts_from_cli(
   119→    mount_specs: list[str],
   120→    default_mode: MountMode = MountMode.READ,
   121→) -> dict[str, BaseBackend | tuple[BaseBackend, MountMode]]:
   122→    mounts: dict[str, BaseBackend | tuple[BaseBackend, MountMode]] = {}
   123→    for spec in mount_specs:
   124→        uri, prefix = split_mount_spec(spec)
   125→        backend, mode = parse_mount_uri_with_mode(uri, default_mode)
   126→        if mode != default_mode:
   127→            mounts[prefix] = (backend, mode)
   128→        else:
   129→            mounts[prefix] = backend
   130→    return mounts
   131→
   132→
   133→def load_yaml_config(path: str) -> dict:
   134→    with open(path) as f:
   135→        return yaml.safe_load(f)
   136→
   137→
   138→def load_workspace_yaml(path: str) -> dict:
   139→    with open(path) as f:
   140→        data = yaml.safe_load(f)
   141→    mounts_raw = data.get("mounts", {})
   142→    mounts: dict[str, str | dict] = {}
   143→    for prefix, value in mounts_raw.items():
   144→        if isinstance(value, str):
   145→            mounts[prefix] = value
   146→        elif isinstance(value, dict):
   147→            backend_spec = value.get("backend")
   148→            if backend_spec:
   149→                entry: dict[str, str | dict] = {"backend": backend_spec}
   150→                if value.get("args"):
   151→                    entry["args"] = value["args"]
   152→                if value.get("mode"):
   153→                    entry["mode"] = value["mode"]
   154→                if value.get("consistency"):
   155→                    entry["consistency"] = value["consistency"]
   156→                mounts[prefix] = entry
   157→            else:
   158→                uri = value.get("uri", "")
   159→                mount_mode = value.get("mode")
   160→                mount_consistency = value.get("consistency")
   161→                entry_uri: dict[str, str] = {"uri": uri}
   162→                if mount_mode:
   163→                    entry_uri["mode"] = mount_mode
   164→                if mount_consistency:
   165→                    entry_uri["consistency"] = mount_consistency
   166→                if len(entry_uri) == 1:
   167→                    mounts[prefix] = uri
   168→                else:
   169→                    mounts[prefix] = entry_uri
   170→    result: dict = {"mounts": mounts, "commands": data.get("commands", {})}
   171→    if "consistency" in data:
   172→        result["consistency"] = data["consistency"]
   173→    if "ttl" in data:
   174→        result["ttl"] = data["ttl"]
   175→    if "agent_id" in data:
   176→        result["agent_id"] = data["agent_id"]
   177→    fuse_ops = data.get("fuse_ops", {})
   178→    if fuse_ops:
   179→        result["fuse_ops"] = fuse_ops
   180→    for key in ("runtime", "url", "ws_id", "command_url", "fuse_ops_url"):
   181→        if key in data:
   182→            result[key] = data[key]
   183→    return result
   184→


     1→import os
     2→import sys
     3→import threading
     4→import time
     5→from pathlib import Path
     6→from typing import TYPE_CHECKING, Annotated
     7→
     8→import typer
     9→
    10→from mirage.backend.loader import load_backend_class
    11→from mirage.cli.client import ClientError
    12→from mirage.cli.config import (load_workspace_yaml, parse_mount_uri,
    13→                               parse_mount_uri_full, split_mount_spec)
    14→from mirage.cli.helpers import _client, _ensure_server
    15→from mirage.fuse.loader import build_registry_from_config
    16→from mirage.fuse.registry import FuseOpRegistry
    17→from mirage.observe.inspector import (workspace_status_from_ws,
    18→                                      workspace_transfers_from_ws)
    19→from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
    20→from mirage.workspace import Workspace
    21→
    22→if TYPE_CHECKING:
    23→    from mirage.fuse.fs import MirageFS
    24→
    25→workspace_app = typer.Typer(no_args_is_help=True)
    26→
    27→REPORT_INTERVAL = 2
    28→
    29→
    30→def _report_loop(ws: Workspace, mountpoint: str, fs: "MirageFS") -> None:
    31→    from mirage.cli.client import MirageClient
    32→    client = MirageClient()
    33→    time.sleep(2)
    34→    while True:
    35→        try:
    36→            status = workspace_status_from_ws(ws)
    37→            transfers = workspace_transfers_from_ws(ws)
    38→            ops = fs.drain_ops()
    39→            client.report_fuse_stats(mountpoint, status, transfers, ops)
    40→        except Exception:
    41→            pass
    42→        time.sleep(REPORT_INTERVAL)
    43→
    44→
    45→def _parse_mounts_for_fuse(
    46→    specs: list[str],
    47→    cfg: dict,
    48→    default_mode: MountMode,
    49→    default_consistency: ConsistencyPolicy,
    50→    ttl: int,
    51→    agent_id: str | None,
    52→) -> tuple[dict, MountMode, ConsistencyPolicy, int, str | None]:
    53→    resolved_agent_id = agent_id
    54→    mounts: dict = {}
    55→    if cfg:
    56→        if "consistency" in cfg:
    57→            default_consistency = ConsistencyPolicy(cfg["consistency"])
    58→        if "ttl" in cfg:
    59→            ttl = cfg["ttl"]
    60→        if not resolved_agent_id and "agent_id" in cfg:
    61→            resolved_agent_id = cfg["agent_id"]
    62→        for prefix, value in cfg.get("mounts", {}).items():
    63→            if isinstance(value, str):
    64→                backend_obj, m, c = parse_mount_uri_full(
    65→                    value, default_mode, default_consistency)
    66→            elif isinstance(value, dict) and "backend" in value:
    67→                cls = load_backend_class(value["backend"])
    68→                backend_obj = cls(**(value.get("args") or {}))
    69→                m = (MountMode(value["mode"])
    70→                     if "mode" in value else default_mode)
    71→                c = (ConsistencyPolicy(value["consistency"])
    72→                     if "consistency" in value else default_consistency)
    73→            elif isinstance(value, dict):
    74→                backend_obj = parse_mount_uri(value.get("uri", ""))
    75→                m = (MountMode(value["mode"])
    76→                     if "mode" in value else default_mode)
    77→                c = (ConsistencyPolicy(value["consistency"])
    78→                     if "consistency" in value else default_consistency)
    79→            else:
    80→                continue
    81→            has_override = (m != default_mode or c != default_consistency)
    82→            if has_override:
    83→                mounts[prefix] = (backend_obj, m, c)
    84→            else:
    85→                mounts[prefix] = backend_obj
    86→    for spec in specs:
    87→        uri, prefix = split_mount_spec(spec)
    88→        backend_obj, m, c = parse_mount_uri_full(uri, default_mode,
    89→                                                 default_consistency)
    90→        has_override = (m != default_mode or c != default_consistency)
    91→        if has_override:
    92→            mounts[prefix] = (backend_obj, m, c)
    93→        else:
    94→            mounts[prefix] = backend_obj
    95→    return mounts, default_mode, default_consistency, ttl, resolved_agent_id
    96→
    97→
    98→def mount(
    99→    mount_specs: Annotated[
   100→        list[str] | None,
   101→        typer.Argument(help="Mount specs: backend:/path "
   102→                       "(e.g. memory:/data/, s3://bucket:/). "
   103→                       "Append #cached|#lazy|#ttl|#always for "
   104→                       "consistency, @read|@write|@exec for mode.")] = None,
   105→    sync_policy: str = typer.Option("staged", help="staged or immediate"),
   106→    mode: MountMode = typer.Option(MountMode.WRITE, help="read or write"),
   107→    consistency: ConsistencyPolicy = typer.Option(ConsistencyPolicy.CACHED),
   108→    ttl: int = typer.Option(300, help="TTL in seconds for ttl consistency"),
   109→    config: str | None = typer.Option(None,
   110→                                      help="Path to YAML workspace config"),
   111→    runtime: RuntimeType = typer.Option(RuntimeType.MEMORY,
   112→                                        help="Runtime: memory or local"),
   113→    fuse: bool = typer.Option(False, help="Also mount as FUSE filesystem"),
   114→    mountpoint: str | None = typer.Option(
   115→        None, help="Local directory for FUSE mount (requires --fuse)"),
   116→    agent_id: str | None = typer.Option(
   117→        None, help="Agent identity exposed via /.mirage/whoami"),
   118→    backend: str | None = typer.Option(
   119→        None,
   120→        help="Backend URI mounted at /. Prefix specs override for subtree."),
   121→) -> None:
   122→    """Create a workspace and mount backends.
   123→
   124→    Each backend URI is routed by prefix. The longest matching prefix wins,
   125→    so /data/logs/ takes priority over /data/ for paths under /data/logs/.
   126→
   127→    With --fuse, also mounts the workspace as a local FUSE filesystem
   128→    (requires macFUSE / libfuse). The process blocks while FUSE is mounted.
   129→
   130→    URI formats:
   131→      memory             in-memory (no disk, no deps)
   132→      local              local filesystem at current directory
   133→      local:./path       local filesystem at ./path (converted to abs)
   134→      s3://bucket/pfx    AWS S3  (requires mirage[s3])
   135→      r2://account/bkt   Cloudflare R2  (requires mirage[s3])
   136→      https://host       HTTP read-only (requires mirage[http])
   137→    """
   138→    specs = list(mount_specs or [])
   139→    if backend:
   140→        specs.insert(0, f"{backend}:/")
   141→
   142→    if fuse:
   143→        _mount_fuse(specs, config, mode, consistency, ttl, agent_id,
   144→                    mountpoint, runtime)
   145→        return
   146→
   147→    _ensure_server()
   148→    mounts: dict[str, str | dict] = {}
   149→    if config:
   150→        cfg = load_workspace_yaml(config)
   151→        mounts.update(cfg.get("mounts", {}))
   152→        consistency_str = cfg.get("consistency")
   153→        if consistency_str:
   154→            consistency = ConsistencyPolicy(consistency_str)
   155→        ttl = cfg.get("ttl", ttl)
   156→    consistency_suffixes = ("#cached", "#lazy", "#ttl", "#always")
   157→    for spec in specs:
   158→        try:
   159→            uri, prefix = split_mount_spec(spec)
   160→        except ValueError as e:
   161→            raise typer.BadParameter(str(e))
   162→        mount_consistency = None
   163→        for suffix in consistency_suffixes:
   164→            if uri.endswith(suffix):
   165→                mount_consistency = suffix[1:]
   166→                uri = uri[:-len(suffix)]
   167→                break
   168→        mount_mode = None
   169→        for suffix in ("@read", "@write", "@exec"):
   170→            if uri.endswith(suffix):
   171→                mount_mode = suffix[1:]
   172→                uri = uri[:-len(suffix)]
   173→                break
   174→        entry: dict[str, str] = {"uri": uri}
   175→        if mount_mode:
   176→            entry["mode"] = mount_mode
   177→        if mount_consistency:
   178→            entry["consistency"] = mount_consistency
   179→        if len(entry) == 1:
   180→            mounts[prefix] = uri
   181→        else:
   182→            mounts[prefix] = entry
   183→    ws_id = _client().create_workspace(
   184→        mounts,
   185→        sync_policy=sync_policy,
   186→        mode=mode.value,
   187→        consistency=consistency.value,
   188→        ttl_seconds=ttl,
   189→        runtime=runtime.value,
   190→    )
   191→    print(ws_id)
   192→
   193→
   194→def _mount_fuse(
   195→    specs: list[str],
   196→    config: str | None,
   197→    mode: MountMode,
   198→    consistency: ConsistencyPolicy,
   199→    ttl: int,
   200→    agent_id: str | None,
   201→    mountpoint: str | None,
   202→    runtime: RuntimeType,
   203→) -> None:
   204→    from mirage.backend.network.proxy import ProxyBackend
   205→    from mirage.fuse.fs import MirageFS
   206→    from mirage.fuse.fs import mount as fuse_mount_fn
   207→    from mirage.fuse.remote.remote import RemoteFuseOp
   208→
   209→    if not mountpoint:
   210→        raise typer.BadParameter("--mountpoint is required when using --fuse")
   211→    if not specs and not config:
   212→        raise typer.BadParameter(
   213→            "No backends specified. Use --backend URI or provide mount specs.")
   214→    for spec in specs:
   215→        if ":" not in spec:
   216→            raise typer.BadParameter(
   217→                f"Invalid mount spec: {spec!r}. Use backend:/path")
   218→
   219→    Path(mountpoint).mkdir(parents=True, exist_ok=True)
   220→    _ensure_server()
   221→    cfg = load_workspace_yaml(config) if config else {}
   222→
   223→    if runtime == RuntimeType.REMOTE:
   224→        url = cfg.get("url", "")
   225→        ws_id_remote = cfg.get("ws_id", "")
   226→        proxy = ProxyBackend(url, ws_id_remote)
   227→        ws = Workspace({"/": proxy}, mode=MountMode.WRITE)
   228→        fuse_registry = FuseOpRegistry()
   229→        fuse_registry.set_default(RemoteFuseOp(proxy))
   230→        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)
   231→    else:
   232→        mounts, mode, consistency, ttl, agent_id = _parse_mounts_for_fuse(
   233→            specs, cfg, mode, consistency, ttl, agent_id)
   234→        ws = Workspace(mounts,
   235→                       mode=mode,
   236→                       consistency=consistency,
   237→                       ttl_seconds=ttl)
   238→        fuse_ops_config = cfg.get("fuse_ops", {}) if cfg else {}
   239→        fuse_registry = build_registry_from_config(fuse_ops_config)
   240→        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)
   241→
   242→    pid = os.getpid()
   243→    _client().register_fuse(mountpoint, pid, specs, runtime=runtime.value)
   244→
   245→    thread = threading.Thread(target=_report_loop,
   246→                              args=(ws, mountpoint, fs),
   247→                              daemon=True)
   248→    thread.start()
   249→
   250→    fuse_mount_fn(mountpoint=mountpoint, foreground=True, fs=fs)
   251→
   252→
   253→def exec_cmd(
   254→        ws_id: str = typer.Argument(),
   255→        command: list[str] = typer.Argument(),
   256→) -> None:
   257→    """Execute a command in a running workspace."""
   258→    cmd_str = " ".join(command)
   259→    try:
   260→        output = _client().exec(ws_id, cmd_str)
   261→        if output:
   262→            print(output)
   263→    except ClientError as e:
   264→        print(f"Error: {e}", file=sys.stderr)
   265→        raise typer.Exit(1)
   266→
   267→
   268→def kill(ws_id: str = typer.Argument()) -> None:
   269→    """Stop a workspace daemon."""
   270→    _client().delete_workspace(ws_id)
   271→    print(f"Killed workspace {ws_id}")
   272→
   273→
   274→def sync(ws_id: str = typer.Argument()) -> None:
   275→    """Flush dirty inodes to backends for a workspace."""
   276→    try:
   277→        results = _client().sync(ws_id)
   278→        if results:
   279→            for r in results:
   280→                print(f"{r['path']}: {r['status']}")
   281→        else:
   282→            print("All clean.")
   283→    except ClientError as e:
   284→        print(f"Error: {e}", file=sys.stderr)
   285→        raise typer.Exit(1)
   286→
   287→
   288→def status(ws_id: str = typer.Argument()) -> None:
   289→    """Show dirty/clean inode status for a workspace."""
   290→    try:
   291→        inodes = _client().status(ws_id)
   292→        if inodes:
   293→            for i in inodes:
   294→                marker = "D" if i["dirty"] else " "
   295→                print(f"{marker} {i['path']}")
   296→        else:
   297→            print("No tracked files.")
   298→    except ClientError as e:
   299→        print(f"Error: {e}", file=sys.stderr)
   300→        raise typer.Exit(1)
   301→
   302→
   303→@workspace_app.command(name="ls")
   304→def workspace_ls() -> None:
   305→    """List active workspaces."""
   306→    _ensure_server()
   307→    workspaces = _client().list_workspaces()
   308→    if not workspaces:
   309→        print("No active workspaces.")
   310→        return
   311→    for w in workspaces:
   312→        parts = [w["id"], w.get("type", "workspace")]
   313→        if w.get("mountpoint"):
   314→            parts.append(w["mountpoint"])
   315→        print("\t".join(parts))
   316→


     1→import typer
     2→
     3→from mirage.cli.config import split_mount_spec
     4→from mirage.cli.helpers import _client
     5→from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
     6→
     7→command_app = typer.Typer(no_args_is_help=True)
     8→command_server_app = typer.Typer(no_args_is_help=True)
     9→
    10→
    11→@command_app.command(name="add")
    12→def command_add(
    13→        ws_id: str = typer.Argument(),
    14→        name: str = typer.Argument(),
    15→        exec_str: str = typer.Argument(),
    16→        description: str = typer.Option(""),
    17→) -> None:
    18→    """Register a user CLI command in a workspace."""
    19→    _client().register_command(ws_id, name, exec_str, description)
    20→    print(f"registered: {name}")
    21→
    22→
    23→@command_server_app.command(name="serve")
    24→def command_server_serve(
    25→    mount_specs: list[str] | None = typer.Argument(default=None),
    26→    host: str = typer.Option("0.0.0.0"),
    27→    port: int = typer.Option(8765),
    28→    transport: str = typer.Option("http", help="http or grpc"),
    29→    auth: str = typer.Option("none", help="none, bearer, basic, or apikey"),
    30→    token: str | None = typer.Option(None,
    31→                                     help="Bearer token (--auth bearer)"),
    32→    api_key: str | None = typer.Option(None, help="API key (--auth apikey)"),
    33→    api_key_header: str = typer.Option("X-API-Key"),
    34→    username: str | None = typer.Option(None, help="Username (--auth basic)"),
    35→    password: str | None = typer.Option(None, help="Password (--auth basic)"),
    36→    mode: MountMode = typer.Option(MountMode.READ),
    37→    config: str | None = typer.Option(None),
    38→    runtime: RuntimeType = typer.Option(RuntimeType.MEMORY,
    39→                                        help="Runtime: memory or local"),
    40→) -> None:
    41→    """Start a standalone command server backed by the given mounts."""
    42→    from mirage.backend.loader import load_backend_class
    43→    from mirage.cli.config import (load_workspace_yaml, parse_mount_uri,
    44→                                   parse_mount_uri_full)
    45→    from mirage.commands.remote.auth import (APIKeyAuth, BasicAuth,
    46→                                             BearerTokenAuth, NoAuth)
    47→    from mirage.workspace import Workspace
    48→
    49→    default_mode = mode
    50→    mounts: dict = {}
    51→    if config:
    52→        cfg = load_workspace_yaml(config)
    53→        if "mounts" in cfg:
    54→            for prefix, value in cfg["mounts"].items():
    55→                if isinstance(value, str):
    56→                    backend_obj, m, _ = parse_mount_uri_full(
    57→                        value, default_mode, ConsistencyPolicy.CACHED)
    58→                elif isinstance(value, dict) and "backend" in value:
    59→                    cls = load_backend_class(value["backend"])
    60→                    backend_obj = cls(**(value.get("args") or {}))
    61→                    m = MountMode(
    62→                        value["mode"]) if "mode" in value else default_mode
    63→                elif isinstance(value, dict):
    64→                    backend_obj = parse_mount_uri(value.get("uri", ""))
    65→                    m = MountMode(
    66→                        value["mode"]) if "mode" in value else default_mode
    67→                else:
    68→                    continue
    69→                mounts[prefix] = (backend_obj, m, ConsistencyPolicy.CACHED)
    70→
    71→    for spec in (mount_specs or []):
    72→        try:
    73→            uri, prefix = split_mount_spec(spec)
    74→        except ValueError as e:
    75→            raise typer.BadParameter(str(e))
    76→        backend_obj, m, _ = parse_mount_uri_full(uri, default_mode,
    77→                                                 ConsistencyPolicy.CACHED)
    78→        mounts[prefix] = (backend_obj, m, ConsistencyPolicy.CACHED)
    79→
    80→    if not mounts:
    81→        raise typer.BadParameter("No mounts specified.")
    82→
    83→    ws = Workspace(mounts, mode=default_mode)
    84→
    85→    if auth == "bearer":
    86→        if not token:
    87→            raise typer.BadParameter("--token required for bearer auth")
    88→        server_auth = BearerTokenAuth(token)
    89→    elif auth == "basic":
    90→        if not username or not password:
    91→            raise typer.BadParameter(
    92→                "--username and --password required for basic auth")
    93→        server_auth = BasicAuth(username, password)
    94→    elif auth == "apikey":
    95→        if not api_key:
    96→            raise typer.BadParameter("--api-key required for apikey auth")
    97→        server_auth = APIKeyAuth(api_key, api_key_header)
    98→    else:
    99→        server_auth = NoAuth()
   100→


mirage/workspace/workspace.py:7:from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
mirage/workspace/workspace.py:55:        consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED,
mirage/workspace/workspace.py:86:              consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED) -> None:
mirage/workspace/mount.py:2:from mirage.types import ConsistencyPolicy, MountMode
mirage/workspace/mount.py:12:            consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED) -> None:
mirage/workspace/mount.py:37:            consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED) -> None:
mirage/workspace/cache/policy.py:4:from mirage.types import ConsistencyPolicy, FileStat
mirage/workspace/cache/policy.py:10:    consistency: ConsistencyPolicy,
mirage/workspace/cache/policy.py:15:    if consistency == ConsistencyPolicy.CACHED:
mirage/workspace/cache/policy.py:17:    if consistency == ConsistencyPolicy.ALWAYS:
mirage/workspace/cache/policy.py:19:    if consistency == ConsistencyPolicy.TTL:
mirage/cli/commands/workspace.py:19:from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
mirage/cli/commands/workspace.py:49:    default_consistency: ConsistencyPolicy,
mirage/cli/commands/workspace.py:52:) -> tuple[dict, MountMode, ConsistencyPolicy, int, str | None]:
mirage/cli/commands/workspace.py:57:            default_consistency = ConsistencyPolicy(cfg["consistency"])
mirage/cli/commands/workspace.py:71:                c = (ConsistencyPolicy(value["consistency"])
mirage/cli/commands/workspace.py:77:                c = (ConsistencyPolicy(value["consistency"])
mirage/cli/commands/workspace.py:107:    consistency: ConsistencyPolicy = typer.Option(ConsistencyPolicy.CACHED),
mirage/cli/commands/workspace.py:154:            consistency = ConsistencyPolicy(consistency_str)
mirage/cli/commands/workspace.py:198:    consistency: ConsistencyPolicy,
mirage/cli/config.py:8:from mirage.types import BackendType, ConsistencyPolicy, MountMode
mirage/cli/config.py:106:    default_consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED,
mirage/cli/config.py:107:) -> tuple[BaseBackend, MountMode, ConsistencyPolicy]:
mirage/cli/config.py:111:            consistency = ConsistencyPolicy(suffix[1:])
mirage/cli/commands/command.py:5:from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
mirage/cli/commands/command.py:57:                        value, default_mode, ConsistencyPolicy.CACHED)
mirage/cli/commands/command.py:69:                mounts[prefix] = (backend_obj, m, ConsistencyPolicy.CACHED)
mirage/cli/commands/command.py:77:                                                 ConsistencyPolicy.CACHED)
mirage/cli/commands/command.py:78:        mounts[prefix] = (backend_obj, m, ConsistencyPolicy.CACHED)
mirage/types.py:49:class ConsistencyPolicy(str, Enum):
mirage/__init__.py:2:from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
mirage/__init__.py:10:    "ConsistencyPolicy",
mirage/server/fastapi/config.py:3:from mirage.types import ConsistencyPolicy, MountMode, SyncPolicy
mirage/server/fastapi/config.py:11:    consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED
mirage/server/fastapi/config.py:18:    consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED
mirage/server/service/local.py:25:from mirage.types import (CommandStats, ConsistencyPolicy, FileStat, MountMode,
mirage/server/service/local.py:81:        default_consistency = ConsistencyPolicy(consistency)
mirage/server/service/local.py:97:                mount_consistency = (ConsistencyPolicy(value["consistency"])


     1→import functools
     2→
     3→from fastapi import APIRouter, HTTPException, Request
     4→
     5→from mirage.server import routes
     6→from mirage.server.fastapi.config import (CreateWorkspaceRequest,
     7→                                          FuseReportRequest,
     8→                                          RegisterFuseRequest)
     9→
    10→workspace_router = APIRouter(tags=["workspace"])
    11→
    12→
    13→def _get_service(request: Request):
    14→    return request.app.state.service
    15→
    16→
    17→def _handle_service_errors(fn):
    18→
    19→    @functools.wraps(fn)
    20→    def wrapper(*args, **kwargs):
    21→        try:
    22→            return fn(*args, **kwargs)
    23→        except KeyError:
    24→            raise HTTPException(status_code=404, detail="workspace not found")
    25→        except ValueError as e:
    26→            raise HTTPException(status_code=400, detail=str(e))
    27→
    28→    return wrapper
    29→
    30→
    31→@workspace_router.post(routes.WORKSPACES)
    32→def create_workspace(req: CreateWorkspaceRequest, request: Request) -> dict:
    33→    svc = _get_service(request)
    34→    ws_id = svc.create_workspace(req.model_dump())
    35→    return {"id": ws_id}
    36→
    37→
    38→@workspace_router.post(routes.WORKSPACE_FUSE)
    39→def register_fuse(req: RegisterFuseRequest, request: Request) -> dict:
    40→    svc = _get_service(request)
    41→    ws_id = svc.register_fuse(
    42→        mountpoint=req.mountpoint,
    43→        pid=req.pid,
    44→        mount_specs=req.mount_specs,
    45→        runtime=req.runtime,
    46→    )
    47→    return {"id": ws_id}
    48→
    49→
    50→@workspace_router.post(routes.WORKSPACE_FUSE_REPORT)
    51→def report_fuse_stats(req: FuseReportRequest, request: Request) -> dict:
    52→    svc = _get_service(request)
    53→    found = svc.report_fuse_stats(
    54→        mountpoint=req.mountpoint,
    55→        status=req.status,
    56→        transfers=req.transfers,
    57→        ops=req.ops,
    58→    )
    59→    if not found:
    60→        raise HTTPException(status_code=404, detail="fuse workspace not found")
    61→    return {"ok": True}
    62→
    63→
    64→@workspace_router.get(routes.WORKSPACES)
    65→def list_workspaces(request: Request) -> list[dict]:
    66→    svc = _get_service(request)
    67→    return svc.list_workspaces()
    68→
    69→
    70→@workspace_router.delete(routes.WORKSPACE, status_code=204)
    71→@_handle_service_errors
    72→def delete_workspace(ws_id: str, request: Request) -> None:
    73→    svc = _get_service(request)
    74→    svc.delete_workspace(ws_id)
    75→
    76→
    77→@workspace_router.post(routes.WORKSPACE_SYNC)
    78→def sync_workspace(ws_id: str, request: Request) -> dict:
    79→    svc = _get_service(request)
    80→    try:
    81→        results = svc.sync(ws_id)
    82→        return {"ok": True, "results": results}
    83→    except KeyError:
    84→        raise HTTPException(status_code=404, detail="workspace not found")
    85→    except ValueError as e:
    86→        raise HTTPException(status_code=400, detail=str(e))
    87→    except Exception as e:
    88→        return {"ok": False, "error": str(e)}
    89→
    90→
    91→@workspace_router.get(routes.WORKSPACE_STATUS)
    92→@_handle_service_errors
    93→def workspace_status(ws_id: str, request: Request) -> list[dict]:
    94→    svc = _get_service(request)
    95→    return svc.status(ws_id)
    96→


     1→import base64
     2→
     3→from fastapi import APIRouter, HTTPException, Request
     4→
     5→from mirage.server import routes
     6→from mirage.server.fastapi.config import (PathRequest, ReaddirRequest,
     7→                                          ReadRequest, RenameRequest,
     8→                                          TruncateRequest, WriteRequest)
     9→
    10→io_router = APIRouter(tags=["io"])
    11→
    12→
    13→def _get_service(request: Request):
    14→    return request.app.state.service
    15→
    16→
    17→@io_router.post(routes.IO_READ)
    18→def io_read(ws_id: str, req: ReadRequest, request: Request) -> dict:
    19→    svc = _get_service(request)
    20→    try:
    21→        data = svc.io_read(ws_id, req.path, req.offset, req.size)
    22→    except KeyError:
    23→        raise HTTPException(status_code=404, detail="workspace not found")
    24→    except FileNotFoundError:
    25→        raise HTTPException(status_code=404, detail="file not found")
    26→    return {"data": base64.b64encode(data).decode()}
    27→
    28→
    29→@io_router.post(routes.IO_WRITE)
    30→def io_write(ws_id: str, req: WriteRequest, request: Request) -> dict:
    31→    svc = _get_service(request)
    32→    try:
    33→        raw = base64.b64decode(req.data)
    34→        written = svc.io_write(ws_id, req.path, req.offset, raw)
    35→    except KeyError:
    36→        raise HTTPException(status_code=404, detail="workspace not found")
    37→    except FileNotFoundError:
    38→        raise HTTPException(status_code=404, detail="file not found")
    39→    return {"written": written}
    40→
    41→
    42→@io_router.post(routes.IO_STAT)
    43→def io_stat(ws_id: str, req: PathRequest, request: Request) -> dict:
    44→    svc = _get_service(request)
    45→    try:
    46→        st = svc.io_stat(ws_id, req.path)
    47→    except KeyError:
    48→        raise HTTPException(status_code=404, detail="workspace not found")
    49→    except FileNotFoundError:
    50→        raise HTTPException(status_code=404, detail="file not found")
    51→    return st.model_dump()
    52→
    53→
    54→@io_router.post(routes.IO_READDIR)
    55→def io_readdir(ws_id: str, req: ReaddirRequest, request: Request) -> dict:
    56→    svc = _get_service(request)
    57→    try:
    58→        entries, cursor = svc.io_readdir(ws_id, req.path, req.cursor)
    59→    except KeyError:
    60→        raise HTTPException(status_code=404, detail="workspace not found")
    61→    return {"entries": entries, "cursor": cursor}
    62→
    63→
    64→@io_router.post(routes.IO_CREATE)
    65→def io_create(ws_id: str, req: PathRequest, request: Request) -> dict:
    66→    svc = _get_service(request)
    67→    try:
    68→        svc.io_create(ws_id, req.path)
    69→    except KeyError:
    70→        raise HTTPException(status_code=404, detail="workspace not found")
    71→    return {"ok": True}
    72→
    73→
    74→@io_router.post(routes.IO_UNLINK)
    75→def io_unlink(ws_id: str, req: PathRequest, request: Request) -> dict:
    76→    svc = _get_service(request)
    77→    try:
    78→        svc.io_unlink(ws_id, req.path)
    79→    except KeyError:
    80→        raise HTTPException(status_code=404, detail="workspace not found")
    81→    except FileNotFoundError:
    82→        raise HTTPException(status_code=404, detail="file not found")
    83→    return {"ok": True}
    84→
    85→
    86→@io_router.post(routes.IO_MKDIR)
    87→def io_mkdir(ws_id: str, req: PathRequest, request: Request) -> dict:
    88→    svc = _get_service(request)
    89→    try:
    90→        svc.io_mkdir(ws_id, req.path)
    91→    except KeyError:
    92→        raise HTTPException(status_code=404, detail="workspace not found")
    93→    return {"ok": True}
    94→
    95→
    96→@io_router.post(routes.IO_RMDIR)
    97→def io_rmdir(ws_id: str, req: PathRequest, request: Request) -> dict:
    98→    svc = _get_service(request)
    99→    try:
   100→        svc.io_rmdir(ws_id, req.path)
   101→    except KeyError:
   102→        raise HTTPException(status_code=404, detail="workspace not found")
   103→    except FileNotFoundError:
   104→        raise HTTPException(status_code=404, detail="file not found")
   105→    return {"ok": True}
   106→
   107→
   108→@io_router.post(routes.IO_RENAME)
   109→def io_rename(ws_id: str, req: RenameRequest, request: Request) -> dict:
   110→    svc = _get_service(request)
   111→    try:
   112→        svc.io_rename(ws_id, req.src, req.dst)
   113→    except KeyError:
   114→        raise HTTPException(status_code=404, detail="workspace not found")
   115→    except FileNotFoundError:
   116→        raise HTTPException(status_code=404, detail="file not found")
   117→    return {"ok": True}
   118→
   119→
   120→@io_router.post(routes.IO_TRUNCATE)
   121→def io_truncate(ws_id: str, req: TruncateRequest, request: Request) -> dict:
   122→    svc = _get_service(request)
   123→    try:
   124→        svc.io_truncate(ws_id, req.path, req.size)
   125→    except KeyError:
   126→        raise HTTPException(status_code=404, detail="workspace not found")
   127→    return {"ok": True}
   128→
   129→
   130→@io_router.post(routes.IO_FUSE_CAT)
   131→def io_fuse_cat(ws_id: str, req: PathRequest, request: Request) -> dict:
   132→    svc = _get_service(request)
   133→    try:
   134→        data = svc.io_fuse_cat(ws_id, req.path)
   135→    except KeyError:
   136→        raise HTTPException(status_code=404, detail="workspace not found")
   137→    except FileNotFoundError:
   138→        raise HTTPException(status_code=404, detail="file not found")
   139→    return {"data": base64.b64encode(data).decode()}
   140→


Bash

find /Users/agent/strukto/mirage/mirage/runtime -type f -name "*.py" | head -20


No files found


     1→from mirage.backend.local import LocalBackend, MemoryBackend
     2→from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
     3→                          SyncPolicy, SyncResult)
     4→from mirage.workspace import Workspace
     5→
     6→__all__ = [
     7→    "Workspace",
     8→    "MemoryBackend",
     9→    "LocalBackend",
    10→    "ConsistencyPolicy",
    11→    "FileStat",
    12→    "Inode",
    13→    "MountMode",
    14→    "SyncPolicy",
    15→    "SyncResult",
    16→]
    17→


     1→from pydantic import BaseModel
     2→
     3→
     4→class CacheEntry(BaseModel):
     5→    data: bytes
     6→    cached_at: float
     7→    etag: str | None = None
     8→    modified: str | None = None
     9→    size: int | None = None
    10→


sorry why the remote one does not share the same consistency policy? should we mv consistency to a separated module?


Good question. Let me check what the "remote" path actually looks like — how the FUSE/IO router and remote command server handle reads.


import base64

from fastapi import APIRouter, HTTPException, Request

from mirage.server import routes
from mirage.server.fastapi.config import (PathRequest, ReaddirRequest,
                                          ReadRequest, RenameRequest,
                                          TruncateRequest, WriteRequest)

io_router = APIRouter(tags=["io"])

def _get_service(request: Request):
    return request.app.state.service

@io_router.post(routes.IO_READ)
def io_read(ws_id: str, req: ReadRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        data = svc.io_read(ws_id, req.path, req.offset, req.size)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="file not found")
    return {"data": base64.b64encode(data).decode()}

@io_router.post(routes.IO_WRITE)
def io_write(ws_id: str, req: WriteRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        raw = base64.b64decode(req.data)
        written = svc.io_write(ws_id, req.path, req.offset, raw)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="file not found")
    return {"written": written}

@io_router.post(routes.IO_STAT)
def io_stat(ws_id: str, req: PathRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        st = svc.io_stat(ws_id, req.path)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="file not found")
    return st.model_dump()

@io_router.post(routes.IO_READDIR)
def io_readdir(ws_id: str, req: ReaddirRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        entries, cursor = svc.io_readdir(ws_id, req.path, req.cursor)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    return {"entries": entries, "cursor": cursor}

@io_router.post(routes.IO_CREATE)
def io_create(ws_id: str, req: PathRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        svc.io_create(ws_id, req.path)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    return {"ok": True}

@io_router.post(routes.IO_UNLINK)
def io_unlink(ws_id: str, req: PathRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        svc.io_unlink(ws_id, req.path)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="file not found")
    return {"ok": True}

@io_router.post(routes.IO_MKDIR)
def io_mkdir(ws_id: str, req: PathRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        svc.io_mkdir(ws_id, req.path)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    return {"ok": True}

@io_router.post(routes.IO_RMDIR)
def io_rmdir(ws_id: str, req: PathRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        svc.io_rmdir(ws_id, req.path)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="file not found")
    return {"ok": True}

@io_router.post(routes.IO_RENAME)
def io_rename(ws_id: str, req: RenameRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        svc.io_rename(ws_id, req.src, req.dst)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="file not found")
    return {"ok": True}

@io_router.post(routes.IO_TRUNCATE)
def io_truncate(ws_id: str, req: TruncateRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        svc.io_truncate(ws_id, req.path, req.size)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    return {"ok": True}

@io_router.post(routes.IO_FUSE_CAT)
def io_fuse_cat(ws_id: str, req: PathRequest, request: Request) -> dict:
    svc = _get_service(request)
    try:
        data = svc.io_fuse_cat(ws_id, req.path)
    except KeyError:
        raise HTTPException(status_code=404, detail="workspace not found")
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="file not found")
    return {"data": base64.b64encode(data).decode()}


     1→from fastapi import FastAPI, HTTPException, Request
     2→from pydantic import BaseModel
     3→
     4→from mirage.commands.custom.registry import CommandRegistry, ExternalCommand
     5→from mirage.commands.remote.auth import AuthError, NoAuth, ServerAuth
     6→from mirage.workspace import Workspace
     7→
     8→command_server_app = FastAPI()
     9→
    10→
    11→def configure(ws: Workspace,
    12→              auth: ServerAuth = NoAuth(),
    13→              registry: CommandRegistry | None = None) -> None:
    14→    command_server_app.state.workspace = ws
    15→    command_server_app.state.auth = auth
    16→    command_server_app.state.registry = registry or CommandRegistry()
    17→
    18→
    19→def _verify_request(request: Request) -> None:
    20→    headers = {k.lower(): v for k, v in request.headers.items()}
    21→    try:
    22→        request.app.state.auth.verify(headers)
    23→    except AuthError as e:
    24→        raise HTTPException(status_code=401, detail=str(e))
    25→
    26→
    27→@command_server_app.get("/commands")
    28→def list_commands(request: Request) -> list[str]:
    29→    _verify_request(request)
    30→    return request.app.state.registry.list_commands()
    31→
    32→
    33→class RunRequest(BaseModel):
    34→    command: str
    35→    cwd: str = "/"
    36→
    37→
    38→@command_server_app.post("/run")
    39→def run_command(request: Request, body: RunRequest) -> dict:
    40→    _verify_request(request)
    41→    ws = request.app.state.workspace
    42→    if ws is None:
    43→        raise HTTPException(status_code=503, detail="server not configured")
    44→    try:
    45→        output = request.app.state.registry.run(ws, body.command, body.cwd)
    46→        return {"ok": True, "output": output}
    47→    except Exception as e:
    48→        return {"ok": False, "error": str(e)}
    49→
    50→
    51→class RegisterRequest(BaseModel):
    52→    name: str
    53→    exec: str
    54→    description: str = ""
    55→
    56→
    57→@command_server_app.post("/register")
    58→def register_command(request: Request, body: RegisterRequest) -> dict:
    59→    _verify_request(request)
    60→    request.app.state.registry.register(
    61→        ExternalCommand(name=body.name,
    62→                        exec=body.exec,
    63→                        description=body.description))
    64→    return {"ok": True}
    65→

from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel

from mirage.commands.custom.registry import CommandRegistry, ExternalCommand
from mirage.commands.remote.auth import AuthError, NoAuth, ServerAuth
from mirage.workspace import Workspace

command_server_app = FastAPI()

def configure(ws: Workspace,
              auth: ServerAuth = NoAuth(),
              registry: CommandRegistry | None = None) -> None:
    command_server_app.state.workspace = ws
    command_server_app.state.auth = auth
    command_server_app.state.registry = registry or CommandRegistry()

def _verify_request(request: Request) -> None:
    headers = {k.lower(): v for k, v in request.headers.items()}
    try:
        request.app.state.auth.verify(headers)
    except AuthError as e:
        raise HTTPException(status_code=401, detail=str(e))

@command_server_app.get("/commands")
def list_commands(request: Request) -> list[str]:
    _verify_request(request)
    return request.app.state.registry.list_commands()

class RunRequest(BaseModel):
    command: str
    cwd: str = "/"

@command_server_app.post("/run")
def run_command(request: Request, body: RunRequest) -> dict:
    _verify_request(request)
    ws = request.app.state.workspace
    if ws is None:
        raise HTTPException(status_code=503, detail="server not configured")
    try:
        output = request.app.state.registry.run(ws, body.command, body.cwd)
        return {"ok": True, "output": output}
    except Exception as e:
        return {"ok": False, "error": str(e)}

class RegisterRequest(BaseModel):
    name: str
    exec: str
    description: str = ""

@command_server_app.post("/register")
def register_command(request: Request, body: RegisterRequest) -> dict:
    _verify_request(request)
    request.app.state.registry.register(
        ExternalCommand(name=body.name,
                        exec=body.exec,
                        description=body.description))
    return {"ok": True}


Grep

def cat|def read|def head|def tail|def grep|def ls


No matches found


import os
import signal
import subprocess
import sys
import time
import uuid
from datetime import datetime, timezone

from pydantic import BaseModel, ConfigDict, Field

from mirage.backend.loader import load_backend_class
from mirage.cli.config import parse_mount_uri, split_mount_spec
from mirage.commands import COMMANDS, _resolve
from mirage.commands.builtin import nav
from mirage.commands.custom.registry import CommandRegistry, ExternalCommand
from mirage.fuse import FUSE_OPS
from mirage.fuse.loader import build_registry_from_config, load_fuse_op_class
from mirage.fuse.registry import FuseOpRegistry
from mirage.observe.entries import (CommandEntry, CommandResult, DispatchInfo,
                                    ParsedCommand, SessionEntry, WorkspaceInfo)
from mirage.observe.session_log import get_or_create_log
from mirage.runtime.base import _BLOCKED, CommandNotAllowedError
from mirage.server.service.base import MirageService
from mirage.server.service.cache import FileCache
from mirage.types import (CommandStats, ConsistencyPolicy, FileStat, MountMode,
                          RuntimeType, SyncPolicy)
from mirage.workspace import Workspace

class WorkspaceSession(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)

    id: str
    ws: Workspace | None
    registry: CommandRegistry | None
    cwd: str = "/"
    fuse_mounted: bool = False
    mode: MountMode = MountMode.WRITE
    mountpoint: str | None = None
    pid: int | None = None
    mounts: list[dict] = Field(default_factory=list)
    runtime_type: RuntimeType | None = None
    fuse_registry: FuseOpRegistry = Field(default_factory=FuseOpRegistry)
    command_stats: CommandStats = Field(default_factory=CommandStats)
    created_at: float = Field(default_factory=time.monotonic)
    reported_status: dict | None = None
    reported_transfers: dict | None = None

def _unmount_fuse(mountpoint: str, pid: int | None) -> None:
    if sys.platform == "darwin":
        subprocess.run(["diskutil", "unmount", "force", mountpoint],
                       capture_output=True)
    else:
        subprocess.run(["fusermount", "-u", mountpoint], capture_output=True)
    if pid:
        try:
            os.kill(pid, signal.SIGTERM)
        except ProcessLookupError:
            pass

class LocalService(MirageService):

    def __init__(self) -> None:
        self._sessions: dict[str, WorkspaceSession] = {}
        self._file_cache = FileCache()

    def _get_session(self, ws_id: str) -> WorkspaceSession:
        session = self._sessions.get(ws_id)
        if session is None:
            raise KeyError(f"workspace {ws_id!r} not found")
        if session.fuse_mounted:
            raise ValueError("not supported for fuse workspaces")
        return session

    def create_workspace(self, config: dict) -> str:
        mode = config.get("mode", "write")
        consistency = config.get("consistency", "cached")
        default_mode = MountMode(mode)
        default_consistency = ConsistencyPolicy(consistency)
        mounts = config.get("mounts", {})

        backends: dict = {}
        for prefix, value in mounts.items():
            if isinstance(value, str):
                backends[prefix] = parse_mount_uri(value)
            else:
                backend_spec = value.get("backend")
                if backend_spec:
                    cls = load_backend_class(backend_spec)
                    backend = cls(**(value.get("args") or {}))
                else:
                    backend = parse_mount_uri(value.get("uri") or "")
                mount_mode = (MountMode(value["mode"])
                              if value.get("mode") else default_mode)
                mount_consistency = (ConsistencyPolicy(value["consistency"])
                                     if value.get("consistency") else
                                     default_consistency)
                if (mount_mode != default_mode
                        or mount_consistency != default_consistency):
                    backends[prefix] = (backend, mount_mode, mount_consistency)
                else:
                    backends[prefix] = backend

        ws = Workspace(
            backends,
            sync_policy=SyncPolicy(config.get("sync_policy", "staged")),
            mode=default_mode,
            consistency=default_consistency,
            ttl_seconds=config.get("ttl_seconds", 300),
        )

        mounts_info: list[dict] = []
        for prefix, value in mounts.items():
            if isinstance(value, str):
                uri = value
            else:
                uri = value.get("uri") or ""
                if not uri and value.get("backend"):
                    uri = f"{value['backend']}://"
            mounts_info.append({"prefix": prefix, "uri": uri})

        ws_id = str(uuid.uuid4())[:8]
        registry = CommandRegistry()
        fuse_ops_config = config.get("fuse_ops") or {}
        fuse_registry = build_registry_from_config(fuse_ops_config)
        runtime = config.get("runtime")
        rt_type = RuntimeType(runtime) if runtime else None

        self._sessions[ws_id] = WorkspaceSession(
            id=ws_id,
            ws=ws,
            registry=registry,
            mode=mode,
            mounts=mounts_info,
            runtime_type=rt_type,
            fuse_registry=fuse_registry,
        )

        log = get_or_create_log(ws_id)
        mount_info = {}
        for m in ws._registry.mounts():
            mount_info[m.prefix] = {
                "type": type(m.backend).__name__,
                "mode": m.mode.value,
            }
        session_entry = SessionEntry(
            timestamp=datetime.now(timezone.utc).isoformat(),
            workspace=WorkspaceInfo(
                mounts=mount_info,
                cache_limit=str(ws._cache.limit),
                runtime_type=rt_type.value if rt_type else None,
            ),
            commands=registry.list_commands(),
        )
        log.write_session(session_entry)
        return ws_id

    def delete_workspace(self, ws_id: str) -> None:
        session = self._sessions.get(ws_id)
        if session is None:
            raise KeyError(f"workspace {ws_id!r} not found")
        if session.fuse_mounted and session.mountpoint:
            _unmount_fuse(session.mountpoint, session.pid)
        self._file_cache.clear(ws_id)
        del self._sessions[ws_id]

    def list_workspaces(self) -> list[dict]:
        return [{
            "id": s.id,
            "fuse_mounted": s.fuse_mounted,
            "mode": s.mode,
            "mountpoint": s.mountpoint,
            "mounts": s.mounts,
            "runtime": s.runtime_type.value if s.runtime_type else None,
        } for s in self._sessions.values()]

    def exec(
        self,
        ws_id: str,
        command: str,
        cwd: str | None = None,
        stdin: bytes | None = None,
        input_files: dict[str, tuple[str, bytes | None]] | None = None,
    ) -> tuple[bool, str, dict[str, bytes] | None, dict[str, str] | None]:
        session = self._get_session(ws_id)
        if cwd is not None:
            session.cwd = cwd

        cache_status: dict[str, str] = {}
        if input_files:
            for path, (checksum, data) in input_files.items():
                hit, _ = self._file_cache.get(ws_id, path, checksum)
                if hit:
                    cache_status[path] = "hit"
                elif data is not None:
                    self._file_cache.put(ws_id, path, checksum, data)
                    session.ws.write(path, data)
                    cache_status[path] = "miss"
                else:
                    return (False, f"cache miss: {path}", None, None)

        cmd_name = command.strip().split()[0] if command.strip() else ""
        session.command_stats.total_commands += 1
        session.command_stats.command_counts[cmd_name] = (
            session.command_stats.command_counts.get(cmd_name, 0) + 1)
        session.command_stats.last_command = command
        session.command_stats.last_timestamp = datetime.now(
            timezone.utc).isoformat()
        t0 = time.monotonic()
        try:
            if session.runtime_type is not None and cmd_name in _BLOCKED:
                raise CommandNotAllowedError(
                    f"{cmd_name!r} is not allowed in this runtime")
            output = session.registry.run(session.ws,
                                          command,
                                          cwd=session.cwd,
                                          stdin=stdin)
            duration_ms = int((time.monotonic() - t0) * 1000)
            if command.strip().startswith("cd "):
                parts = command.strip().split(None, 1)
                if len(parts) > 1:
                    session.cwd = nav.cd(session.ws, session.cwd,
                                         _resolve(session.cwd, parts[1]))
            args = command.strip().split()[1:] if command.strip() else []
            source = "builtin" if cmd_name in COMMANDS else "external"
            log = get_or_create_log(ws_id)
            entry = CommandEntry(
                timestamp=datetime.now(timezone.utc).isoformat(),
                raw=command,
                parsed=ParsedCommand(
                    pipeline=[command],
                    args={cmd_name: args},
                ),
                dispatch=[
                    DispatchInfo(
                        command=cmd_name,
                        handler=f"_{cmd_name}"
                        if source == "builtin" else cmd_name,
                        source=source,
                    )
                ],
                result=CommandResult(
                    exit_code=0,
                    stdout_bytes=len(output.encode()) if output else 0,
                    duration_ms=duration_ms,
                ),
            )
            log.write_command(entry)

            return (True, output, None, cache_status or None)
        except Exception as e:
            duration_ms = int((time.monotonic() - t0) * 1000)
            session.command_stats.total_errors += 1
            log = get_or_create_log(ws_id)
            entry = CommandEntry(
                timestamp=datetime.now(timezone.utc).isoformat(),
                raw=command,
                result=CommandResult(exit_code=1,
                                     stdout_bytes=0,
                                     duration_ms=duration_ms),
            )
            log.write_command(entry)
            return (False, str(e), None, None)

    def list_commands(self, ws_id: str) -> list[str]:
        session = self._get_session(ws_id)
        return session.registry.list_commands()

    def register_command(self,
                         ws_id: str,
                         name: str,
                         exec_str: str,
                         description: str = "") -> None:
        session = self._get_session(ws_id)
        session.registry.register(
            ExternalCommand(name=name, exec=exec_str, description=description))

    def sync(self, ws_id: str) -> list[dict]:
        session = self._get_session(ws_id)
        results = session.ws.sync()
        return [{"path": r.path, "status": r.status} for r in results]

    def status(self, ws_id: str) -> list[dict]:
        session = self._get_session(ws_id)
        inodes = session.ws.status()
        return [{
            "path": i.virtual_path,
            "dirty": i.dirty,
            "size": i.size
        } for i in inodes]

    # ── File I/O (backend-level) ─────────────────────────────────────────

    def io_read(self, ws_id: str, path: str, offset: int, size: int) -> bytes:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        return backend.read(backend_path, offset, size)

    def io_write(self, ws_id: str, path: str, offset: int, data: bytes) -> int:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        return backend.write(backend_path, offset, data)

    def io_stat(self, ws_id: str, path: str) -> FileStat:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        return backend.stat(backend_path)

    def io_readdir(self,
                   ws_id: str,
                   path: str,
                   cursor: str | None = None) -> tuple[list[str], str | None]:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        return backend.readdir(backend_path, cursor)

    def io_create(self, ws_id: str, path: str) -> None:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        backend.create(backend_path)

    def io_unlink(self, ws_id: str, path: str) -> None:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        backend.unlink(backend_path)

    def io_mkdir(self, ws_id: str, path: str) -> None:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        backend.mkdir(backend_path)

    def io_rmdir(self, ws_id: str, path: str) -> None:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        backend.rmdir(backend_path)

    def io_rename(self, ws_id: str, src: str, dst: str) -> None:
        session = self._get_session(ws_id)
        backend, src_path, _ = session.ws._registry.resolve(src)
        _, dst_path, _ = session.ws._registry.resolve(dst)
        backend.rename(src_path, dst_path)

    def io_truncate(self, ws_id: str, path: str, size: int) -> None:
        session = self._get_session(ws_id)
        backend, backend_path, _ = session.ws._registry.resolve(path)
        backend.truncate(backend_path, size)

    def io_fuse_cat(self, ws_id: str, path: str) -> bytes:
        session = self._get_session(ws_id)
        st = session.ws.stat(path)
        op = session.fuse_registry.resolve(path, st.size or 0, session.ws)
        ctx = op.on_open(session.ws, path, st.size or 0)
        return op.read(session.ws, path, 0, st.size or 0, ctx)

    # ── FuseOp management ────────────────────────────────────────────────

    def list_fuse_ops(self, ws_id: str) -> list[dict]:
        session = self._get_session(ws_id)
        return session.fuse_registry.list_ops()

    def register_fuse_op(self, ws_id: str, pattern: str, handler: str) -> None:
        session = self._get_session(ws_id)
        op = FUSE_OPS.get(handler)
        if op is None:
            try:
                cls = load_fuse_op_class(handler)
                op = cls()
            except (ValueError, Exception) as e:
                raise ValueError(f"unknown handler: {handler} ({e})")
        session.fuse_registry.register(pattern, op)

    # ── FUSE-specific (not part of MirageService ABC) ────────────────────

    def register_fuse(self,
                      mountpoint: str,
                      pid: int,
                      mount_specs: list[str] | None = None,
                      runtime: str | None = None) -> str:
        fuse_mounts: list[dict] = []
        for spec in (mount_specs or []):
            try:
                uri, prefix = split_mount_spec(spec)
                fuse_mounts.append({"prefix": prefix, "uri": uri})
            except ValueError:
                pass
        ws_id = str(uuid.uuid4())[:8]
        rt_type = RuntimeType(runtime) if runtime else None
        self._sessions[ws_id] = WorkspaceSession(
            id=ws_id,
            ws=None,
            registry=None,
            fuse_mounted=True,
            mountpoint=mountpoint,
            pid=pid,
            mounts=fuse_mounts,
            runtime_type=rt_type,
        )
        mount_info = {m["prefix"]: {"type": m["uri"]} for m in fuse_mounts}
        log = get_or_create_log(ws_id)
        session_entry = SessionEntry(
            timestamp=datetime.now(timezone.utc).isoformat(),
            workspace=WorkspaceInfo(
                mounts=mount_info,
                runtime_type=rt_type.value if rt_type else None,
            ),
        )
        log.write_session(session_entry)
        return ws_id

    def report_fuse_stats(self,
                          mountpoint: str,
                          status: dict,
                          transfers: dict | None = None,
                          ops: list[dict] | None = None) -> bool:
        for s in self._sessions.values():
            if s.fuse_mounted and s.mountpoint == mountpoint:
                s.reported_status = status
                s.reported_transfers = transfers or {}
                if ops:
                    log = get_or_create_log(s.id)
                    for op in ops:
                        log._append(op)
                return True
        return False


mirage/server/service/base.py:52:    def io_read(self, ws_id: str, path: str, offset: int, size: int) -> bytes:
mirage/server/service/base.py:64:    def io_readdir(self,
mirage/server/service/base.py:95:    def io_fuse_cat(self, ws_id: str, path: str) -> bytes:
mirage/server/service/local.py:296:    def io_read(self, ws_id: str, path: str, offset: int, size: int) -> bytes:
mirage/server/service/local.py:311:    def io_readdir(self,
mirage/server/service/local.py:350:    def io_fuse_cat(self, ws_id: str, path: str) -> bytes:


Now I see the issue clearly. The `io_*` methods in `LocalService` (lines 296-348) **bypass the Workspace entirely** and go directly to the backend:

```python
def io_read(self, ws_id: str, path: str, offset: int, size: int) -> bytes:
    session = self._get_session(ws_id)
    backend, backend_path, _ = session.ws._registry.resolve(path)
    return backend.read(backend_path, offset, size)
```

They use `session.ws._registry.resolve(path)` to get the backend, then call backend methods directly.

No caching, no consistency checks. Meanwhile, `exec()` runs commands through the workspace's registry which invokes methods like `ws.cat()` that do go through consistency. So the remote IO path used by FUSE completely skips the workspace's consistency and cache system, which confirms the user's observation.

The user also wants to move consistency to a separate module — right now `ConsistencyPolicy` lives in `types.py`, gets used in `mount.py`, and the `is_fresh()` logic is scattered in `workspace/cache/policy.py`. I need to think through how to consolidate this properly.

Looking at the architecture: the IO router endpoints bypass the workspace entirely and hit backends directly, while command execution goes through the workspace and gets consistency checks. This design choice makes sense for FUSE needing raw backend access, but it creates an inconsistency across the codebase. The real question is whether consistency should be enforced uniformly across all paths or if this separation is intentional.


You've spotted a real inconsistency. Look at the two read paths in [local.py](mirage/server/service/local.py):

**Command path** (goes through Workspace → gets consistency):
```python
# exec() → registry.run(session.ws, ...) → ws.cat() → is_fresh() ✓
```

**IO path** (bypasses Workspace → no consistency):
```python
def io_read(self, ws_id: str, path: str, offset: int, size: int) -> bytes:
    session = self._get_session(ws_id)
    backend, backend_path, _ = session.ws._registry.resolve(path)
    return backend.read(backend_path, offset, size)  # Direct backend call, no cache/consistency
```

All `io_*` methods (lines 296-348) resolve the path to a backend and call it directly — no cache, no `is_fresh()`, no consistency policy. Same for `io_stat`, `io_readdir`, etc.

And even within Workspace itself, only `cat()` uses consistency. `head()`, `tail()`, `read()`, `grep()`, `ls_l()` all go straight to the backend too.

So right now consistency is effectively only a `cat()`-level feature that the IO/FUSE path completely ignores.

Two questions for you:

1. **Should the IO path go through Workspace methods** instead of calling `backend.*` directly? That would give FUSE/remote reads the same caching + consistency behavior.

2. **Should we move consistency to its own module?** Right now it's scattered: enum in `types.py`, freshness logic in `workspace/cache/policy.py`, per-mount storage in `mount.py`. We could create something like `mirage/consistency/` or keep it where it is but make Workspace's other read ops use it too.

What's your preference on the scope here?


<ide_opened_file>The user opened the file /Users/agent/strukto/mirage/pyproject.toml in the IDE. This may or may not be related to the current task.</ide_opened_file>

give me a simple diagram before and after changes


The user wants a simple before/after diagram showing how consistency is used (or not used) across the different paths. They previously asked about whether the remote IO path should share the same consistency policy, and whether consistency should be moved to a separate module.

Let me think about what the current state is and what the proposed changes would be. The user hasn't explicitly said they want changes made - they asked for a diagram first. Let me create a simple ASCII/text diagram showing:

**Before:** Current state where:
- `exec()` → Workspace.cat() → is_fresh() → ConsistencyPolicy ✓
- `io_read()` → backend.read() directly (bypasses Workspace, no consistency)

- `head()`, `tail()`, `read()`, `grep()` → backend directly (no consistency)

**After:** Proposed state where:
- All read paths go through Workspace methods that apply ConsistencyPolicy

The user asked for a diagram showing before and after, and from the conversation they're concerned about two things: the IO path not going through Workspace like other reads do, and whether consistency logic should be extracted into its own module. So I'll create a diagram that illustrates both the current inconsistency and what it would look like if all paths were unified.

Creating ASCII diagrams showing the current split architecture versus a cleaner unified approach...


## Before

```
Read request
    │
    ├── exec() → Workspace.cat()
    │               │
    │               ├── LRUCache.get()
    │               ├── mount.consistency  ← ConsistencyPolicy applied
    │               └── is_fresh() → backend.cat() (if stale)
    │
    └── io_read() ──────────────────────────→ backend.read()
        io_stat()  ──────────────────────────→ backend.stat()     (no cache,
        io_readdir() ────────────────────────→ backend.readdir()   no consistency)
```

ConsistencyPolicy is spread across:
- `types.py` — enum definition
- `workspace/cache/policy.py` — `is_fresh()` logic
- `workspace/mount.py` — stored on `Mount`

---

## After

```
Read request
    │
    ├── exec() → Workspace.cat()
    │               │
    └── io_read() → Workspace.read()    ← goes through Workspace
        io_stat() → Workspace.stat()
        io_readdir() → Workspace.ls()
                        │
                        ├── LRUCache.get()
                        ├── mount.consistency  ← ConsistencyPolicy applied uniformly
                        └── is_fresh() → backend.* (if stale)
```

ConsistencyPolicy moved to `workspace/consistency.py`:
- enum definition (moved from `types.py`)
- `is_fresh()` logic (moved from `workspace/cache/policy.py`)

`mount.py` still stores `Mount.consistency` but imports from `workspace/consistency.py`

---

Do you want both changes (unified IO path + move consistency module), or just one of them?


Ok


The user said "Ok" which means they want to proceed with both changes:
1. Unified IO path - `io_read()`, `io_stat()`, `io_readdir()` should go through Workspace methods
2. Move ConsistencyPolicy to `workspace/consistency.py`

Let me use the brainstorming skill first to think through the approach, then write a plan.

Wait, actually the user just said "Ok" confirming both changes. Let me use the writing-plans skill since this is a multi-step implementation.

Actually, let me re-read the system reminder - I need to check if any skills apply before responding. The superpowers:writing-plans skill should be used "when you have a spec or requirements for a multi-step task, before touching code".

This is a multi-step refactoring task, so I should invoke the writing-plans skill to map out the implementation steps.


Launching skill: superpowers:writing-plans