     1→from __future__ import annotations
     2→
     3→import time
     4→
     5→from mirage.backends.base import BaseBackend
     6→from mirage.backends.filetype import guess_type
     7→from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
     8→                          SyncPolicy, SyncResult, TransferStats)
     9→from mirage.workspace.cache import LRUCache
    10→from mirage.workspace.cache.policy import is_fresh
    11→from mirage.workspace.mount import MountRegistry
    12→from mirage.workspace.sync import DirtyTracker
    13→
    14→
    15→class _PathLock:
    16→
    17→    def __init__(self, ws: Workspace, paths: list[str]) -> None:
    18→        self._ws = ws
    19→        self._paths = paths
    20→
    21→    def __enter__(self) -> _PathLock:
    22→        return self
    23→
    24→    def __exit__(self, *_: object) -> None:
    25→        self._ws.unlock_paths(self._paths)
    26→
    27→
    28→class Workspace:
    29→    """
    30→    Unified virtual filesystem over heterogeneous backends.
    31→
    32→    Mounts backends at virtual path prefixes and provides a command-level API
    33→    (cat, ls, write, cp, mv, rm, ...) with transparent caching
    34→    and write staging.
    35→
    36→    Example::
    37→
    38→        ws = Workspace(
    39→            {"/data/": S3Backend(S3Config(bucket="my-bucket")),
    40→             "/tmp/":  MemoryBackend()},
    41→            cache_limit="256MB",
    42→            sync_policy=SyncPolicy.STAGED,
    43→        )
    44→        content = ws.cat("/data/reports/q1.json")
    45→        ws.write("/tmp/summary.txt", b"hello")
    46→        ws.sync()
    47→    """
    48→
    49→    def __init__(
    50→        self,
    51→        backends: dict[str, BaseBackend | tuple],
    52→        cache_limit: str | int = "512MB",
    53→        sync_policy: SyncPolicy = SyncPolicy.STAGED,
    54→        mode: MountMode = MountMode.READ,
    55→        consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,
    56→        ttl_seconds: int = 300,
    57→    ) -> None:
    58→        self._registry = MountRegistry()
    59→        self._cache = LRUCache(cache_limit)
    60→        self._tracker = DirtyTracker()
    61→        self._sync_policy = sync_policy
    62→        self._consistency = consistency
    63→        self._ttl_seconds = ttl_seconds
    64→        self._locked_paths: set[str] = set()
    65→        self._transfer_stats: dict[str, TransferStats] = {}
    66→        self._created_at: float = time.monotonic()
    67→
    68→        for prefix, value in backends.items():
    69→            if isinstance(value, tuple) and len(value) == 3:
    70→                backend, mount_mode, mount_consistency = value
    71→            elif isinstance(value, tuple):
    72→                backend, mount_mode = value
    73→                mount_consistency = None
    74→            else:
    75→                backend = value
    76→                mount_mode = mode
    77→                mount_consistency = None
    78→            self._registry.mount(prefix, backend, mount_mode,
    79→                                 mount_consistency)
    80→
    81→    # ── mount management ────────────────────────────────────────────────────
    82→
    83→    def mount(self,
    84→              prefix: str,
    85→              backend: BaseBackend,
    86→              mode: MountMode = MountMode.READ,
    87→              consistency: ConsistencyPolicy | None = None) -> None:
    88→        self._registry.mount(prefix, backend, mode, consistency)
    89→
    90→    def backend(self, path: str) -> BaseBackend:
    91→        backend, _, _ = self._registry.resolve(path)
    92→        return backend
    93→
    94→    # ── read ops ────────────────────────────────────────────────────────────
    95→
    96→    def cat(self, path: str) -> bytes:
    97→        inode = self._tracker.get(path)
    98→        if inode and inode.dirty:
    99→            entry = self._cache.get(path)
   100→            if entry is not None:
   101→                self._record_transfer(path,
   102→                                      "read",
   103→                                      len(entry.data),
   104→                                      0,
   105→                                      cache_hit=True)
   106→                return entry.data
   107→
   108→        entry = self._cache.get(path)
   109→        remote_stat = None
   110→        if entry is not None:
   111→            consistency = self._effective_consistency(path)
   112→            backend, bp, _ = self._registry.resolve(path)
   113→            fresh, remote_stat = is_fresh(entry, consistency,
   114→                                          self._ttl_seconds, backend, bp)
   115→            if fresh:
   116→                self._record_transfer(path,
   117→                                      "read",
   118→                                      len(entry.data),
   119→                                      0,
   120→                                      cache_hit=True)
   121→                return entry.data
   122→        else:
   123→            backend, bp, _ = self._registry.resolve(path)
   124→
   125→        t0 = time.monotonic()
   126→        data = backend.cat(bp)
   127→        duration_ms = int((time.monotonic() - t0) * 1000)
   128→        self._record_transfer(path,
   129→                              "read",
   130→                              len(data),
   131→                              duration_ms,
   132→                              cache_hit=False)
   133→        if remote_stat is None:
   134→            remote_stat = backend.stat(bp)
   135→        self._cache.put(path,
   136→                        data,
   137→                        etag=remote_stat.extra.get("etag"),
   138→                        modified=remote_stat.modified,
   139→                        size=remote_stat.size)
   140→        self._tracker.on_read(path, data, self._mount_prefix(path))
   141→        return data
   142→
   143→    def ls(self, path: str) -> list[str]:
   144→        backend, bp, _ = self._registry.resolve(path)
   145→        prefix = self._mount_prefix(path).rstrip("/")
   146→        backend_ok = False
   147→        try:
   148→            backend_entries = backend.ls(bp)
   149→            backend_ok = True
   150→        except FileNotFoundError:
   151→            backend_entries = []
   152→        result: set[str] = {
   153→            prefix + "/" + e.lstrip("/")
   154→            for e in backend_entries
   155→        }
   156→        norm_path = "/" + path.strip("/")
   157→        dir_prefix = norm_path.rstrip("/") + "/"
   158→        has_staged_children = False
   159→        for inode in self._tracker.dirty():
   160→            vp = inode.virtual_path
   161→            if not vp.startswith(dir_prefix):
   162→                continue
   163→            if inode.checksum is None:
   164→                result.discard(vp)
   165→                continue
   166→            has_staged_children = True
   167→            remainder = vp[len(dir_prefix):]
   168→            first = remainder.split("/")[0]
   169→            if not first:
   170→                continue
   171→            if "/" in remainder:
   172→                result.add(dir_prefix + first + "/")
   173→            else:
   174→                result.add(dir_prefix + first)
   175→        if not backend_ok and not has_staged_children:
   176→            raise FileNotFoundError(path)
   177→        return sorted(result)
   178→
   179→    def ls_l(self, path: str) -> list[FileStat]:
   180→        backend, bp, _ = self._registry.resolve(path)
   181→        return backend.ls_l(bp)
   182→
   183→    def stat(self, path: str) -> FileStat:
   184→        # For staged writes, build FileStat from the tracker rather than
   185→        # hitting the backend (which hasn't received the write yet).
   186→        inode = self._tracker.get(path)
   187→        if inode and inode.dirty and inode.checksum is not None:
   188→            entry = self._cache.get(path)
   189→            size = len(entry.data) if entry is not None else inode.size
   190→            return FileStat(
   191→                name=path.rstrip("/").rsplit("/", 1)[-1],
   192→                size=size,
   193→                type=guess_type(path),
   194→            )
   195→        backend, bp, _ = self._registry.resolve(path)
   196→        return backend.stat(bp)
   197→
   198→    def exists(self, path: str) -> bool:
   199→        try:
   200→            self.stat(path)
   201→            return True
   202→        except (FileNotFoundError, ValueError):
   203→            return False
   204→
   205→    def head(self, path: str, lines: int = 10) -> bytes:
   206→        backend, bp, _ = self._registry.resolve(path)
   207→        return backend.head(bp, lines)
   208→
   209→    def tail(self, path: str, lines: int = 10) -> bytes:
   210→        backend, bp, _ = self._registry.resolve(path)
   211→        return backend.tail(bp, lines)
   212→
   213→    def read(self, path: str, offset: int, size: int) -> bytes:
   214→        inode = self._tracker.get(path)
   215→        if inode and inode.dirty:
   216→            entry = self._cache.get(path)
   217→            if entry is not None:
   218→                return entry.data[offset:offset + size]
   219→        backend, bp, _ = self._registry.resolve(path)
   220→        return backend.read(bp, offset, size)
   221→
   222→    def grep(self, pattern: str, path: str) -> list[str]:
   223→        backend, bp, _ = self._registry.resolve(path)
   224→        return backend.grep(pattern, bp)
   225→
   226→    # ── write ops ───────────────────────────────────────────────────────────
   227→
   228→    def write(self, path: str, data: bytes) -> None:
   229→        self._assert_not_locked(path)
   230→        self._assert_writable(path)
   231→        backend, bp, _ = self._registry.resolve(path)
   232→        self._cache.put(path, data)
   233→        self._tracker.on_write(path, data, self._mount_prefix(path))
   234→        self._record_transfer(path, "write", len(data), 0)
   235→        if self._sync_policy == SyncPolicy.IMMEDIATE:
   236→            backend.put(bp, data)
   237→            self._tracker.on_read(path, data, self._mount_prefix(path))
   238→
   239→    def mkdir(self, path: str) -> None:
   240→        self._assert_writable(path)
   241→        backend, bp, _ = self._registry.resolve(path)
   242→        backend.mkdir(bp)
   243→
   244→    def rm(self, path: str) -> None:
   245→        self._assert_writable(path)
   246→        backend, bp, _ = self._registry.resolve(path)
   247→        inode = self._tracker.get(path)
   248→        staged_only = inode and inode.dirty and inode.remote_checksum is None
   249→        if not staged_only:
   250→            backend.unlink(bp)
   251→        self._cache.invalidate(path)
   252→        self._tracker.on_delete(path)
   253→
   254→    def mv(self, src: str, dst: str) -> None:
   255→        self._assert_writable(src)
   256→        sb, sbp, _ = self._registry.resolve(src)
   257→        db, dbp, _ = self._registry.resolve(dst)
   258→        src_inode = self._tracker.get(src)
   259→        staged_only = (src_inode and src_inode.dirty
   260→                       and src_inode.remote_checksum is None)
   261→        if staged_only:
   262→            entry = self._cache.get(src)
   263→            data = entry.data if entry else b""
   264→            self._cache.put(dst, data)
   265→            self._tracker.on_write(dst, data, self._mount_prefix(dst))
   266→            self._cache.invalidate(src)
   267→            self._tracker.on_delete(src)
   268→        elif sb is db:
   269→            sb.rename(sbp, dbp)
   270→            self._cache.invalidate(src)
   271→        else:
   272→            data = sb.cat(sbp)
   273→            db.put(dbp, data)
   274→            sb.unlink(sbp)
   275→            self._cache.invalidate(src)
   276→
   277→    def cp(self, src: str, dst: str) -> None:
   278→        self._assert_writable(dst)
   279→        data = self.cat(src)
   280→        db, dbp, _ = self._registry.resolve(dst)
   281→        db.put(dbp, data)
   282→
   283→    # ── sync lifecycle ──────────────────────────────────────────────────────
   284→
   285→    def sync(self, path: str | None = None) -> list[SyncResult]:
   286→        results: list[SyncResult] = []
   287→        targets = [
   288→            i for i in self._tracker.dirty()
   289→            if path is None or i.virtual_path == path
   290→        ]
   291→        for inode in targets:
   292→            backend, bp, _ = self._registry.resolve(inode.virtual_path)
   293→            entry = self._cache.get(inode.virtual_path)
   294→            data = entry.data if entry else None
   295→            result = self._tracker.on_sync(inode.virtual_path, bp, data,
   296→                                           backend)
   297→            results.append(result)
   298→        return results
   299→
   300→    def status(self) -> list[Inode]:
   301→        return self._tracker.status()
   302→
   303→    def reset(self) -> None:
   304→        self._tracker.reset()
   305→        self._cache.clear()
   306→
   307→    # ── path locking ─────────────────────────────────────────────────────────
   308→
   309→    def _lock_path(self, path: str) -> None:
   310→        self._locked_paths.add(path)
   311→
   312→    def unlock_paths(self, paths: list[str]) -> None:
   313→        for p in paths:
   314→            self._locked_paths.discard(p)
   315→
   316→    def lock_paths(self, paths: list[str]) -> _PathLock:
   317→        for p in paths:
   318→            self._lock_path(p)
   319→        return _PathLock(self, paths)
   320→
   321→    def _assert_not_locked(self, path: str) -> None:
   322→        if path in self._locked_paths:
   323→            raise PermissionError(
   324→                f"path {path!r} is locked by remote execution")
   325→
   326→    # ── helpers ─────────────────────────────────────────────────────────────
   327→
   328→    def _mount_prefix(self, path: str) -> str:
   329→        norm = "/" + path.strip("/")
   330→        for m in self._registry.mounts():
   331→            if norm == m.prefix.rstrip("/") or norm.startswith(m.prefix):
   332→                return m.prefix
   333→        return "/"
   334→
   335→    def _effective_consistency(self, path: str) -> ConsistencyPolicy:
   336→        mount = self._registry.mount_for(path)
   337→        if mount.consistency is not None:
   338→            return mount.consistency
   339→        return self._consistency
   340→
   341→    def _assert_writable(self, path: str) -> None:
   342→        _, _, mode = self._registry.resolve(path)
   343→        if mode == MountMode.READ:
   344→            raise PermissionError(f"mount at {path!r} is read-only")
   345→
   346→    def _record_transfer(self,
   347→                         path: str,
   348→                         op: str,
   349→                         nbytes: int,
   350→                         duration_ms: int,
   351→                         cache_hit: bool | None = None) -> None:
   352→        prefix = self._mount_prefix(path)
   353→        if prefix not in self._transfer_stats:
   354→            self._transfer_stats[prefix] = TransferStats()
   355→        stats = self._transfer_stats[prefix]
   356→        if op == "read":
   357→            stats.bytes_read += nbytes
   358→            stats.read_ops += 1
   359→            stats.total_read_ms += duration_ms
   360→            if cache_hit is True:
   361→                stats.cache_hits += 1
   362→            elif cache_hit is False:
   363→                stats.cache_misses += 1
   364→        elif op == "write":
   365→            stats.bytes_written += nbytes
   366→            stats.write_ops += 1
   367→            stats.total_write_ms += duration_ms
   368→


     1→import pytest
     2→from fastapi.testclient import TestClient
     3→
     4→from mirage.backends.network.proxy import ProxyBackend
     5→from mirage.observe import session_log as observe_session_module
     6→from mirage.server.fastapi import app as server_app_module
     7→from mirage.server.fastapi.app import app
     8→
     9→
    10→@pytest.fixture
    11→def proxy():
    12→    server_app_module._sessions.clear()
    13→    observe_session_module._session_logs.clear()
    14→    client = TestClient(app)
    15→    resp = client.post("/workspaces", json={"mounts": {"/": "memory"}})
    16→    ws_id = resp.json()["id"]
    17→    backend = ProxyBackend("http://testserver", ws_id, http_client=client)
    18→    yield backend
    19→    server_app_module._sessions.clear()
    20→    observe_session_module._session_logs.clear()
    21→
    22→
    23→def test_create_stat(proxy):
    24→    proxy.create("/f.txt")
    25→    st = proxy.stat("/f.txt")
    26→    assert st.name is not None
    27→    assert st.size == 0
    28→
    29→
    30→def test_write_read(proxy):
    31→    proxy.create("/f.txt")
    32→    written = proxy.write("/f.txt", 0, b"hello world")
    33→    assert written == 11
    34→    data = proxy.read("/f.txt", 0, 11)
    35→    assert data == b"hello world"
    36→
    37→
    38→def test_cat(proxy):
    39→    proxy.create("/f.txt")
    40→    proxy.write("/f.txt", 0, b"hello")
    41→    data = proxy.cat("/f.txt")
    42→    assert data == b"hello"
    43→
    44→
    45→def test_mkdir_readdir(proxy):
    46→    proxy.mkdir("/sub")
    47→    entries = proxy.ls("/")
    48→    names = [e.rsplit("/", 1)[-1] for e in entries]
    49→    assert "sub" in names
    50→
    51→
    52→def test_unlink(proxy):
    53→    proxy.create("/f.txt")
    54→    proxy.unlink("/f.txt")
    55→    with pytest.raises(FileNotFoundError):
    56→        proxy.stat("/f.txt")
    57→
    58→
    59→def test_rename(proxy):
    60→    proxy.create("/a.txt")
    61→    proxy.write("/a.txt", 0, b"data")
    62→    proxy.rename("/a.txt", "/b.txt")
    63→    with pytest.raises(FileNotFoundError):
    64→        proxy.stat("/a.txt")
    65→    st = proxy.stat("/b.txt")
    66→    assert st.size == 4
    67→
    68→
    69→def test_truncate(proxy):
    70→    proxy.create("/f.txt")
    71→    proxy.write("/f.txt", 0, b"hello world")
    72→    proxy.truncate("/f.txt", 5)
    73→    st = proxy.stat("/f.txt")
    74→    assert st.size == 5
    75→
    76→
    77→def test_stat_missing(proxy):
    78→    with pytest.raises(FileNotFoundError):
    79→        proxy.stat("/missing.txt")
    80→
    81→
    82→def test_ls(proxy):
    83→    proxy.create("/a.txt")
    84→    proxy.create("/b.txt")
    85→    entries = proxy.ls("/")
    86→    names = [e.rsplit("/", 1)[-1] for e in entries]
    87→    assert "a.txt" in names
    88→    assert "b.txt" in names
    89→
    90→
    91→def test_fuse_cat(proxy):
    92→    proxy.create("/f.txt")
    93→    proxy.write("/f.txt", 0, b"content")
    94→    data = proxy.fuse_cat("/f.txt")
    95→    assert data == b"content"
    96→
    97→
    98→def test_put(proxy):
    99→    proxy.put("/f.txt", b"hello")
   100→    data = proxy.cat("/f.txt")
   101→    assert data == b"hello"
   102→


     1→import hashlib
     2→import time
     3→
     4→from mirage.backends.base import BaseBackend
     5→from mirage.types import Inode, SyncResult
     6→
     7→
     8→class DirtyTracker:
     9→    """
    10→    Tracks which virtual paths have local changes that haven't been synced
    11→    to their backing backend.
    12→
    13→    Lifecycle:
    14→        on_read()  — record baseline remote checksum (not dirty)
    15→        on_write() — update local checksum, mark dirty=True
    16→        on_sync()  — upload to backend, clear dirty flag
    17→    """
    18→
    19→    def __init__(self) -> None:
    20→        self._inodes: dict[str, Inode] = {}
    21→
    22→    # ── event hooks ─────────────────────────────────────────────────────────
    23→
    24→    def on_read(self, path: str, data: bytes, mount: str) -> None:
    25→        checksum = _md5(data)
    26→        if path in self._inodes:
    27→            inode = self._inodes[path]
    28→            inode.remote_checksum = checksum
    29→            inode.checksum = checksum
    30→            inode.size = len(data)
    31→            inode.dirty = False
    32→        else:
    33→            self._inodes[path] = Inode(
    34→                virtual_path=path,
    35→                backend_mount=mount,
    36→                checksum=checksum,
    37→                size=len(data),
    38→                remote_checksum=checksum,
    39→                dirty=False,
    40→            )
    41→
    42→    def on_write(self, path: str, data: bytes, mount: str) -> None:
    43→        checksum = _md5(data)
    44→        if path in self._inodes:
    45→            inode = self._inodes[path]
    46→            inode.checksum = checksum
    47→            inode.size = len(data)
    48→            inode.dirty = True
    49→        else:
    50→            self._inodes[path] = Inode(
    51→                virtual_path=path,
    52→                backend_mount=mount,
    53→                checksum=checksum,
    54→                size=len(data),
    55→                remote_checksum=None,
    56→                dirty=True,
    57→            )
    58→
    59→    def on_delete(self, path: str) -> None:
    60→        if path in self._inodes:
    61→            self._inodes[path].dirty = True
    62→            self._inodes[path].checksum = None
    63→            self._inodes[path].size = None
    64→
    65→    def on_sync(self, virtual_path: str, backend_path: str, data: bytes | None,
    66→                backend: BaseBackend) -> SyncResult:
    67→        """
    68→        Upload `data` to backend at `backend_path`. Pass None for deletions.
    69→        `virtual_path` tracks the inode; `backend_path` goes to the backend.
    70→        Returns a SyncResult describing the outcome.
    71→        """
    72→        inode = self._inodes.get(virtual_path)
    73→        op = "delete" if data is None else ("create" if (
    74→            inode and inode.remote_checksum is None) else "modify")
    75→        t0 = time.monotonic()
    76→        try:
    77→            if data is None:
    78→                backend.unlink(backend_path)
    79→            else:
    80→                backend.put(backend_path, data)
    81→            duration_ms = int((time.monotonic() - t0) * 1000)
    82→            checksum = _md5(data) if data is not None else None
    83→            if inode:
    84→                inode.remote_checksum = checksum
    85→                inode.dirty = False
    86→            return SyncResult(path=virtual_path,
    87→                              op=op,
    88→                              status="ok",
    89→                              bytes=len(data) if data else None,
    90→                              duration_ms=duration_ms)
    91→        except Exception as exc:
    92→            duration_ms = int((time.monotonic() - t0) * 1000)
    93→            return SyncResult(path=virtual_path,
    94→                              op=op,
    95→                              status="error",
    96→                              duration_ms=duration_ms,
    97→                              error=str(exc))
    98→
    99→    # ── queries ─────────────────────────────────────────────────────────────
   100→
   101→    def dirty(self) -> list[Inode]:
   102→        return [i for i in self._inodes.values() if i.dirty]
   103→
   104→    def status(self) -> list[Inode]:
   105→        return list(self._inodes.values())
   106→
   107→    def get(self, path: str) -> Inode | None:
   108→        return self._inodes.get(path)
   109→
   110→    def reset(self) -> None:
   111→        self._inodes.clear()
   112→
   113→
   114→def _md5(data: bytes) -> str:
   115→    return hashlib.md5(data).hexdigest()
   116→


     1→import pytest
     2→from pydantic import ValidationError
     3→
     4→from mirage.backends.object_storage.r2 import R2Config
     5→from mirage.backends.object_storage.s3 import S3Config
     6→from mirage.commands.custom.registry import ExternalCommand
     7→
     8→
     9→def test_s3config_defaults():
    10→    c = S3Config(bucket="my-bucket")
    11→    assert c.region is None
    12→    assert c.timeout == 30
    13→    assert c.select_threshold_bytes == 1024 * 1024
    14→
    15→
    16→def test_s3config_immutable():
    17→    c = S3Config(bucket="x")
    18→    with pytest.raises(ValidationError):
    19→        c.bucket = "y"
    20→
    21→
    22→def test_r2config_fields():
    23→    c = R2Config(account_id="acc",
    24→                 bucket="bkt",
    25→                 access_key_id="k",
    26→                 secret_access_key="s")
    27→    assert c.region == "auto"
    28→    assert c.timeout == 30
    29→
    30→
    31→def test_r2config_immutable():
    32→    c = R2Config(account_id="a",
    33→                 bucket="b",
    34→                 access_key_id="k",
    35→                 secret_access_key="s")
    36→    with pytest.raises(ValidationError):
    37→        c.bucket = "other"
    38→
    39→
    40→def test_external_command_fields():
    41→    cmd = ExternalCommand(name="linecount", exec="python lc.py {file}")
    42→    assert cmd.description == ""
    43→
    44→
    45→def test_external_command_immutable():
    46→    cmd = ExternalCommand(name="x", exec="echo {file}")
    47→    with pytest.raises(ValidationError):
    48→        cmd.name = "y"
    49→


     1→from mirage.backends.base import BaseBackend
     2→from mirage.types import ConsistencyPolicy, MountMode
     3→
     4→
     5→class Mount:
     6→
     7→    def __init__(self,
     8→                 prefix: str,
     9→                 backend: BaseBackend,
    10→                 mode: MountMode = MountMode.READ,
    11→                 consistency: ConsistencyPolicy | None = None) -> None:
    12→        stripped = prefix.strip("/")
    13→        self.prefix = "/" + stripped + "/" if stripped else "/"
    14→        self.backend = backend
    15→        self.mode = mode
    16→        self.consistency = consistency
    17→
    18→
    19→class MountRegistry:
    20→    """
    21→    Longest-prefix-match router.
    22→
    23→    Given a virtual path like "/s3-prod/data/file.json", resolves to the
    24→    backend mounted at "/s3-prod/" and returns the stripped path
    25→    "/data/file.json".
    26→    """
    27→
    28→    def __init__(self) -> None:
    29→        self._mounts: list[Mount] = []
    30→
    31→    def mount(self,
    32→              prefix: str,
    33→              backend: BaseBackend,
    34→              mode: MountMode = MountMode.READ,
    35→              consistency: ConsistencyPolicy | None = None) -> None:
    36→        m = Mount(prefix, backend, mode, consistency)
    37→        for existing in self._mounts:
    38→            if existing.prefix == m.prefix:
    39→                raise ValueError(f"duplicate mount prefix: {m.prefix!r}")
    40→        self._mounts.append(m)
    41→        self._mounts.sort(key=lambda x: len(x.prefix), reverse=True)
    42→
    43→    def resolve(self, path: str) -> tuple[BaseBackend, str, MountMode]:
    44→        """
    45→        Returns (backend, backend_path, mode).
    46→
    47→        backend_path has the mount prefix stripped.
    48→        Raises ValueError if no mount matches.
    49→        """
    50→        norm = "/" + path.strip("/")
    51→        # ensure trailing slash when checking prefix match for directories
    52→        for m in self._mounts:
    53→            if norm == m.prefix.rstrip("/") or norm.startswith(m.prefix):
    54→                backend_path = "/" + norm[len(m.prefix):]
    55→                return m.backend, backend_path, m.mode
    56→        raise ValueError(f"no mount matches path: {path!r}")
    57→
    58→    def mount_for(self, path: str) -> Mount:
    59→        norm = "/" + path.strip("/")
    60→        for m in self._mounts:
    61→            if norm == m.prefix.rstrip("/") or norm.startswith(m.prefix):
    62→                return m
    63→        raise ValueError(f"no mount matches path: {path!r}")
    64→
    65→    def mounts(self) -> list[Mount]:
    66→        return list(self._mounts)
    67→


     1→from dataclasses import dataclass, field
     2→
     3→from mirage.backends.local.memory import MemoryBackend
     4→from mirage.commands import COMMANDS
     5→from mirage.commands.custom.registry import CommandRegistry
     6→from mirage.observe import inspector
     7→from mirage.types import CommandStats, MountMode, SyncPolicy
     8→from mirage.workspace import Workspace
     9→
    10→
    11→@dataclass
    12→class _FakeSession:
    13→    id: str = "test-ws"
    14→    ws: Workspace | None = None
    15→    registry: CommandRegistry | None = None
    16→    cwd: str = "/"
    17→    fuse_mounted: bool = False
    18→    mode: str = "write"
    19→    mountpoint: str | None = None
    20→    pid: int | None = None
    21→    mounts: list[dict] = field(default_factory=list)
    22→    command_stats: CommandStats = field(default_factory=CommandStats)
    23→
    24→
    25→def _make_session() -> _FakeSession:
    26→    ws = Workspace(
    27→        {"/tmp/": MemoryBackend()},
    28→        mode=MountMode.WRITE,
    29→        sync_policy=SyncPolicy.STAGED,
    30→    )
    31→    return _FakeSession(ws=ws, registry=CommandRegistry())
    32→
    33→
    34→def test_global_status():
    35→    session = _make_session()
    36→    sessions = {"ws1": session}
    37→    result = inspector.global_status(sessions)
    38→    assert "rss_bytes" in result
    39→    assert result["total_sessions"] == 1
    40→    assert result["total_cache_bytes"] == 0
    41→    assert "uptime_seconds" in result
    42→
    43→
    44→def test_list_sessions():
    45→    session = _make_session()
    46→    sessions = {"ws1": session}
    47→    result = inspector.list_sessions(sessions)
    48→    assert len(result) == 1
    49→    assert result[0]["id"] == "test-ws"
    50→    assert result[0]["cache_used"] == 0
    51→    assert result[0]["total_commands"] == 0
    52→
    53→
    54→def test_workspace_status():
    55→    session = _make_session()
    56→    session.command_stats.total_commands = 5
    57→    session.command_stats.total_errors = 1
    58→    session.command_stats.last_command = "cat /tmp/test"
    59→    result = inspector.workspace_status(session)
    60→    assert result["cache_used"] == 0
    61→    assert result["dirty_inodes"] == 0
    62→    assert result["total_commands"] == 5
    63→    assert result["total_errors"] == 1
    64→    assert result["last_command"] == "cat /tmp/test"
    65→    assert len(result["backends"]) == 1
    66→    assert result["backends"][0]["backend"] == "MemoryBackend"
    67→
    68→
    69→def test_workspace_commands():
    70→    session = _make_session()
    71→    result = inspector.workspace_commands(session)
    72→    names = {c["name"] for c in result}
    73→    assert "cat" in names
    74→    assert "ls" in names
    75→    assert "grep" in names
    76→    assert len(result) >= len(COMMANDS)
    77→    for cmd in result:
    78→        assert "source" in cmd
    79→        assert "cost_level" in cmd
    80→
    81→
    82→def test_workspace_memory():
    83→    session = _make_session()
    84→    session.ws.write("/tmp/hello.txt", b"hello world")
    85→    result = inspector.workspace_memory(session)
    86→    assert result["cache"]["used"] > 0
    87→    assert result["cache"]["entry_count"] == 1
    88→    assert "/tmp/hello.txt" in result["cache"]["entries"]
    89→    assert result["dirty_tracker"]["total_inodes"] == 1
    90→    assert "/tmp/hello.txt" in result["dirty_tracker"]["dirty_paths"]
    91→    assert "/tmp/" in result["backend_memory"]
    92→    assert result["backend_memory"]["/tmp/"]["file_count"] == 0
    93→
    94→
    95→def test_workspace_memory_with_synced_file():
    96→    ws = Workspace(
    97→        {"/tmp/": MemoryBackend()},
    98→        mode=MountMode.WRITE,
    99→        sync_policy=SyncPolicy.IMMEDIATE,
   100→    )
   101→    session = _FakeSession(ws=ws, registry=CommandRegistry())
   102→    ws.write("/tmp/hello.txt", b"hello")
   103→    result = inspector.workspace_memory(session)
   104→    assert result["backend_memory"]["/tmp/"]["file_count"] == 1
   105→
   106→
   107→def test_workspace_transfers():
   108→    session = _make_session()
   109→    session.ws.write("/tmp/a.txt", b"data")
   110→    session.ws.cat("/tmp/a.txt")
   111→    session.ws.cat("/tmp/a.txt")
   112→    result = inspector.workspace_transfers(session)
   113→    assert "/tmp/" in result
   114→    stats = result["/tmp/"]
   115→    assert stats["write_ops"] >= 1
   116→    assert stats["read_ops"] >= 2
   117→    assert stats["bytes_read"] > 0
   118→    assert stats["cache_hit_rate"] > 0
   119→


     1→import time
     2→
     3→from mirage.backends.base import BaseBackend
     4→from mirage.types import ConsistencyPolicy, FileStat
     5→from mirage.workspace.cache.entry import CacheEntry
     6→
     7→
     8→def is_fresh(
     9→    entry: CacheEntry,
    10→    consistency: ConsistencyPolicy,
    11→    ttl_seconds: int,
    12→    backend: BaseBackend,
    13→    backend_path: str,
    14→) -> tuple[bool, FileStat | None]:
    15→    if consistency == ConsistencyPolicy.NONE:
    16→        return True, None
    17→    if consistency == ConsistencyPolicy.ALWAYS:
    18→        return False, None
    19→    if consistency == ConsistencyPolicy.TTL:
    20→        if (time.monotonic() - entry.cached_at) < ttl_seconds:
    21→            return True, None
    22→    try:
    23→        remote = backend.stat(backend_path)
    24→    except FileNotFoundError:
    25→        return False, None
    26→    return metadata_matches(entry, remote), remote
    27→
    28→
    29→def metadata_matches(entry: CacheEntry, remote: FileStat) -> bool:
    30→    remote_etag = remote.extra.get("etag")
    31→    if entry.etag and remote_etag:
    32→        return entry.etag == remote_etag
    33→    if entry.modified and remote.modified:
    34→        return entry.modified == remote.modified
    35→    if entry.size is not None and remote.size is not None:
    36→        return entry.size == remote.size
    37→    return False
    38→


     1→from mirage.fuse_ops import FUSE_OPS, FuseOp
     2→from mirage.fuse_ops.registry import FuseOpRegistry
     3→
     4→
     5→class ParquetOp(FuseOp):
     6→    name = "parquet"
     7→
     8→
     9→def test_fuse_ops_dict_has_default():
    10→    assert "default" in FUSE_OPS
    11→
    12→
    13→def test_fuse_ops_dict_has_all_backends():
    14→    assert "s3" in FUSE_OPS
    15→    assert "memory" in FUSE_OPS
    16→    assert "local" in FUSE_OPS
    17→
    18→
    19→def test_registry_resolve_default():
    20→    reg = FuseOpRegistry()
    21→    op = reg.resolve("/data/file.txt", 100)
    22→    assert op.name == "default"
    23→
    24→
    25→def test_registry_resolve_custom():
    26→    reg = FuseOpRegistry()
    27→    reg.register("*.parquet", ParquetOp())
    28→    op = reg.resolve("/data/file.parquet", 100)
    29→    assert op.name == "parquet"
    30→
    31→
    32→def test_registry_first_match_wins():
    33→    reg = FuseOpRegistry()
    34→    reg.register("*.parquet", ParquetOp())
    35→    reg.register("*", FuseOp())
    36→    op = reg.resolve("/data/file.parquet", 100)
    37→    assert op.name == "parquet"
    38→
    39→
    40→def test_registry_list_ops():
    41→    reg = FuseOpRegistry()
    42→    reg.register("*.csv", FuseOp())
    43→    ops = reg.list_ops()
    44→    assert len(ops) >= 2
    45→    patterns = [o["pattern"] for o in ops]
    46→    assert "*.csv" in patterns
    47→    assert "*" in patterns
    48→
    49→
    50→def test_registry_set_default():
    51→    reg = FuseOpRegistry()
    52→    reg.set_default(ParquetOp())
    53→    op = reg.resolve("/anything.txt", 100)
    54→    assert op.name == "parquet"
    55→
    56→
    57→def test_fuse_ops_dict_has_format_ops():
    58→    assert "parquet" in FUSE_OPS
    59→    assert "csv" in FUSE_OPS
    60→
    61→
    62→def test_registry_backend_aware_memory():
    63→    from mirage.backends.local.memory import MemoryBackend
    64→    from mirage.types import MountMode
    65→    from mirage.workspace import Workspace
    66→
    67→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    68→    reg = FuseOpRegistry()
    69→    op = reg.resolve("/file.txt", 100, ws)
    70→    assert op.name == "memory"
    71→
    72→
    73→def test_registry_backend_aware_local():
    74→    import tempfile
    75→
    76→    from mirage.backends.local.local import LocalBackend
    77→    from mirage.types import MountMode
    78→    from mirage.workspace import Workspace
    79→
    80→    with tempfile.TemporaryDirectory() as d:
    81→        ws = Workspace({"/": LocalBackend(root=d)}, mode=MountMode.READ)
    82→        reg = FuseOpRegistry()
    83→        op = reg.resolve("/file.txt", 100, ws)
    84→        assert op.name == "local"
    85→
    86→
    87→def test_registry_pattern_overrides_backend():
    88→    from mirage.backends.local.memory import MemoryBackend
    89→    from mirage.types import MountMode
    90→    from mirage.workspace import Workspace
    91→
    92→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    93→    reg = FuseOpRegistry()
    94→    reg.register("*.txt", FUSE_OPS["s3"])
    95→    op = reg.resolve("/file.txt", 100, ws)
    96→    assert op.name == "s3"
    97→
    98→
    99→def test_workspace_read_partial():
   100→    from mirage.backends.local.memory import MemoryBackend
   101→    from mirage.types import MountMode
   102→    from mirage.workspace import Workspace
   103→
   104→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
   105→    ws.write("/f.txt", b"hello world")
   106→    chunk = ws.read("/f.txt", 0, 5)
   107→    assert chunk == b"hello"
   108→
   109→
   110→def test_workspace_read_full():
   111→    from mirage.backends.local.memory import MemoryBackend
   112→    from mirage.types import MountMode
   113→    from mirage.workspace import Workspace
   114→
   115→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
   116→    ws.write("/f.txt", b"hello")
   117→    data = ws.read("/f.txt", 0, 5)
   118→    assert data == b"hello"
   119→


     1→import os
     2→
     3→import yaml
     4→
     5→from mirage.backends.base import BaseBackend
     6→from mirage.backends.local.local import LocalBackend
     7→from mirage.backends.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.backends.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.backends.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.backends.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 = ("#none", "#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.NONE,
   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 io
     2→
     3→import pyarrow as pa
     4→import pyarrow.parquet as pq
     5→
     6→from mirage.backends.local.memory import MemoryBackend
     7→from mirage.fuse_ops.csv_op import CsvFuseOp
     8→from mirage.fuse_ops.parquet import ParquetFuseOp
     9→from mirage.types import MountMode
    10→from mirage.workspace import Workspace
    11→
    12→
    13→def _make_parquet_bytes() -> bytes:
    14→    table = pa.table({
    15→        "name": ["alice", "bob", "charlie"],
    16→        "score": [95, 87, 72],
    17→    })
    18→    buf = io.BytesIO()
    19→    pq.write_table(table, buf)
    20→    return buf.getvalue()
    21→
    22→
    23→def test_parquet_read_returns_text():
    24→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    25→    ws.write("/data.parquet", _make_parquet_bytes())
    26→    op = ParquetFuseOp()
    27→    ctx = op.on_open(ws, "/data.parquet", 1000)
    28→    data = op.read(ws, "/data.parquet", 0, 4096, ctx)
    29→    text = data.decode()
    30→    assert "name" in text
    31→    assert "score" in text
    32→    assert "alice" in text
    33→
    34→
    35→def test_parquet_schema_in_output():
    36→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    37→    ws.write("/data.parquet", _make_parquet_bytes())
    38→    op = ParquetFuseOp()
    39→    ctx = op.on_open(ws, "/data.parquet", 1000)
    40→    data = op.read(ws, "/data.parquet", 0, 8192, ctx)
    41→    text = data.decode()
    42→    assert "int" in text.lower()
    43→
    44→
    45→def test_parquet_row_count():
    46→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    47→    ws.write("/data.parquet", _make_parquet_bytes())
    48→    op = ParquetFuseOp()
    49→    ctx = op.on_open(ws, "/data.parquet", 1000)
    50→    data = op.read(ws, "/data.parquet", 0, 8192, ctx)
    51→    text = data.decode()
    52→    assert "Rows: 3" in text
    53→
    54→
    55→def test_parquet_error_handling():
    56→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    57→    ws.write("/bad.parquet", b"not a parquet file")
    58→    op = ParquetFuseOp()
    59→    ctx = op.on_open(ws, "/bad.parquet", 18)
    60→    data = op.read(ws, "/bad.parquet", 0, 4096, ctx)
    61→    text = data.decode()
    62→    assert "Error" in text
    63→
    64→
    65→def test_csv_small_passthrough():
    66→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    67→    small = b"name,score\nalice,95\nbob,87\n"
    68→    ws.write("/small.csv", small)
    69→    op = CsvFuseOp()
    70→    ctx = op.on_open(ws, "/small.csv", len(small))
    71→    data = op.read(ws, "/small.csv", 0, 4096, ctx)
    72→    assert data == small
    73→
    74→
    75→def test_csv_large_preview():
    76→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    77→    header = "name,score\n"
    78→    rows = "".join(f"user{i},{i}\n" for i in range(5000))
    79→    big = (header + rows).encode()
    80→    ws.write("/big.csv", big)
    81→    op = CsvFuseOp()
    82→    ctx = op.on_open(ws, "/big.csv", len(big))
    83→    data = op.read(ws, "/big.csv", 0, 8192, ctx)
    84→    text = data.decode()
    85→    assert "name" in text
    86→    assert "score" in text
    87→    assert "5001 rows" in text
    88→    assert "2 columns" in text
    89→
    90→
    91→def test_csv_large_has_preview_marker():
    92→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    93→    header = "a,b,c\n"
    94→    rows = "".join(f"{i},{i+1},{i+2}\n" for i in range(5000))
    95→    big = (header + rows).encode()
    96→    ws.write("/big.csv", big)
    97→    op = CsvFuseOp()
    98→    ctx = op.on_open(ws, "/big.csv", len(big))
    99→    data = op.read(ws, "/big.csv", 0, 8192, ctx)
   100→    text = data.decode()
   101→    assert "more rows" in text
   102→


     1→from mirage.backends.local.memory import MemoryBackend
     2→from mirage.fuse_ops.default import DefaultFuseOp
     3→from mirage.fuse_ops.memory import MemoryFuseOp
     4→from mirage.types import MountMode
     5→from mirage.workspace import Workspace
     6→
     7→
     8→def test_default_eager_small_file():
     9→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    10→    ws.write("/f.txt", b"hello world")
    11→    op = DefaultFuseOp()
    12→    ctx = op.on_open(ws, "/f.txt", 11)
    13→    assert ctx["eager"] is True
    14→    data = op.read(ws, "/f.txt", 0, 5, ctx)
    15→    assert data == b"hello"
    16→
    17→
    18→def test_default_lazy_large_file():
    19→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    20→    big = b"x" * (2 * 1024 * 1024)
    21→    ws.write("/big.bin", big)
    22→    op = DefaultFuseOp()
    23→    ctx = op.on_open(ws, "/big.bin", len(big))
    24→    assert ctx["eager"] is False
    25→    chunk = op.read(ws, "/big.bin", 0, 100, ctx)
    26→    assert chunk == b"x" * 100
    27→
    28→
    29→def test_memory_always_eager():
    30→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    31→    big = b"y" * (2 * 1024 * 1024)
    32→    ws.write("/big.bin", big)
    33→    op = MemoryFuseOp()
    34→    ctx = op.on_open(ws, "/big.bin", len(big))
    35→    assert ctx["eager"] is True
    36→
    37→
    38→def test_buffered_write_flush():
    39→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    40→    ws.write("/f.txt", b"hello world")
    41→    op = DefaultFuseOp()
    42→    ctx = op.on_open(ws, "/f.txt", 11)
    43→    op.write(ws, "/f.txt", 0, b"HELLO", ctx)
    44→    op.write(ws, "/f.txt", 6, b"WORLD", ctx)
    45→    op.on_flush(ws, "/f.txt", ctx)
    46→    assert ws.cat("/f.txt") == b"HELLO WORLD"
    47→
    48→
    49→def test_buffered_write_new_file():
    50→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    51→    op = DefaultFuseOp()
    52→    ctx = op.on_open(ws, "/new.txt", 0)
    53→    op.write(ws, "/new.txt", 0, b"brand new", ctx)
    54→    op.on_flush(ws, "/new.txt", ctx)
    55→    assert ws.cat("/new.txt") == b"brand new"
    56→
    57→
    58→def test_flush_noop_without_writes():
    59→    ws = Workspace({"/": MemoryBackend()}, mode=MountMode.WRITE)
    60→    ws.write("/f.txt", b"original")
    61→    op = DefaultFuseOp()
    62→    ctx = op.on_open(ws, "/f.txt", 8)
    63→    op.on_flush(ws, "/f.txt", ctx)
    64→    assert ws.cat("/f.txt") == b"original"
    65→


     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.backends.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_ops.loader import build_registry_from_config
    16→from mirage.fuse_ops.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 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 #none|#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.NONE),
   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 = ("#none", "#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_fuse import MirageFS
   205→    from mirage_fuse import mount as fuse_mount_fn
   206→
   207→    from mirage.backends.network.proxy import ProxyBackend
   208→    from mirage.fuse_ops.remote import RemoteFuseOp
   209→
   210→    if not mountpoint:
   211→        raise typer.BadParameter("--mountpoint is required when using --fuse")
   212→    if not specs and not config:
   213→        raise typer.BadParameter(
   214→            "No backends specified. Use --backend URI or provide mount specs.")
   215→    for spec in specs:
   216→        if ":" not in spec:
   217→            raise typer.BadParameter(
   218→                f"Invalid mount spec: {spec!r}. Use backend:/path")
   219→
   220→    Path(mountpoint).mkdir(parents=True, exist_ok=True)
   221→    _ensure_server()
   222→    cfg = load_workspace_yaml(config) if config else {}
   223→
   224→    if runtime == RuntimeType.REMOTE:
   225→        url = cfg.get("url", "")
   226→        ws_id_remote = cfg.get("ws_id", "")
   227→        proxy = ProxyBackend(url, ws_id_remote)
   228→        ws = Workspace({"/": proxy}, mode=MountMode.WRITE)
   229→        fuse_registry = FuseOpRegistry()
   230→        fuse_registry.set_default(RemoteFuseOp(proxy))
   231→        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)
   232→    else:
   233→        mounts, mode, consistency, ttl, agent_id = _parse_mounts_for_fuse(
   234→            specs, cfg, mode, consistency, ttl, agent_id)
   235→        ws = Workspace(mounts,
   236→                       mode=mode,
   237→                       consistency=consistency,
   238→                       ttl_seconds=ttl)
   239→        fuse_ops_config = cfg.get("fuse_ops", {}) if cfg else {}
   240→        fuse_registry = build_registry_from_config(fuse_ops_config)
   241→        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)
   242→
   243→    pid = os.getpid()
   244→    _client().register_fuse(mountpoint, pid, specs, runtime=runtime.value)
   245→
   246→    thread = threading.Thread(target=_report_loop,
   247→                              args=(ws, mountpoint, fs),
   248→                              daemon=True)
   249→    thread.start()
   250→
   251→    fuse_mount_fn(mountpoint=mountpoint, foreground=True, fs=fs)
   252→
   253→
   254→def exec_cmd(
   255→        ws_id: str = typer.Argument(),
   256→        command: list[str] = typer.Argument(),
   257→) -> None:
   258→    """Execute a command in a running workspace."""
   259→    cmd_str = " ".join(command)
   260→    try:
   261→        output = _client().exec(ws_id, cmd_str)
   262→        if output:
   263→            print(output)
   264→    except ClientError as e:
   265→        print(f"Error: {e}", file=sys.stderr)
   266→        raise typer.Exit(1)
   267→
   268→
   269→def kill(ws_id: str = typer.Argument()) -> None:
   270→    """Stop a workspace daemon."""
   271→    _client().delete_workspace(ws_id)
   272→    print(f"Killed workspace {ws_id}")
   273→
   274→
   275→def sync(ws_id: str = typer.Argument()) -> None:
   276→    """Flush dirty inodes to backends for a workspace."""
   277→    try:
   278→        results = _client().sync(ws_id)
   279→        if results:
   280→            for r in results:
   281→                print(f"{r['path']}: {r['status']}")
   282→        else:
   283→            print("All clean.")
   284→    except ClientError as e:
   285→        print(f"Error: {e}", file=sys.stderr)
   286→        raise typer.Exit(1)
   287→
   288→
   289→def status(ws_id: str = typer.Argument()) -> None:
   290→    """Show dirty/clean inode status for a workspace."""
   291→    try:
   292→        inodes = _client().status(ws_id)
   293→        if inodes:
   294→            for i in inodes:
   295→                marker = "D" if i["dirty"] else " "
   296→                print(f"{marker} {i['path']}")
   297→        else:
   298→            print("No tracked files.")
   299→    except ClientError as e:
   300→        print(f"Error: {e}", file=sys.stderr)
   301→        raise typer.Exit(1)
   302→
   303→
   304→@workspace_app.command(name="ls")
   305→def workspace_ls() -> None:
   306→    """List active workspaces."""
   307→    _ensure_server()
   308→    workspaces = _client().list_workspaces()
   309→    if not workspaces:
   310→        print("No active workspaces.")
   311→        return
   312→    for w in workspaces:
   313→        parts = [w["id"], w.get("type", "workspace")]
   314→        if w.get("mountpoint"):
   315→            parts.append(w["mountpoint"])
   316→        print("\t".join(parts))
   317→


     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.backends.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.NONE)
    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.NONE)
    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.NONE)
    78→        mounts[prefix] = (backend_obj, m, ConsistencyPolicy.NONE)
    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→
   101→    if transport == "grpc":
   102→        from mirage.commands.remote.grpc.server import serve
   103→        serve(ws, server_auth, host=host, port=port)
   104→    else:
   105→        import uvicorn
   106→
   107→        from mirage.commands.remote.fastapi.app import \
   108→            command_server_app as cs_app
   109→        from mirage.commands.remote.fastapi.app import configure
   110→        configure(ws, server_auth)
   111→        uvicorn.run(cs_app, host=host, port=port, log_level="warning")
   112→


     1→from unittest.mock import MagicMock
     2→
     3→from mirage.backends.local.memory import MemoryBackend
     4→from mirage.fuse_ops.remote import RemoteFuseOp
     5→from mirage.types import MountMode, SyncPolicy
     6→from mirage.workspace import Workspace
     7→
     8→
     9→def test_remote_on_open_sends_file():
    10→    mock_client = MagicMock()
    11→    mock_client.on_open.return_value = {"eager": True, "buffer": b"processed"}
    12→
    13→    ws = Workspace({"/data/": MemoryBackend()},
    14→                   mode=MountMode.WRITE,
    15→                   sync_policy=SyncPolicy.IMMEDIATE)
    16→    ws.write("/data/test.txt", b"hello world")
    17→
    18→    op = RemoteFuseOp(client=mock_client)
    19→    ctx = op.on_open(ws, "/data/test.txt", 11)
    20→    assert ctx.get("eager") is True
    21→
    22→    call_kwargs = mock_client.on_open.call_args
    23→    assert call_kwargs.kwargs.get("data") == b"hello world"
    24→    assert call_kwargs.kwargs.get("checksum") is not None
    25→
    26→
    27→def test_remote_read_delegates():
    28→    mock_client = MagicMock()
    29→    mock_client.read.return_value = b"partial"
    30→
    31→    ws = Workspace({"/data/": MemoryBackend()})
    32→    op = RemoteFuseOp(client=mock_client)
    33→    result = op.read(ws, "/data/test.txt", 0, 7, {"eager": False})
    34→    assert result == b"partial"
    35→    mock_client.read.assert_called_once()
    36→
    37→
    38→def test_remote_on_flush_writes_back():
    39→    mock_client = MagicMock()
    40→    mock_client.on_flush.return_value = b"final output"
    41→
    42→    ws = Workspace({"/data/": MemoryBackend()},
    43→                   mode=MountMode.WRITE,
    44→                   sync_policy=SyncPolicy.IMMEDIATE)
    45→    ws.write("/data/test.txt", b"initial")
    46→
    47→    op = RemoteFuseOp(client=mock_client)
    48→    ctx = {"writes": [(0, b"some data")]}
    49→    op.on_flush(ws, "/data/test.txt", ctx)
    50→    mock_client.on_flush.assert_called_once()
    51→    result = ws.cat("/data/test.txt")
    52→    assert result == b"final output"
    53→
    54→
    55→def test_remote_write_delegates():
    56→    mock_client = MagicMock()
    57→    mock_client.write.return_value = 5
    58→
    59→    ws = Workspace({"/data/": MemoryBackend()})
    60→    op = RemoteFuseOp(client=mock_client)
    61→    result = op.write(ws, "/data/test.txt", 0, b"hello", {})
    62→    assert result == 5
    63→    mock_client.write.assert_called_once()
    64→
    65→
    66→def test_remote_on_open_empty_file():
    67→    mock_client = MagicMock()
    68→    mock_client.on_open.return_value = {"eager": True}
    69→
    70→    ws = Workspace({"/data/": MemoryBackend()})
    71→    op = RemoteFuseOp(client=mock_client)
    72→    ctx = op.on_open(ws, "/data/empty.txt", 0)
    73→    assert ctx.get("eager") is True
    74→    call_kwargs = mock_client.on_open.call_args
    75→    assert call_kwargs.kwargs.get("data") == b""
    76→


     1→from mirage.backends.local.local import LocalBackend
     2→from mirage.commands.custom.registry import CommandRegistry
     3→from mirage.runtime.base import CommandNotAllowedError, Runtime
     4→from mirage.types import MountMode, SyncPolicy
     5→from mirage.workspace import Workspace
     6→
     7→
     8→class LocalRuntime(Runtime):
     9→
    10→    def __init__(self,
    11→                 root: str,
    12→                 registry: CommandRegistry | None = None,
    13→                 mode: MountMode = MountMode.WRITE) -> None:
    14→        backend = LocalBackend(root)
    15→        ws = Workspace({"/": backend},
    16→                       mode=mode,
    17→                       sync_policy=SyncPolicy.IMMEDIATE)
    18→        super().__init__(ws, registry)
    19→
    20→
    21→__all__ = ["CommandNotAllowedError", "LocalRuntime"]
    22→


     1→import pytest
     2→
     3→from mirage.backends.local.local import LocalBackend
     4→from mirage.backends.local.memory import MemoryBackend
     5→from mirage.cli.config import (load_workspace_yaml, load_yaml_config,
     6→                               parse_mount_uri, parse_mount_uri_full,
     7→                               parse_mount_uri_with_mode,
     8→                               parse_mounts_from_cli, split_mount_spec)
     9→from mirage.types import ConsistencyPolicy, MountMode
    10→
    11→
    12→def test_parse_memory_uri():
    13→    backend = parse_mount_uri("memory")
    14→    assert isinstance(backend, MemoryBackend)
    15→
    16→
    17→def test_parse_local_uri():
    18→    backend = parse_mount_uri("local:./scratch")
    19→    assert isinstance(backend, LocalBackend)
    20→
    21→
    22→def test_parse_local_uri_relative_path():
    23→    backend = parse_mount_uri("./scratch")
    24→    assert isinstance(backend, LocalBackend)
    25→
    26→
    27→def test_parse_local_uri_absolute_path():
    28→    backend = parse_mount_uri("/tmp/mydir")
    29→    assert isinstance(backend, LocalBackend)
    30→
    31→
    32→def test_parse_unknown_scheme_raises():
    33→    with pytest.raises(ValueError, match="Unknown URI scheme"):
    34→        parse_mount_uri("memory://")
    35→
    36→
    37→def test_parse_mount_cli_args():
    38→    mounts = parse_mounts_from_cli(
    39→        ["memory:/data/", "local:./scratch:/local/"])
    40→    assert "/data/" in mounts
    41→    assert "/local/" in mounts
    42→    assert isinstance(mounts["/data/"], MemoryBackend)
    43→    assert isinstance(mounts["/local/"], LocalBackend)
    44→
    45→
    46→def test_load_yaml_config(tmp_path):
    47→    cfg_file = tmp_path / "ws.yaml"
    48→    cfg_file.write_text(
    49→        "mounts:\n  /data/: memory\n  /local/: local:./scratch\n"
    50→        "sync_policy: immediate\nmode: write\n")
    51→    cfg = load_yaml_config(str(cfg_file))
    52→    assert "/data/" in cfg["mounts"]
    53→    assert cfg["sync_policy"] == "immediate"
    54→    assert cfg["mode"] == "write"
    55→
    56→
    57→def test_parse_mount_uri_with_mode_read():
    58→    backend, mode = parse_mount_uri_with_mode("memory@read")
    59→    assert isinstance(backend, MemoryBackend)
    60→    assert mode == MountMode.READ
    61→
    62→
    63→def test_parse_mount_uri_with_mode_write():
    64→    backend, mode = parse_mount_uri_with_mode("memory@write")
    65→    assert isinstance(backend, MemoryBackend)
    66→    assert mode == MountMode.WRITE
    67→
    68→
    69→def test_parse_mount_uri_with_mode_default():
    70→    backend, mode = parse_mount_uri_with_mode("memory")
    71→    assert isinstance(backend, MemoryBackend)
    72→    assert mode == MountMode.READ
    73→
    74→
    75→def test_parse_mounts_from_cli_per_mount_mode():
    76→    mounts = parse_mounts_from_cli(
    77→        ["memory@write:/a/", "memory:/b/"],
    78→        default_mode=MountMode.READ,
    79→    )
    80→    assert isinstance(mounts["/a/"], tuple)
    81→    backend, mode = mounts["/a/"]
    82→    assert isinstance(backend, MemoryBackend)
    83→    assert mode == MountMode.WRITE
    84→    assert isinstance(mounts["/b/"], MemoryBackend)
    85→
    86→
    87→def test_split_mount_spec_memory():
    88→    backend, path = split_mount_spec("memory:/data/")
    89→    assert backend == "memory"
    90→    assert path == "/data/"
    91→
    92→
    93→def test_split_mount_spec_s3():
    94→    backend, path = split_mount_spec("s3://bucket:/")
    95→    assert backend == "s3://bucket"
    96→    assert path == "/"
    97→
    98→
    99→def test_split_mount_spec_with_mode():
   100→    backend, path = split_mount_spec("memory@write:/a/")
   101→    assert backend == "memory@write"
   102→    assert path == "/a/"
   103→
   104→
   105→def test_split_mount_spec_invalid():
   106→    with pytest.raises(ValueError, match="Invalid mount spec"):
   107→        split_mount_spec("nocolon")
   108→
   109→
   110→def test_load_workspace_yaml_per_mount_mode(tmp_path):
   111→    cfg_file = tmp_path / "ws.yaml"
   112→    cfg_file.write_text("mounts:\n"
   113→                        "  /data/: memory\n"
   114→                        "  /scratch/:\n"
   115→                        "    uri: memory\n"
   116→                        "    mode: write\n")
   117→    cfg = load_workspace_yaml(str(cfg_file))
   118→    assert cfg["mounts"]["/data/"] == "memory"
   119→    assert cfg["mounts"]["/scratch/"] == {"uri": "memory", "mode": "write"}
   120→
   121→
   122→def test_parse_mount_uri_full_lazy():
   123→    backend, mode, consistency = parse_mount_uri_full("memory@write#lazy")
   124→    assert isinstance(backend, MemoryBackend)
   125→    assert mode == MountMode.WRITE
   126→    assert consistency == ConsistencyPolicy.LAZY
   127→
   128→
   129→def test_parse_mount_uri_full_default():
   130→    backend, mode, consistency = parse_mount_uri_full("memory")
   131→    assert isinstance(backend, MemoryBackend)
   132→    assert mode == MountMode.READ
   133→    assert consistency == ConsistencyPolicy.NONE
   134→
   135→
   136→def test_load_workspace_yaml_consistency(tmp_path):
   137→    cfg_file = tmp_path / "ws.yaml"
   138→    cfg_file.write_text("consistency: lazy\n"
   139→                        "ttl: 60\n"
   140→                        "mounts:\n"
   141→                        "  /data/: memory\n"
   142→                        "  /scratch/:\n"
   143→                        "    uri: memory\n"
   144→                        "    consistency: always\n")
   145→    cfg = load_workspace_yaml(str(cfg_file))
   146→    assert cfg["consistency"] == "lazy"
   147→    assert cfg["ttl"] == 60
   148→    assert cfg["mounts"]["/data/"] == "memory"
   149→    assert cfg["mounts"]["/scratch/"] == {
   150→        "uri": "memory",
   151→        "consistency": "always",
   152→    }
   153→
   154→
   155→def test_load_workspace_yaml_custom_backend(tmp_path):
   156→    cfg_file = tmp_path / "ws.yaml"
   157→    cfg_file.write_text("mounts:\n"
   158→                        "  /data/:\n"
   159→                        "    backend: './my_backend.py:MyBackend'\n"
   160→                        "    args:\n"
   161→                        "      bucket: my-bucket\n"
   162→                        "    mode: write\n"
   163→                        "    consistency: lazy\n")
   164→    cfg = load_workspace_yaml(str(cfg_file))
   165→    mount = cfg["mounts"]["/data/"]
   166→    assert mount["backend"] == "./my_backend.py:MyBackend"
   167→    assert mount["args"] == {"bucket": "my-bucket"}
   168→    assert mount["mode"] == "write"
   169→    assert mount["consistency"] == "lazy"
   170→
   171→
   172→def test_load_workspace_yaml_custom_backend_minimal(tmp_path):
   173→    cfg_file = tmp_path / "ws.yaml"
   174→    cfg_file.write_text("mounts:\n"
   175→                        "  /data/:\n"
   176→                        "    backend: 'mypackage:MyBackend'\n")
   177→    cfg = load_workspace_yaml(str(cfg_file))
   178→    mount = cfg["mounts"]["/data/"]
   179→    assert mount == {"backend": "mypackage:MyBackend"}
   180→
   181→
   182→def test_load_workspace_yaml_with_fuse_ops(tmp_path):
   183→    cfg_file = tmp_path / "ws.yaml"
   184→    cfg_file.write_text("mounts:\n"
   185→                        "  /data/: memory\n"
   186→                        "fuse_ops:\n"
   187→                        "  '*.parquet': parquet\n"
   188→                        "  '*.csv': csv\n")
   189→    cfg = load_workspace_yaml(str(cfg_file))
   190→    assert "fuse_ops" in cfg
   191→    assert cfg["fuse_ops"]["*.parquet"] == "parquet"
   192→    assert cfg["fuse_ops"]["*.csv"] == "csv"
   193→
   194→
   195→def test_load_workspace_yaml_no_fuse_ops(tmp_path):
   196→    cfg_file = tmp_path / "ws.yaml"
   197→    cfg_file.write_text("mounts:\n  /data/: memory\n")
   198→    cfg = load_workspace_yaml(str(cfg_file))
   199→    assert "fuse_ops" not in cfg
   200→
   201→
   202→def test_load_workspace_yaml_remote(tmp_path):
   203→    cfg_file = tmp_path / "ws.yaml"
   204→    cfg_file.write_text("runtime: remote\n"
   205→                        "url: http://server:8000\n"
   206→                        "ws_id: abc12345\n"
   207→                        "mounts: {}\n")
   208→    cfg = load_workspace_yaml(str(cfg_file))
   209→    assert cfg["runtime"] == "remote"
   210→    assert cfg["url"] == "http://server:8000"
   211→    assert cfg["ws_id"] == "abc12345"
   212→
   213→
   214→def test_load_workspace_yaml_remote_with_command_url(tmp_path):
   215→    cfg_file = tmp_path / "ws.yaml"
   216→    cfg_file.write_text("runtime: remote\n"
   217→                        "url: http://data:8000\n"
   218→                        "ws_id: abc12345\n"
   219→                        "command_url: http://cmd:8001\n"
   220→                        "fuse_ops_url: http://ops:8002\n"
   221→                        "mounts: {}\n")
   222→    cfg = load_workspace_yaml(str(cfg_file))
   223→    assert cfg["runtime"] == "remote"
   224→    assert cfg["url"] == "http://data:8000"
   225→    assert cfg["command_url"] == "http://cmd:8001"
   226→    assert cfg["fuse_ops_url"] == "http://ops:8002"
   227→


     1→import shlex
     2→
     3→import httpx
     4→
     5→from mirage.backends.network.proxy import ProxyBackend
     6→from mirage.runtime.accessors.remote import (RemoteCommandsAccessor,
     7→                                             RemoteFuseOpsAccessor,
     8→                                             RemoteWorkspaceOpsAccessor)
     9→from mirage.runtime.base import _BLOCKED, CommandNotAllowedError, Runtime
    10→from mirage.server import routes
    11→from mirage.types import MountMode, SyncPolicy
    12→from mirage.workspace import Workspace
    13→
    14→
    15→class RemoteRuntime(Runtime):
    16→
    17→    def __init__(self,
    18→                 url: str,
    19→                 ws_id: str,
    20→                 agent_id: str | None = None,
    21→                 timeout: int = 30,
    22→                 http_client: httpx.Client | None = None) -> None:
    23→        self._url = url.rstrip("/")
    24→        self._ws_id = ws_id
    25→        backend = ProxyBackend(url, ws_id, timeout, http_client=http_client)
    26→        ws = Workspace({"/": backend},
    27→                       mode=MountMode.WRITE,
    28→                       sync_policy=SyncPolicy.IMMEDIATE)
    29→        self._http = http_client or httpx.Client(base_url=self._url,
    30→                                                 timeout=timeout)
    31→        super().__init__(ws, registry=None, agent_id=agent_id)
    32→        self.commands = RemoteCommandsAccessor(self._http, ws_id)
    33→        self.fuse_ops = RemoteFuseOpsAccessor(self._http, ws_id)
    34→        self.workspace = RemoteWorkspaceOpsAccessor(self._http, ws_id)
    35→
    36→    def run(self, command: str) -> str:
    37→        parts = shlex.split(command)
    38→        if not parts:
    39→            return ""
    40→        cmd = parts[0]
    41→        if cmd in _BLOCKED:
    42→            raise CommandNotAllowedError(f"{cmd!r} is not allowed in Runtime.")
    43→        if cmd == "cd":
    44→            return self._handle_cd(parts[1:])
    45→        if cmd == "whoami":
    46→            return self._whoami()
    47→        if cmd == "identify":
    48→            if parts[1:]:
    49→                self.agent_id = " ".join(parts[1:])
    50→            return self.agent_id or ""
    51→        resp = self._http.post(routes.WORKSPACE_EXEC.format(ws_id=self._ws_id),
    52→                               json={
    53→                                   "command": command,
    54→                                   "cwd": self._cwd
    55→                               })
    56→        data = resp.json()
    57→        if not data.get("ok"):
    58→            raise RuntimeError(data.get("error", "unknown error"))
    59→        return data.get("output", "")
    60→
    61→    def _handle_cd(self, argv: list[str]) -> str:
    62→        from mirage.commands import _resolve
    63→        from mirage.commands.builtin import nav
    64→        if not argv:
    65→            self._cwd = "/"
    66→            return ""
    67→        self._cwd = nav.cd(self.ws, self._cwd, _resolve(self._cwd, argv[0]))
    68→        return ""
    69→


     1→from unittest.mock import MagicMock
     2→
     3→from mirage.backends.local.memory import MemoryBackend
     4→from mirage.commands.custom.registry import CommandRegistry, ExternalCommand
     5→from mirage.commands.remote.client import CommandClient
     6→from mirage.types import CommandResult, MountMode, SyncPolicy
     7→from mirage.workspace import Workspace
     8→
     9→
    10→def _fake_run_transform(command, cwd="/", stdin=None, files=None):
    11→    assert files is not None
    12→    assert "/data/input.json" in files
    13→    input_data = files["/data/input.json"].data
    14→    processed = input_data.upper()
    15→    return CommandResult(
    16→        stdout="processed 1 file",
    17→        output_files={"/data/output.csv": processed},
    18→    )
    19→
    20→
    21→def test_full_round_trip_file_transfer():
    22→    mock_client = MagicMock(spec=CommandClient)
    23→    mock_client.run.side_effect = _fake_run_transform
    24→
    25→    ws = Workspace(
    26→        {"/data/": MemoryBackend()},
    27→        mode=MountMode.WRITE,
    28→        sync_policy=SyncPolicy.IMMEDIATE,
    29→    )
    30→
    31→    ws.write("/data/input.json", b'{"name": "test"}')
    32→
    33→    reg = CommandRegistry(client=mock_client)
    34→    reg.register(
    35→        ExternalCommand(
    36→            name="transform",
    37→            exec="transform {file} -o {out}",
    38→        ))
    39→
    40→    result = reg.run(ws,
    41→                     "transform /data/input.json /data/output.csv",
    42→                     cwd="/")
    43→    assert "processed" in result
    44→
    45→    output = ws.cat("/data/output.csv")
    46→    assert output == b'{"NAME": "TEST"}'
    47→
    48→
    49→def test_full_round_trip_pipe_mode():
    50→    mock_client = MagicMock(spec=CommandClient)
    51→    mock_client.run.return_value = CommandResult(stdout="HELLO WORLD")
    52→
    53→    ws = Workspace({"/data/": MemoryBackend()})
    54→    reg = CommandRegistry(client=mock_client)
    55→    reg.register(ExternalCommand(name="remote-upper", exec="upper"))
    56→
    57→    result = reg.run(ws, "remote-upper", cwd="/", stdin=b"hello world")
    58→    assert result == "HELLO WORLD"
    59→
    60→    call_kwargs = mock_client.run.call_args
    61→    assert call_kwargs.kwargs["stdin"] == b"hello world"
    62→
    63→
    64→def test_mixed_local_and_remote_commands():
    65→    mock_client = MagicMock(spec=CommandClient)
    66→    mock_client.run.return_value = CommandResult(
    67→        stdout="ok",
    68→        output_files={"/data/summary.txt": b"summary content"},
    69→    )
    70→
    71→    ws = Workspace(
    72→        {"/data/": MemoryBackend()},
    73→        mode=MountMode.WRITE,
    74→        sync_policy=SyncPolicy.IMMEDIATE,
    75→    )
    76→
    77→    reg = CommandRegistry(client=mock_client)
    78→    reg.register(
    79→        ExternalCommand(name="summarize", exec="summarize {file} -o {out}"))
    80→
    81→    ws.write("/data/raw.txt", b"hello")
    82→
    83→    reg.run(ws, "summarize /data/raw.txt /data/summary.txt", cwd="/")
    84→
    85→    content = ws.cat("/data/summary.txt")
    86→    assert content == b"summary content"
    87→
    88→
    89→def test_checksum_sent_with_files():
    90→    mock_client = MagicMock(spec=CommandClient)
    91→    mock_client.run.return_value = CommandResult(stdout="ok")
    92→
    93→    ws = Workspace(
    94→        {"/data/": MemoryBackend()},
    95→        mode=MountMode.WRITE,
    96→        sync_policy=SyncPolicy.IMMEDIATE,
    97→    )
    98→    ws.write("/data/input.txt", b"test data")
    99→
   100→    reg = CommandRegistry(client=mock_client)
   101→    reg.register(ExternalCommand(name="process", exec="process {file}"))
   102→
   103→    reg.run(ws, "process /data/input.txt", cwd="/")
   104→
   105→    call_kwargs = mock_client.run.call_args
   106→    files = call_kwargs.kwargs["files"]
   107→    payload = files["/data/input.txt"]
   108→    assert payload.checksum is not None
   109→    assert len(payload.checksum) == 32
   110→    assert payload.data == b"test data"
   111→
   112→
   113→def test_relative_path_resolution():
   114→    mock_client = MagicMock(spec=CommandClient)
   115→    mock_client.run.return_value = CommandResult(stdout="ok")
   116→
   117→    ws = Workspace(
   118→        {"/data/": MemoryBackend()},
   119→        mode=MountMode.WRITE,
   120→        sync_policy=SyncPolicy.IMMEDIATE,
   121→    )
   122→    ws.write("/data/input.txt", b"content")
   123→
   124→    reg = CommandRegistry(client=mock_client)
   125→    reg.register(ExternalCommand(name="process", exec="process {file}"))
   126→
   127→    reg.run(ws, "process /data/input.txt", cwd="/data")
   128→
   129→    call_kwargs = mock_client.run.call_args
   130→    files = call_kwargs.kwargs["files"]
   131→    assert "/data/input.txt" in files
   132→