The file /Users/agent/strukto/mirage/mirage/cli/commands/workspace.py has been updated. All occurrences of 'ConsistencyPolicy.NONE' were successfully replaced with 'ConsistencyPolicy.CACHED'.

import os
import sys
import threading
import time
from pathlib import Path
from typing import TYPE_CHECKING, Annotated

import typer

from mirage.backend.loader import load_backend_class
from mirage.cli.client import ClientError
from mirage.cli.config import (load_workspace_yaml, parse_mount_uri,
                               parse_mount_uri_full, split_mount_spec)
from mirage.cli.helpers import _client, _ensure_server
from mirage.fuse.loader import build_registry_from_config
from mirage.fuse.registry import FuseOpRegistry
from mirage.observe.inspector import (workspace_status_from_ws,
                                      workspace_transfers_from_ws)
from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
from mirage.workspace import Workspace

if TYPE_CHECKING:
    from mirage.fuse.fs import MirageFS

workspace_app = typer.Typer(no_args_is_help=True)

REPORT_INTERVAL = 2

def _report_loop(ws: Workspace, mountpoint: str, fs: "MirageFS") -> None:
    from mirage.cli.client import MirageClient
    client = MirageClient()
    time.sleep(2)
    while True:
        try:
            status = workspace_status_from_ws(ws)
            transfers = workspace_transfers_from_ws(ws)
            ops = fs.drain_ops()
            client.report_fuse_stats(mountpoint, status, transfers, ops)
        except Exception:
            pass
        time.sleep(REPORT_INTERVAL)

def _parse_mounts_for_fuse(
    specs: list[str],
    cfg: dict,
    default_mode: MountMode,
    default_consistency: ConsistencyPolicy,
    ttl: int,
    agent_id: str | None,
) -> tuple[dict, MountMode, ConsistencyPolicy, int, str | None]:
    resolved_agent_id = agent_id
    mounts: dict = {}
    if cfg:
        if "consistency" in cfg:
            default_consistency = ConsistencyPolicy(cfg["consistency"])
        if "ttl" in cfg:
            ttl = cfg["ttl"]
        if not resolved_agent_id and "agent_id" in cfg:
            resolved_agent_id = cfg["agent_id"]
        for prefix, value in cfg.get("mounts", {}).items():
            if isinstance(value, str):
                backend_obj, m, c = parse_mount_uri_full(
                    value, default_mode, default_consistency)
            elif isinstance(value, dict) and "backend" in value:
                cls = load_backend_class(value["backend"])
                backend_obj = cls(**(value.get("args") or {}))
                m = (MountMode(value["mode"])
                     if "mode" in value else default_mode)
                c = (ConsistencyPolicy(value["consistency"])
                     if "consistency" in value else default_consistency)
            elif isinstance(value, dict):
                backend_obj = parse_mount_uri(value.get("uri", ""))
                m = (MountMode(value["mode"])
                     if "mode" in value else default_mode)
                c = (ConsistencyPolicy(value["consistency"])
                     if "consistency" in value else default_consistency)
            else:
                continue
            has_override = (m != default_mode or c != default_consistency)
            if has_override:
                mounts[prefix] = (backend_obj, m, c)
            else:
                mounts[prefix] = backend_obj
    for spec in specs:
        uri, prefix = split_mount_spec(spec)
        backend_obj, m, c = parse_mount_uri_full(uri, default_mode,
                                                 default_consistency)
        has_override = (m != default_mode or c != default_consistency)
        if has_override:
            mounts[prefix] = (backend_obj, m, c)
        else:
            mounts[prefix] = backend_obj
    return mounts, default_mode, default_consistency, ttl, resolved_agent_id

def mount(
    mount_specs: Annotated[
        list[str] | None,
        typer.Argument(help="Mount specs: backend:/path "
                       "(e.g. memory:/data/, s3://bucket:/). "
                       "Append #none|#lazy|#ttl|#always for "
                       "consistency, @read|@write|@exec for mode.")] = None,
    sync_policy: str = typer.Option("staged", help="staged or immediate"),
    mode: MountMode = typer.Option(MountMode.WRITE, help="read or write"),
    consistency: ConsistencyPolicy = typer.Option(ConsistencyPolicy.NONE),
    ttl: int = typer.Option(300, help="TTL in seconds for ttl consistency"),
    config: str | None = typer.Option(None,
                                      help="Path to YAML workspace config"),
    runtime: RuntimeType = typer.Option(RuntimeType.MEMORY,
                                        help="Runtime: memory or local"),
    fuse: bool = typer.Option(False, help="Also mount as FUSE filesystem"),
    mountpoint: str | None = typer.Option(
        None, help="Local directory for FUSE mount (requires --fuse)"),
    agent_id: str | None = typer.Option(
        None, help="Agent identity exposed via /.mirage/whoami"),
    backend: str | None = typer.Option(
        None,
        help="Backend URI mounted at /. Prefix specs override for subtree."),
) -> None:
    """Create a workspace and mount backends.

    Each backend URI is routed by prefix. The longest matching prefix wins,
    so /data/logs/ takes priority over /data/ for paths under /data/logs/.

    With --fuse, also mounts the workspace as a local FUSE filesystem
    (requires macFUSE / libfuse). The process blocks while FUSE is mounted.

    URI formats:
      memory             in-memory (no disk, no deps)
      local              local filesystem at current directory
      local:./path       local filesystem at ./path (converted to abs)
      s3://bucket/pfx    AWS S3  (requires mirage[s3])
      r2://account/bkt   Cloudflare R2  (requires mirage[s3])
      https://host       HTTP read-only (requires mirage[http])
    """
    specs = list(mount_specs or [])
    if backend:
        specs.insert(0, f"{backend}:/")

    if fuse:
        _mount_fuse(specs, config, mode, consistency, ttl, agent_id,
                    mountpoint, runtime)
        return

    _ensure_server()
    mounts: dict[str, str | dict] = {}
    if config:
        cfg = load_workspace_yaml(config)
        mounts.update(cfg.get("mounts", {}))
        consistency_str = cfg.get("consistency")
        if consistency_str:
            consistency = ConsistencyPolicy(consistency_str)
        ttl = cfg.get("ttl", ttl)
    consistency_suffixes = ("#none", "#lazy", "#ttl", "#always")
    for spec in specs:
        try:
            uri, prefix = split_mount_spec(spec)
        except ValueError as e:
            raise typer.BadParameter(str(e))
        mount_consistency = None
        for suffix in consistency_suffixes:
            if uri.endswith(suffix):
                mount_consistency = suffix[1:]
                uri = uri[:-len(suffix)]
                break
        mount_mode = None
        for suffix in ("@read", "@write", "@exec"):
            if uri.endswith(suffix):
                mount_mode = suffix[1:]
                uri = uri[:-len(suffix)]
                break
        entry: dict[str, str] = {"uri": uri}
        if mount_mode:
            entry["mode"] = mount_mode
        if mount_consistency:
            entry["consistency"] = mount_consistency
        if len(entry) == 1:
            mounts[prefix] = uri
        else:
            mounts[prefix] = entry
    ws_id = _client().create_workspace(
        mounts,
        sync_policy=sync_policy,
        mode=mode.value,
        consistency=consistency.value,
        ttl_seconds=ttl,
        runtime=runtime.value,
    )
    print(ws_id)

def _mount_fuse(
    specs: list[str],
    config: str | None,
    mode: MountMode,
    consistency: ConsistencyPolicy,
    ttl: int,
    agent_id: str | None,
    mountpoint: str | None,
    runtime: RuntimeType,
) -> None:
    from mirage.backend.network.proxy import ProxyBackend
    from mirage.fuse.fs import MirageFS
    from mirage.fuse.fs import mount as fuse_mount_fn
    from mirage.fuse.remote.remote import RemoteFuseOp

    if not mountpoint:
        raise typer.BadParameter("--mountpoint is required when using --fuse")
    if not specs and not config:
        raise typer.BadParameter(
            "No backends specified. Use --backend URI or provide mount specs.")
    for spec in specs:
        if ":" not in spec:
            raise typer.BadParameter(
                f"Invalid mount spec: {spec!r}. Use backend:/path")

    Path(mountpoint).mkdir(parents=True, exist_ok=True)
    _ensure_server()
    cfg = load_workspace_yaml(config) if config else {}

    if runtime == RuntimeType.REMOTE:
        url = cfg.get("url", "")
        ws_id_remote = cfg.get("ws_id", "")
        proxy = ProxyBackend(url, ws_id_remote)
        ws = Workspace({"/": proxy}, mode=MountMode.WRITE)
        fuse_registry = FuseOpRegistry()
        fuse_registry.set_default(RemoteFuseOp(proxy))
        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)
    else:
        mounts, mode, consistency, ttl, agent_id = _parse_mounts_for_fuse(
            specs, cfg, mode, consistency, ttl, agent_id)
        ws = Workspace(mounts,
                       mode=mode,
                       consistency=consistency,
                       ttl_seconds=ttl)
        fuse_ops_config = cfg.get("fuse_ops", {}) if cfg else {}
        fuse_registry = build_registry_from_config(fuse_ops_config)
        fs = MirageFS(ws, agent_id=agent_id, fuse_registry=fuse_registry)

    pid = os.getpid()
    _client().register_fuse(mountpoint, pid, specs, runtime=runtime.value)

    thread = threading.Thread(target=_report_loop,
                              args=(ws, mountpoint, fs),
                              daemon=True)
    thread.start()

    fuse_mount_fn(mountpoint=mountpoint, foreground=True, fs=fs)

def exec_cmd(
        ws_id: str = typer.Argument(),
        command: list[str] = typer.Argument(),
) -> None:
    """Execute a command in a running workspace."""
    cmd_str = " ".join(command)
    try:
        output = _client().exec(ws_id, cmd_str)
        if output:
            print(output)
    except ClientError as e:
        print(f"Error: {e}", file=sys.stderr)
        raise typer.Exit(1)

def kill(ws_id: str = typer.Argument()) -> None:
    """Stop a workspace daemon."""
    _client().delete_workspace(ws_id)
    print(f"Killed workspace {ws_id}")

def sync(ws_id: str = typer.Argument()) -> None:
    """Flush dirty inodes to backends for a workspace."""
    try:
        results = _client().sync(ws_id)
        if results:
            for r in results:
                print(f"{r['path']}: {r['status']}")
        else:
            print("All clean.")
    except ClientError as e:
        print(f"Error: {e}", file=sys.stderr)
        raise typer.Exit(1)

def status(ws_id: str = typer.Argument()) -> None:
    """Show dirty/clean inode status for a workspace."""
    try:
        inodes = _client().status(ws_id)
        if inodes:
            for i in inodes:
                marker = "D" if i["dirty"] else " "
                print(f"{marker} {i['path']}")
        else:
            print("No tracked files.")
    except ClientError as e:
        print(f"Error: {e}", file=sys.stderr)
        raise typer.Exit(1)

@workspace_app.command(name="ls")
def workspace_ls() -> None:
    """List active workspaces."""
    _ensure_server()
    workspaces = _client().list_workspaces()
    if not workspaces:
        print("No active workspaces.")
        return
    for w in workspaces:
        parts = [w["id"], w.get("type", "workspace")]
        if w.get("mountpoint"):
            parts.append(w["mountpoint"])
        print("\t".join(parts))

-    consistency: ConsistencyPolicy = typer.Option(ConsistencyPolicy.NONE),

+    consistency: ConsistencyPolicy = typer.Option(ConsistencyPolicy.CACHED),


The file /Users/agent/strukto/mirage/mirage/cli/config.py has been updated. All occurrences of 'ConsistencyPolicy.NONE' were successfully replaced with 'ConsistencyPolicy.CACHED'.

import os

import yaml

from mirage.backend.base import BaseBackend
from mirage.backend.local.local import LocalBackend
from mirage.backend.local.memory import MemoryBackend
from mirage.types import BackendType, ConsistencyPolicy, MountMode

def _build_backend(type_name: str, connection: str) -> BaseBackend:
    if type_name == BackendType.MEMORY:
        return MemoryBackend()
    if type_name == BackendType.LOCAL:
        return LocalBackend(root=os.path.abspath(connection))
    if type_name == BackendType.S3:
        from mirage.backend.object_storage.s3 import S3Backend, S3Config
        rest = connection[len("s3://"):]
        bucket = rest.split("/", 1)[0]
        return S3Backend(S3Config(bucket=bucket))
    if type_name == BackendType.R2:
        from mirage.backend.object_storage.r2 import R2Backend, R2Config
        rest = connection[len("r2://"):]
        parts = rest.split("/", 1)
        return R2Backend(
            R2Config(
                account_id=parts[0],
                bucket=parts[1] if len(parts) > 1 else "",
                access_key_id=os.environ.get("R2_ACCESS_KEY_ID", ""),
                secret_access_key=os.environ.get("R2_SECRET_ACCESS_KEY", ""),
            ))
    if type_name in (BackendType.HTTP, BackendType.HTTPS):
        from mirage.backend.network.http import HTTPBackend, HTTPConfig
        return HTTPBackend(HTTPConfig(base_url=connection))
    raise ValueError(f"Unknown backend type: {type_name!r}. "
                     "Use built-ins: memory, local, s3, r2, http")

def parse_mount_uri(uri: str) -> BaseBackend:
    if uri == BackendType.MEMORY:
        return MemoryBackend()
    if uri == BackendType.LOCAL:
        return LocalBackend(root=os.path.abspath("."))

    colon = uri.find(":")
    if colon > 0 and "//" not in uri[:colon + 3]:
        return _build_backend(uri[:colon], uri[colon + 1:])

    if uri.startswith("s3://"):
        return _build_backend("s3", uri)
    if uri.startswith("r2://"):
        return _build_backend("r2", uri)
    if uri.startswith("http://") or uri.startswith("https://"):
        return _build_backend("http", uri)

    if "://" in uri:
        scheme = uri.split("://")[0]
        raise ValueError(
            f"Unknown URI scheme {scheme!r}. "
            "Accepted: s3://, r2://, https://. "
            "For simple backends use: memory, local, local:./path")
    return LocalBackend(root=os.path.abspath(uri))

def split_mount_spec(spec: str) -> tuple[str, str]:
    """Split 'backend:/path' into (backend, path).

    Args:
        spec (str): Mount spec like 'memory:/data/' or 's3://bucket:/data/'.

    Returns:
        tuple[str, str]: (backend, mount_path) pair.
    """
    i = len(spec) - 1
    while i >= 0:
        if spec[i] == ":" and i + 1 < len(spec) and spec[i + 1] == "/":
            if i >= 2 and spec[i - 1:i + 2] == "://":
                i -= 1
                continue
            return spec[:i], spec[i + 1:]
        i -= 1
    raise ValueError(
        f"Invalid mount spec: {spec!r}. "
        "Use format: backend:/path (e.g. memory:/data/, s3://bucket:/)")

_MODE_SUFFIXES = ("@read", "@write", "@exec")

def parse_mount_uri_with_mode(
    uri: str,
    default_mode: MountMode = MountMode.READ,
) -> tuple[BaseBackend, MountMode]:
    for suffix in _MODE_SUFFIXES:
        if uri.endswith(suffix):
            return parse_mount_uri(uri[:-len(suffix)]), MountMode(suffix[1:])
    return parse_mount_uri(uri), default_mode

_CONSISTENCY_SUFFIXES = ("#none", "#lazy", "#ttl", "#always")

def parse_mount_uri_full(
    uri: str,
    default_mode: MountMode = MountMode.READ,
    default_consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,
) -> tuple[BaseBackend, MountMode, ConsistencyPolicy]:
    consistency = default_consistency
    for suffix in _CONSISTENCY_SUFFIXES:
        if uri.endswith(suffix):
            consistency = ConsistencyPolicy(suffix[1:])
            uri = uri[:-len(suffix)]
            break
    backend, mode = parse_mount_uri_with_mode(uri, default_mode)
    return backend, mode, consistency

def parse_mounts_from_cli(
    mount_specs: list[str],
    default_mode: MountMode = MountMode.READ,
) -> dict[str, BaseBackend | tuple[BaseBackend, MountMode]]:
    mounts: dict[str, BaseBackend | tuple[BaseBackend, MountMode]] = {}
    for spec in mount_specs:
        uri, prefix = split_mount_spec(spec)
        backend, mode = parse_mount_uri_with_mode(uri, default_mode)
        if mode != default_mode:
            mounts[prefix] = (backend, mode)
        else:
            mounts[prefix] = backend
    return mounts

def load_yaml_config(path: str) -> dict:
    with open(path) as f:
        return yaml.safe_load(f)

def load_workspace_yaml(path: str) -> dict:
    with open(path) as f:
        data = yaml.safe_load(f)
    mounts_raw = data.get("mounts", {})
    mounts: dict[str, str | dict] = {}
    for prefix, value in mounts_raw.items():
        if isinstance(value, str):
            mounts[prefix] = value
        elif isinstance(value, dict):
            backend_spec = value.get("backend")
            if backend_spec:
                entry: dict[str, str | dict] = {"backend": backend_spec}
                if value.get("args"):
                    entry["args"] = value["args"]
                if value.get("mode"):
                    entry["mode"] = value["mode"]
                if value.get("consistency"):
                    entry["consistency"] = value["consistency"]
                mounts[prefix] = entry
            else:
                uri = value.get("uri", "")
                mount_mode = value.get("mode")
                mount_consistency = value.get("consistency")
                entry_uri: dict[str, str] = {"uri": uri}
                if mount_mode:
                    entry_uri["mode"] = mount_mode
                if mount_consistency:
                    entry_uri["consistency"] = mount_consistency
                if len(entry_uri) == 1:
                    mounts[prefix] = uri
                else:
                    mounts[prefix] = entry_uri
    result: dict = {"mounts": mounts, "commands": data.get("commands", {})}
    if "consistency" in data:
        result["consistency"] = data["consistency"]
    if "ttl" in data:
        result["ttl"] = data["ttl"]
    if "agent_id" in data:
        result["agent_id"] = data["agent_id"]
    fuse_ops = data.get("fuse_ops", {})
    if fuse_ops:
        result["fuse_ops"] = fuse_ops
    for key in ("runtime", "url", "ws_id", "command_url", "fuse_ops_url"):
        if key in data:
            result[key] = data[key]
    return result

-    default_consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,

+    default_consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED,


The file /Users/agent/strukto/mirage/mirage/server/fastapi/config.py has been updated. All occurrences of 'ConsistencyPolicy.NONE' were successfully replaced with 'ConsistencyPolicy.CACHED'.

from pydantic import BaseModel

from mirage.types import ConsistencyPolicy, MountMode, SyncPolicy

class MountConfig(BaseModel):
    uri: str | None = None
    backend: str | None = None
    args: dict[str, str] | None = None
    mode: MountMode | None = None
    consistency: ConsistencyPolicy | None = None

class CreateWorkspaceRequest(BaseModel):
    mounts: dict[str, str | MountConfig]
    sync_policy: SyncPolicy = SyncPolicy.STAGED
    mode: MountMode = MountMode.WRITE
    consistency: ConsistencyPolicy = ConsistencyPolicy.NONE
    ttl_seconds: int = 300
    runtime: str | None = None
    fuse_ops: dict[str, str] | None = None

class RegisterFuseRequest(BaseModel):
    mountpoint: str
    pid: int
    mount_specs: list[str] = []
    runtime: str | None = None

class FilePayloadRequest(BaseModel):
    checksum: str
    data: str | None = None

class ExecRequest(BaseModel):
    command: str
    cwd: str | None = None
    stdin: str | None = None
    input_files: dict[str, FilePayloadRequest] | None = None

class FuseReportRequest(BaseModel):
    mountpoint: str
    status: dict
    transfers: dict = {}
    ops: list[dict] = []

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

class RegisterFuseOpRequest(BaseModel):
    pattern: str
    handler: str

class ReadRequest(BaseModel):
    path: str
    offset: int
    size: int

class WriteRequest(BaseModel):
    path: str
    offset: int
    data: str

class PathRequest(BaseModel):
    path: str

class ReaddirRequest(BaseModel):
    path: str
    cursor: str | None = None

class RenameRequest(BaseModel):
    src: str
    dst: str

class TruncateRequest(BaseModel):
    path: str
    size: int

-    consistency: ConsistencyPolicy = ConsistencyPolicy.NONE

+    consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED


The file /Users/agent/strukto/mirage/tests/workspace/test_init.py has been updated. All occurrences of 'ConsistencyPolicy.NONE' were successfully replaced with 'ConsistencyPolicy.CACHED'.

from datetime import datetime, timezone

import pytest

from mirage.backend.local.memory import MemoryBackend
from mirage.types import ConsistencyPolicy, MountMode, SyncPolicy
from mirage.workspace import Workspace

def _make_consistency_ws(consistency=ConsistencyPolicy.NONE, ttl_seconds=300):
    backend = MemoryBackend()
    backend.put("/file.txt", b"original")
    ws = Workspace(
        {"/data/": backend},
        mode=MountMode.WRITE,
        consistency=consistency,
        ttl_seconds=ttl_seconds,
    )
    return ws, backend

def _make_ws(mode=MountMode.WRITE, sync_policy=SyncPolicy.STAGED):
    backend = MemoryBackend()
    return Workspace({"/data/": backend}, mode=mode,
                     sync_policy=sync_policy), backend

def test_consistency_none_caches_forever():
    ws, backend = _make_consistency_ws(ConsistencyPolicy.NONE)
    assert ws.cat("/data/file.txt") == b"original"
    backend._files["/file.txt"] = b"updated"
    backend._modified["/file.txt"] = datetime.now(timezone.utc).isoformat()
    assert ws.cat("/data/file.txt") == b"original"

def test_consistency_always_refetches():
    ws, backend = _make_consistency_ws(ConsistencyPolicy.ALWAYS)
    assert ws.cat("/data/file.txt") == b"original"
    backend._files["/file.txt"] = b"updated"
    backend._modified["/file.txt"] = datetime.now(timezone.utc).isoformat()
    assert ws.cat("/data/file.txt") == b"updated"

def test_consistency_ttl_fresh_within_window():
    ws, backend = _make_consistency_ws(ConsistencyPolicy.TTL, ttl_seconds=300)
    assert ws.cat("/data/file.txt") == b"original"
    backend._files["/file.txt"] = b"updated"
    backend._modified["/file.txt"] = datetime.now(timezone.utc).isoformat()
    assert ws.cat("/data/file.txt") == b"original"

def test_consistency_ttl_stale_after_expiry():
    ws, backend = _make_consistency_ws(ConsistencyPolicy.TTL, ttl_seconds=10)
    assert ws.cat("/data/file.txt") == b"original"
    backend._files["/file.txt"] = b"updated"
    backend._modified["/file.txt"] = datetime.now(timezone.utc).isoformat()
    entry = ws._cache.get("/data/file.txt")
    entry.cached_at -= 20
    assert ws.cat("/data/file.txt") == b"updated"

def test_consistency_lazy_unchanged():
    ws, backend = _make_consistency_ws(ConsistencyPolicy.LAZY)
    assert ws.cat("/data/file.txt") == b"original"
    assert ws.cat("/data/file.txt") == b"original"

def test_consistency_lazy_changed():
    ws, backend = _make_consistency_ws(ConsistencyPolicy.LAZY)
    assert ws.cat("/data/file.txt") == b"original"
    backend._files["/file.txt"] = b"updated-content"
    backend._modified["/file.txt"] = datetime.now(timezone.utc).isoformat()
    assert ws.cat("/data/file.txt") == b"updated-content"

def test_dirty_file_skips_revalidation():
    ws, backend = _make_consistency_ws(ConsistencyPolicy.ALWAYS)
    ws.write("/data/file.txt", b"local-edit")
    backend._files["/file.txt"] = b"remote-change"
    assert ws.cat("/data/file.txt") == b"local-edit"

def test_per_mount_consistency():
    be_none = MemoryBackend()
    be_none.put("/f.txt", b"v1")
    be_always = MemoryBackend()
    be_always.put("/f.txt", b"v1")
    ws = Workspace(
        {
            "/a/": be_none,
            "/b/": (be_always, MountMode.READ, ConsistencyPolicy.ALWAYS),
        },
        mode=MountMode.READ,
        consistency=ConsistencyPolicy.NONE,
    )
    assert ws.cat("/a/f.txt") == b"v1"
    assert ws.cat("/b/f.txt") == b"v1"
    be_none._files["/f.txt"] = b"v2"
    be_none._modified["/f.txt"] = datetime.now(timezone.utc).isoformat()
    be_always._files["/f.txt"] = b"v2"
    be_always._modified["/f.txt"] = datetime.now(timezone.utc).isoformat()
    assert ws.cat("/a/f.txt") == b"v1"
    assert ws.cat("/b/f.txt") == b"v2"

def test_write_and_cat():
    ws, _ = _make_ws()
    ws.write("/data/hello.txt", b"hello world")
    assert ws.cat("/data/hello.txt") == b"hello world"

def test_write_marks_dirty():
    ws, _ = _make_ws()
    ws.write("/data/file.txt", b"x")
    assert any(i.virtual_path == "/data/file.txt" and i.dirty
               for i in ws.status())

def test_rm_staged_only():
    ws, _ = _make_ws()
    ws.write("/data/new.txt", b"staged")
    ws.rm("/data/new.txt")
    assert not ws.exists("/data/new.txt")

def test_rm_persisted():
    ws, backend = _make_ws()
    backend.put("/remote.txt", b"remote")
    ws.rm("/data/remote.txt")
    assert not ws.exists("/data/remote.txt")

def test_mv_staged_only():
    ws, _ = _make_ws()
    ws.write("/data/a.txt", b"content")
    ws.mv("/data/a.txt", "/data/b.txt")
    assert ws.cat("/data/b.txt") == b"content"
    assert not ws.exists("/data/a.txt")

def test_cp_creates_copy():
    ws, backend = _make_ws()
    backend.put("/src.txt", b"original")
    ws.cp("/data/src.txt", "/data/copy.txt")
    assert ws.cat("/data/copy.txt") == b"original"

def test_write_read_only_raises():
    ws, _ = _make_ws(mode=MountMode.READ)
    with pytest.raises(PermissionError):
        ws.write("/data/x.txt", b"x")

def test_sync_staged_writes_to_backend():
    ws, backend = _make_ws(sync_policy=SyncPolicy.STAGED)
    ws.write("/data/out.txt", b"synced")
    results = ws.sync()
    assert any(r.path == "/data/out.txt" and r.status == "ok" for r in results)
    assert backend.cat("/out.txt") == b"synced"

def test_sync_immediate_writes_through():
    ws, backend = _make_ws(sync_policy=SyncPolicy.IMMEDIATE)
    ws.write("/data/live.txt", b"live data")
    assert backend.cat("/live.txt") == b"live data"
    assert not any(i.dirty for i in ws.status())

def test_reset_clears_dirty():
    ws, _ = _make_ws()
    ws.write("/data/x.txt", b"x")
    ws.reset()
    assert ws.status() == []

def test_stat_staged_file():
    ws, _ = _make_ws()
    ws.write("/data/stat.txt", b"hello")
    stat = ws.stat("/data/stat.txt")
    assert stat.size == 5

def test_exists_staged_file():
    ws, _ = _make_ws()
    ws.write("/data/exists.txt", b"y")
    assert ws.exists("/data/exists.txt")

def test_lock_paths_blocks_write():
    ws = Workspace({"/data/": MemoryBackend()},
                   mode=MountMode.WRITE,
                   sync_policy=SyncPolicy.IMMEDIATE)
    ws.write("/data/file.txt", b"hello")
    with ws.lock_paths(["/data/file.txt"]):
        with pytest.raises(PermissionError, match="locked"):
            ws.write("/data/file.txt", b"world")

def test_lock_paths_unlocks_on_exit():
    ws = Workspace({"/data/": MemoryBackend()},
                   mode=MountMode.WRITE,
                   sync_policy=SyncPolicy.IMMEDIATE)
    ws.write("/data/file.txt", b"hello")
    with ws.lock_paths(["/data/file.txt"]):
        pass
    ws.write("/data/file.txt", b"world")
    assert ws.cat("/data/file.txt") == b"world"

def test_lock_paths_unlocks_on_exception():
    ws = Workspace({"/data/": MemoryBackend()},
                   mode=MountMode.WRITE,
                   sync_policy=SyncPolicy.IMMEDIATE)
    ws.write("/data/file.txt", b"hello")
    with pytest.raises(RuntimeError):
        with ws.lock_paths(["/data/file.txt"]):
            raise RuntimeError("boom")
    ws.write("/data/file.txt", b"world")
    assert ws.cat("/data/file.txt") == b"world"

def test_lock_allows_read():
    ws = Workspace({"/data/": MemoryBackend()},
                   mode=MountMode.WRITE,
                   sync_policy=SyncPolicy.IMMEDIATE)
    ws.write("/data/file.txt", b"hello")
    with ws.lock_paths(["/data/file.txt"]):
        assert ws.cat("/data/file.txt") == b"hello"

def test_lock_multiple_paths():
    ws = Workspace({"/data/": MemoryBackend()},
                   mode=MountMode.WRITE,
                   sync_policy=SyncPolicy.IMMEDIATE)
    ws.write("/data/a.txt", b"a")
    ws.write("/data/b.txt", b"b")
    with ws.lock_paths(["/data/a.txt", "/data/b.txt"]):
        with pytest.raises(PermissionError, match="locked"):
            ws.write("/data/a.txt", b"x")
        with pytest.raises(PermissionError, match="locked"):
            ws.write("/data/b.txt", b"x")

def test_unlock_paths_force():
    ws = Workspace({"/data/": MemoryBackend()},
                   mode=MountMode.WRITE,
                   sync_policy=SyncPolicy.IMMEDIATE)
    ws.write("/data/file.txt", b"hello")
    ws._lock_path("/data/file.txt")
    with pytest.raises(PermissionError, match="locked"):
        ws.write("/data/file.txt", b"world")
    ws.unlock_paths(["/data/file.txt"])
    ws.write("/data/file.txt", b"world")

-    ws, backend = _make_consistency_ws(ConsistencyPolicy.NONE)

+    ws, backend = _make_consistency_ws(ConsistencyPolicy.CACHED)

-        consistency=ConsistencyPolicy.NONE,

+        consistency=ConsistencyPolicy.CACHED,


Need to read workspace/__init__.py first:


     1→from __future__ import annotations
     2→
     3→import time
     4→
     5→from mirage.backend.base import BaseBackend
     6→from mirage.backend.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→

from __future__ import annotations

import time

from mirage.backend.base import BaseBackend
from mirage.backend.filetype import guess_type
from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
                          SyncPolicy, SyncResult, TransferStats)
from mirage.workspace.cache import LRUCache
from mirage.workspace.cache.policy import is_fresh
from mirage.workspace.mount import MountRegistry
from mirage.workspace.sync import DirtyTracker

class _PathLock:

    def __init__(self, ws: Workspace, paths: list[str]) -> None:
        self._ws = ws
        self._paths = paths

    def __enter__(self) -> _PathLock:
        return self

    def __exit__(self, *_: object) -> None:
        self._ws.unlock_paths(self._paths)

class Workspace:
    """
    Unified virtual filesystem over heterogeneous backends.

    Mounts backends at virtual path prefixes and provides a command-level API
    (cat, ls, write, cp, mv, rm, ...) with transparent caching
    and write staging.

    Example::

        ws = Workspace(
            {"/data/": S3Backend(S3Config(bucket="my-bucket")),
             "/tmp/":  MemoryBackend()},
            cache_limit="256MB",
            sync_policy=SyncPolicy.STAGED,
        )
        content = ws.cat("/data/reports/q1.json")
        ws.write("/tmp/summary.txt", b"hello")
        ws.sync()
    """

    def __init__(
        self,
        backends: dict[str, BaseBackend | tuple],
        cache_limit: str | int = "512MB",
        sync_policy: SyncPolicy = SyncPolicy.STAGED,
        mode: MountMode = MountMode.READ,
        consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,
        ttl_seconds: int = 300,
    ) -> None:
        self._registry = MountRegistry()
        self._cache = LRUCache(cache_limit)
        self._tracker = DirtyTracker()
        self._sync_policy = sync_policy
        self._consistency = consistency
        self._ttl_seconds = ttl_seconds
        self._locked_paths: set[str] = set()
        self._transfer_stats: dict[str, TransferStats] = {}
        self._created_at: float = time.monotonic()

        for prefix, value in backends.items():
            if isinstance(value, tuple) and len(value) == 3:
                backend, mount_mode, mount_consistency = value
            elif isinstance(value, tuple):
                backend, mount_mode = value
                mount_consistency = None
            else:
                backend = value
                mount_mode = mode
                mount_consistency = None
            self._registry.mount(prefix, backend, mount_mode,
                                 mount_consistency)

    # ── mount management ────────────────────────────────────────────────────

    def mount(self,
              prefix: str,
              backend: BaseBackend,
              mode: MountMode = MountMode.READ,
              consistency: ConsistencyPolicy | None = None) -> None:
        self._registry.mount(prefix, backend, mode, consistency)

    def backend(self, path: str) -> BaseBackend:
        backend, _, _ = self._registry.resolve(path)
        return backend

    # ── read ops ────────────────────────────────────────────────────────────

    def cat(self, path: str) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data

        entry = self._cache.get(path)
        remote_stat = None
        if entry is not None:
            consistency = self._effective_consistency(path)
            backend, bp, _ = self._registry.resolve(path)
            fresh, remote_stat = is_fresh(entry, consistency,
                                          self._ttl_seconds, backend, bp)
            if fresh:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data
        else:
            backend, bp, _ = self._registry.resolve(path)

        t0 = time.monotonic()
        data = backend.cat(bp)
        duration_ms = int((time.monotonic() - t0) * 1000)
        self._record_transfer(path,
                              "read",
                              len(data),
                              duration_ms,
                              cache_hit=False)
        if remote_stat is None:
            remote_stat = backend.stat(bp)
        self._cache.put(path,
                        data,
                        etag=remote_stat.extra.get("etag"),
                        modified=remote_stat.modified,
                        size=remote_stat.size)
        self._tracker.on_read(path, data, self._mount_prefix(path))
        return data

    def ls(self, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        prefix = self._mount_prefix(path).rstrip("/")
        backend_ok = False
        try:
            backend_entries = backend.ls(bp)
            backend_ok = True
        except FileNotFoundError:
            backend_entries = []
        result: set[str] = {
            prefix + "/" + e.lstrip("/")
            for e in backend_entries
        }
        norm_path = "/" + path.strip("/")
        dir_prefix = norm_path.rstrip("/") + "/"
        has_staged_children = False
        for inode in self._tracker.dirty():
            vp = inode.virtual_path
            if not vp.startswith(dir_prefix):
                continue
            if inode.checksum is None:
                result.discard(vp)
                continue
            has_staged_children = True
            remainder = vp[len(dir_prefix):]
            first = remainder.split("/")[0]
            if not first:
                continue
            if "/" in remainder:
                result.add(dir_prefix + first + "/")
            else:
                result.add(dir_prefix + first)
        if not backend_ok and not has_staged_children:
            raise FileNotFoundError(path)
        return sorted(result)

    def ls_l(self, path: str) -> list[FileStat]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.ls_l(bp)

    def stat(self, path: str) -> FileStat:
        # For staged writes, build FileStat from the tracker rather than
        # hitting the backend (which hasn't received the write yet).
        inode = self._tracker.get(path)
        if inode and inode.dirty and inode.checksum is not None:
            entry = self._cache.get(path)
            size = len(entry.data) if entry is not None else inode.size
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1],
                size=size,
                type=guess_type(path),
            )
        backend, bp, _ = self._registry.resolve(path)
        return backend.stat(bp)

    def exists(self, path: str) -> bool:
        try:
            self.stat(path)
            return True
        except (FileNotFoundError, ValueError):
            return False

    def head(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.head(bp, lines)

    def tail(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.tail(bp, lines)

    def read(self, path: str, offset: int, size: int) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                return entry.data[offset:offset + size]
        backend, bp, _ = self._registry.resolve(path)
        return backend.read(bp, offset, size)

    def grep(self, pattern: str, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.grep(pattern, bp)

    # ── write ops ───────────────────────────────────────────────────────────

    def write(self, path: str, data: bytes) -> None:
        self._assert_not_locked(path)
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        self._cache.put(path, data)
        self._tracker.on_write(path, data, self._mount_prefix(path))
        self._record_transfer(path, "write", len(data), 0)
        if self._sync_policy == SyncPolicy.IMMEDIATE:
            backend.put(bp, data)
            self._tracker.on_read(path, data, self._mount_prefix(path))

    def mkdir(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        backend.mkdir(bp)

    def rm(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        inode = self._tracker.get(path)
        staged_only = inode and inode.dirty and inode.remote_checksum is None
        if not staged_only:
            backend.unlink(bp)
        self._cache.invalidate(path)
        self._tracker.on_delete(path)

    def mv(self, src: str, dst: str) -> None:
        self._assert_writable(src)
        sb, sbp, _ = self._registry.resolve(src)
        db, dbp, _ = self._registry.resolve(dst)
        src_inode = self._tracker.get(src)
        staged_only = (src_inode and src_inode.dirty
                       and src_inode.remote_checksum is None)
        if staged_only:
            entry = self._cache.get(src)
            data = entry.data if entry else b""
            self._cache.put(dst, data)
            self._tracker.on_write(dst, data, self._mount_prefix(dst))
            self._cache.invalidate(src)
            self._tracker.on_delete(src)
        elif sb is db:
            sb.rename(sbp, dbp)
            self._cache.invalidate(src)
        else:
            data = sb.cat(sbp)
            db.put(dbp, data)
            sb.unlink(sbp)
            self._cache.invalidate(src)

    def cp(self, src: str, dst: str) -> None:
        self._assert_writable(dst)
        data = self.cat(src)
        db, dbp, _ = self._registry.resolve(dst)
        db.put(dbp, data)

    # ── sync lifecycle ──────────────────────────────────────────────────────

    def sync(self, path: str | None = None) -> list[SyncResult]:
        results: list[SyncResult] = []
        targets = [
            i for i in self._tracker.dirty()
            if path is None or i.virtual_path == path
        ]
        for inode in targets:
            backend, bp, _ = self._registry.resolve(inode.virtual_path)
            entry = self._cache.get(inode.virtual_path)
            data = entry.data if entry else None
            result = self._tracker.on_sync(inode.virtual_path, bp, data,
                                           backend)
            results.append(result)
        return results

    def status(self) -> list[Inode]:
        return self._tracker.status()

    def reset(self) -> None:
        self._tracker.reset()
        self._cache.clear()

    # ── path locking ─────────────────────────────────────────────────────────

    def _lock_path(self, path: str) -> None:
        self._locked_paths.add(path)

    def unlock_paths(self, paths: list[str]) -> None:
        for p in paths:
            self._locked_paths.discard(p)

    def lock_paths(self, paths: list[str]) -> _PathLock:
        for p in paths:
            self._lock_path(p)
        return _PathLock(self, paths)

    def _assert_not_locked(self, path: str) -> None:
        if path in self._locked_paths:
            raise PermissionError(
                f"path {path!r} is locked by remote execution")

    # ── helpers ─────────────────────────────────────────────────────────────

    def _mount_prefix(self, path: str) -> str:
        norm = "/" + path.strip("/")
        for m in self._registry.mounts():
            if norm == m.prefix.rstrip("/") or norm.startswith(m.prefix):
                return m.prefix
        return "/"

    def _effective_consistency(self, path: str) -> ConsistencyPolicy:
        mount = self._registry.mount_for(path)
        if mount.consistency is not None:
            return mount.consistency
        return self._consistency

    def _assert_writable(self, path: str) -> None:
        _, _, mode = self._registry.resolve(path)
        if mode == MountMode.READ:
            raise PermissionError(f"mount at {path!r} is read-only")

    def _record_transfer(self,
                         path: str,
                         op: str,
                         nbytes: int,
                         duration_ms: int,
                         cache_hit: bool | None = None) -> None:
        prefix = self._mount_prefix(path)
        if prefix not in self._transfer_stats:
            self._transfer_stats[prefix] = TransferStats()
        stats = self._transfer_stats[prefix]
        if op == "read":
            stats.bytes_read += nbytes
            stats.read_ops += 1
            stats.total_read_ms += duration_ms
            if cache_hit is True:
                stats.cache_hits += 1
            elif cache_hit is False:
                stats.cache_misses += 1
        elif op == "write":
            stats.bytes_written += nbytes
            stats.write_ops += 1
            stats.total_write_ms += duration_ms


The file /Users/agent/strukto/mirage/mirage/workspace/__init__.py has been updated. All occurrences of 'ConsistencyPolicy.NONE' were successfully replaced with 'ConsistencyPolicy.CACHED'.

from __future__ import annotations

import time

from mirage.backend.base import BaseBackend
from mirage.backend.filetype import guess_type
from mirage.types import (ConsistencyPolicy, FileStat, Inode, MountMode,
                          SyncPolicy, SyncResult, TransferStats)
from mirage.workspace.cache import LRUCache
from mirage.workspace.cache.policy import is_fresh
from mirage.workspace.mount import MountRegistry
from mirage.workspace.sync import DirtyTracker

class _PathLock:

    def __init__(self, ws: Workspace, paths: list[str]) -> None:
        self._ws = ws
        self._paths = paths

    def __enter__(self) -> _PathLock:
        return self

    def __exit__(self, *_: object) -> None:
        self._ws.unlock_paths(self._paths)

class Workspace:
    """
    Unified virtual filesystem over heterogeneous backends.

    Mounts backends at virtual path prefixes and provides a command-level API
    (cat, ls, write, cp, mv, rm, ...) with transparent caching
    and write staging.

    Example::

        ws = Workspace(
            {"/data/": S3Backend(S3Config(bucket="my-bucket")),
             "/tmp/":  MemoryBackend()},
            cache_limit="256MB",
            sync_policy=SyncPolicy.STAGED,
        )
        content = ws.cat("/data/reports/q1.json")
        ws.write("/tmp/summary.txt", b"hello")
        ws.sync()
    """

    def __init__(
        self,
        backends: dict[str, BaseBackend | tuple],
        cache_limit: str | int = "512MB",
        sync_policy: SyncPolicy = SyncPolicy.STAGED,
        mode: MountMode = MountMode.READ,
        consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,
        ttl_seconds: int = 300,
    ) -> None:
        self._registry = MountRegistry()
        self._cache = LRUCache(cache_limit)
        self._tracker = DirtyTracker()
        self._sync_policy = sync_policy
        self._consistency = consistency
        self._ttl_seconds = ttl_seconds
        self._locked_paths: set[str] = set()
        self._transfer_stats: dict[str, TransferStats] = {}
        self._created_at: float = time.monotonic()

        for prefix, value in backends.items():
            if isinstance(value, tuple) and len(value) == 3:
                backend, mount_mode, mount_consistency = value
            elif isinstance(value, tuple):
                backend, mount_mode = value
                mount_consistency = None
            else:
                backend = value
                mount_mode = mode
                mount_consistency = None
            self._registry.mount(prefix, backend, mount_mode,
                                 mount_consistency)

    # ── mount management ────────────────────────────────────────────────────

    def mount(self,
              prefix: str,
              backend: BaseBackend,
              mode: MountMode = MountMode.READ,
              consistency: ConsistencyPolicy | None = None) -> None:
        self._registry.mount(prefix, backend, mode, consistency)

    def backend(self, path: str) -> BaseBackend:
        backend, _, _ = self._registry.resolve(path)
        return backend

    # ── read ops ────────────────────────────────────────────────────────────

    def cat(self, path: str) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data

        entry = self._cache.get(path)
        remote_stat = None
        if entry is not None:
            consistency = self._effective_consistency(path)
            backend, bp, _ = self._registry.resolve(path)
            fresh, remote_stat = is_fresh(entry, consistency,
                                          self._ttl_seconds, backend, bp)
            if fresh:
                self._record_transfer(path,
                                      "read",
                                      len(entry.data),
                                      0,
                                      cache_hit=True)
                return entry.data
        else:
            backend, bp, _ = self._registry.resolve(path)

        t0 = time.monotonic()
        data = backend.cat(bp)
        duration_ms = int((time.monotonic() - t0) * 1000)
        self._record_transfer(path,
                              "read",
                              len(data),
                              duration_ms,
                              cache_hit=False)
        if remote_stat is None:
            remote_stat = backend.stat(bp)
        self._cache.put(path,
                        data,
                        etag=remote_stat.extra.get("etag"),
                        modified=remote_stat.modified,
                        size=remote_stat.size)
        self._tracker.on_read(path, data, self._mount_prefix(path))
        return data

    def ls(self, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        prefix = self._mount_prefix(path).rstrip("/")
        backend_ok = False
        try:
            backend_entries = backend.ls(bp)
            backend_ok = True
        except FileNotFoundError:
            backend_entries = []
        result: set[str] = {
            prefix + "/" + e.lstrip("/")
            for e in backend_entries
        }
        norm_path = "/" + path.strip("/")
        dir_prefix = norm_path.rstrip("/") + "/"
        has_staged_children = False
        for inode in self._tracker.dirty():
            vp = inode.virtual_path
            if not vp.startswith(dir_prefix):
                continue
            if inode.checksum is None:
                result.discard(vp)
                continue
            has_staged_children = True
            remainder = vp[len(dir_prefix):]
            first = remainder.split("/")[0]
            if not first:
                continue
            if "/" in remainder:
                result.add(dir_prefix + first + "/")
            else:
                result.add(dir_prefix + first)
        if not backend_ok and not has_staged_children:
            raise FileNotFoundError(path)
        return sorted(result)

    def ls_l(self, path: str) -> list[FileStat]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.ls_l(bp)

    def stat(self, path: str) -> FileStat:
        # For staged writes, build FileStat from the tracker rather than
        # hitting the backend (which hasn't received the write yet).
        inode = self._tracker.get(path)
        if inode and inode.dirty and inode.checksum is not None:
            entry = self._cache.get(path)
            size = len(entry.data) if entry is not None else inode.size
            return FileStat(
                name=path.rstrip("/").rsplit("/", 1)[-1],
                size=size,
                type=guess_type(path),
            )
        backend, bp, _ = self._registry.resolve(path)
        return backend.stat(bp)

    def exists(self, path: str) -> bool:
        try:
            self.stat(path)
            return True
        except (FileNotFoundError, ValueError):
            return False

    def head(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.head(bp, lines)

    def tail(self, path: str, lines: int = 10) -> bytes:
        backend, bp, _ = self._registry.resolve(path)
        return backend.tail(bp, lines)

    def read(self, path: str, offset: int, size: int) -> bytes:
        inode = self._tracker.get(path)
        if inode and inode.dirty:
            entry = self._cache.get(path)
            if entry is not None:
                return entry.data[offset:offset + size]
        backend, bp, _ = self._registry.resolve(path)
        return backend.read(bp, offset, size)

    def grep(self, pattern: str, path: str) -> list[str]:
        backend, bp, _ = self._registry.resolve(path)
        return backend.grep(pattern, bp)

    # ── write ops ───────────────────────────────────────────────────────────

    def write(self, path: str, data: bytes) -> None:
        self._assert_not_locked(path)
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        self._cache.put(path, data)
        self._tracker.on_write(path, data, self._mount_prefix(path))
        self._record_transfer(path, "write", len(data), 0)
        if self._sync_policy == SyncPolicy.IMMEDIATE:
            backend.put(bp, data)
            self._tracker.on_read(path, data, self._mount_prefix(path))

    def mkdir(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        backend.mkdir(bp)

    def rm(self, path: str) -> None:
        self._assert_writable(path)
        backend, bp, _ = self._registry.resolve(path)
        inode = self._tracker.get(path)
        staged_only = inode and inode.dirty and inode.remote_checksum is None
        if not staged_only:
            backend.unlink(bp)
        self._cache.invalidate(path)
        self._tracker.on_delete(path)

    def mv(self, src: str, dst: str) -> None:
        self._assert_writable(src)
        sb, sbp, _ = self._registry.resolve(src)
        db, dbp, _ = self._registry.resolve(dst)
        src_inode = self._tracker.get(src)
        staged_only = (src_inode and src_inode.dirty
                       and src_inode.remote_checksum is None)
        if staged_only:
            entry = self._cache.get(src)
            data = entry.data if entry else b""
            self._cache.put(dst, data)
            self._tracker.on_write(dst, data, self._mount_prefix(dst))
            self._cache.invalidate(src)
            self._tracker.on_delete(src)
        elif sb is db:
            sb.rename(sbp, dbp)
            self._cache.invalidate(src)
        else:
            data = sb.cat(sbp)
            db.put(dbp, data)
            sb.unlink(sbp)
            self._cache.invalidate(src)

    def cp(self, src: str, dst: str) -> None:
        self._assert_writable(dst)
        data = self.cat(src)
        db, dbp, _ = self._registry.resolve(dst)
        db.put(dbp, data)

    # ── sync lifecycle ──────────────────────────────────────────────────────

    def sync(self, path: str | None = None) -> list[SyncResult]:
        results: list[SyncResult] = []
        targets = [
            i for i in self._tracker.dirty()
            if path is None or i.virtual_path == path
        ]
        for inode in targets:
            backend, bp, _ = self._registry.resolve(inode.virtual_path)
            entry = self._cache.get(inode.virtual_path)
            data = entry.data if entry else None
            result = self._tracker.on_sync(inode.virtual_path, bp, data,
                                           backend)
            results.append(result)
        return results

    def status(self) -> list[Inode]:
        return self._tracker.status()

    def reset(self) -> None:
        self._tracker.reset()
        self._cache.clear()

    # ── path locking ─────────────────────────────────────────────────────────

    def _lock_path(self, path: str) -> None:
        self._locked_paths.add(path)

    def unlock_paths(self, paths: list[str]) -> None:
        for p in paths:
            self._locked_paths.discard(p)

    def lock_paths(self, paths: list[str]) -> _PathLock:
        for p in paths:
            self._lock_path(p)
        return _PathLock(self, paths)

    def _assert_not_locked(self, path: str) -> None:
        if path in self._locked_paths:
            raise PermissionError(
                f"path {path!r} is locked by remote execution")

    # ── helpers ─────────────────────────────────────────────────────────────

    def _mount_prefix(self, path: str) -> str:
        norm = "/" + path.strip("/")
        for m in self._registry.mounts():
            if norm == m.prefix.rstrip("/") or norm.startswith(m.prefix):
                return m.prefix
        return "/"

    def _effective_consistency(self, path: str) -> ConsistencyPolicy:
        mount = self._registry.mount_for(path)
        if mount.consistency is not None:
            return mount.consistency
        return self._consistency

    def _assert_writable(self, path: str) -> None:
        _, _, mode = self._registry.resolve(path)
        if mode == MountMode.READ:
            raise PermissionError(f"mount at {path!r} is read-only")

    def _record_transfer(self,
                         path: str,
                         op: str,
                         nbytes: int,
                         duration_ms: int,
                         cache_hit: bool | None = None) -> None:
        prefix = self._mount_prefix(path)
        if prefix not in self._transfer_stats:
            self._transfer_stats[prefix] = TransferStats()
        stats = self._transfer_stats[prefix]
        if op == "read":
            stats.bytes_read += nbytes
            stats.read_ops += 1
            stats.total_read_ms += duration_ms
            if cache_hit is True:
                stats.cache_hits += 1
            elif cache_hit is False:
                stats.cache_misses += 1
        elif op == "write":
            stats.bytes_written += nbytes
            stats.write_ops += 1
            stats.total_write_ms += duration_ms

-        consistency: ConsistencyPolicy = ConsistencyPolicy.NONE,

+        consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED,


Now update the remaining test files:


    30→
    31→def _fuse_defaults() -> dict:
    32→    from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
    33→    return dict(
    34→        sync_policy="staged",
    35→        mode=MountMode.READ,
    36→        consistency=ConsistencyPolicy.NONE,
    37→        ttl=300,
    38→        config=None,
    39→        runtime=RuntimeType.MEMORY,
    40→        fuse=True,
    41→        agent_id=None,
    42→    )
    43→
    44→

def _fuse_defaults() -> dict:
    from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
    return dict(
        sync_policy="staged",
        mode=MountMode.READ,
        consistency=ConsistencyPolicy.NONE,
        ttl=300,
        config=None,
        runtime=RuntimeType.MEMORY,
        fuse=True,
        agent_id=None,
    )


   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"

    assert mode == MountMode.WRITE
    assert consistency == ConsistencyPolicy.LAZY

def test_parse_mount_uri_full_default():
    backend, mode, consistency = parse_mount_uri_full("memory")
    assert isinstance(backend, MemoryBackend)
    assert mode == MountMode.READ
    assert consistency == ConsistencyPolicy.NONE

def test_load_workspace_yaml_consistency(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("consistency: lazy\n"
                        "ttl: 60\n"


The file /Users/agent/strukto/mirage/tests/cli/test_app.py has been updated. All occurrences of 'ConsistencyPolicy.NONE' were successfully replaced with 'ConsistencyPolicy.CACHED'.

import os
import subprocess
import sys
import tempfile
import time
from unittest.mock import patch

import pytest

def _run(args: list[str],
         env: dict | None = None,
         check: bool = True) -> subprocess.CompletedProcess:
    e = os.environ.copy()
    if env:
        e.update(env)
    return subprocess.run(
        [sys.executable, "-m", "mirage.cli.app"] + args,
        capture_output=True,
        text=True,
        env=e,
        check=check,
    )

def test_help():
    result = _run(["--help"], check=False)
    assert result.returncode == 0

def _fuse_defaults() -> dict:
    from mirage.types import ConsistencyPolicy, MountMode, RuntimeType
    return dict(
        sync_policy="staged",
        mode=MountMode.READ,
        consistency=ConsistencyPolicy.NONE,
        ttl=300,
        config=None,
        runtime=RuntimeType.MEMORY,
        fuse=True,
        agent_id=None,
    )

def test_mount_fuse_backend_mounts_at_root():
    """--backend memory with --fuse mounts the backend at / prefix."""
    from mirage.cli.commands.workspace import mount
    with tempfile.TemporaryDirectory() as mp:
        with patch("mirage.fuse.fs.mount") as mock_fuse, \
             patch("mirage.cli.commands.workspace._ensure_server"), \
             patch("mirage.cli.commands.workspace._client"):
            mount(backend="memory", mountpoint=mp, **_fuse_defaults())
            ws = mock_fuse.call_args.kwargs["fs"].ws
            prefixes = [m.prefix for m in ws._registry._mounts]
            assert "/" in prefixes

def test_mount_fuse_backend_with_extra_spec():
    """--backend combined with positional prefix:uri spec and --fuse."""
    from mirage.cli.commands.workspace import mount
    with tempfile.TemporaryDirectory() as mp:
        with patch("mirage.fuse.fs.mount") as mock_fuse, \
             patch("mirage.cli.commands.workspace._ensure_server"), \
             patch("mirage.cli.commands.workspace._client"):
            mount(["memory:/data/"],
                  backend="memory",
                  mountpoint=mp,
                  **_fuse_defaults())
            ws = mock_fuse.call_args.kwargs["fs"].ws
            prefixes = [m.prefix for m in ws._registry._mounts]
            assert "/" in prefixes
            assert any(p.rstrip("/") == "/data" for p in prefixes)

def test_mount_fuse_no_backend_no_spec_raises():
    """mount --fuse with no --backend and no specs exits with error."""
    result = _run(["mount", "--fuse", "--mountpoint", "/tmp/x"], check=False)
    assert result.returncode != 0

@pytest.mark.skip(reason="requires live server")
def test_mount_exec_kill():
    tmpdir = tempfile.mkdtemp(dir="/tmp")
    env = {"MIRAGE_HOME": tmpdir}

    result = _run(["mount", "memory:/data/"], env=env)
    ws_id = result.stdout.strip()
    assert len(ws_id) == 8

    time.sleep(0.3)

    result = _run(["exec", ws_id, "ls", "/data"], env=env)
    assert result.returncode == 0

    result = _run(["workspace", "ls"], env=env)
    assert ws_id in result.stdout

    _run(["kill", ws_id], env=env)
    time.sleep(0.3)

    result = _run(["workspace", "ls"], env=env, check=False)
    assert ws_id not in result.stdout


The file /Users/agent/strukto/mirage/tests/cli/test_config.py has been updated. All occurrences of 'ConsistencyPolicy.NONE' were successfully replaced with 'ConsistencyPolicy.CACHED'.

import pytest

from mirage.backend.local.local import LocalBackend
from mirage.backend.local.memory import MemoryBackend
from mirage.cli.config import (load_workspace_yaml, load_yaml_config,
                               parse_mount_uri, parse_mount_uri_full,
                               parse_mount_uri_with_mode,
                               parse_mounts_from_cli, split_mount_spec)
from mirage.types import ConsistencyPolicy, MountMode

def test_parse_memory_uri():
    backend = parse_mount_uri("memory")
    assert isinstance(backend, MemoryBackend)

def test_parse_local_uri():
    backend = parse_mount_uri("local:./scratch")
    assert isinstance(backend, LocalBackend)

def test_parse_local_uri_relative_path():
    backend = parse_mount_uri("./scratch")
    assert isinstance(backend, LocalBackend)

def test_parse_local_uri_absolute_path():
    backend = parse_mount_uri("/tmp/mydir")
    assert isinstance(backend, LocalBackend)

def test_parse_unknown_scheme_raises():
    with pytest.raises(ValueError, match="Unknown URI scheme"):
        parse_mount_uri("memory://")

def test_parse_mount_cli_args():
    mounts = parse_mounts_from_cli(
        ["memory:/data/", "local:./scratch:/local/"])
    assert "/data/" in mounts
    assert "/local/" in mounts
    assert isinstance(mounts["/data/"], MemoryBackend)
    assert isinstance(mounts["/local/"], LocalBackend)

def test_load_yaml_config(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text(
        "mounts:\n  /data/: memory\n  /local/: local:./scratch\n"
        "sync_policy: immediate\nmode: write\n")
    cfg = load_yaml_config(str(cfg_file))
    assert "/data/" in cfg["mounts"]
    assert cfg["sync_policy"] == "immediate"
    assert cfg["mode"] == "write"

def test_parse_mount_uri_with_mode_read():
    backend, mode = parse_mount_uri_with_mode("memory@read")
    assert isinstance(backend, MemoryBackend)
    assert mode == MountMode.READ

def test_parse_mount_uri_with_mode_write():
    backend, mode = parse_mount_uri_with_mode("memory@write")
    assert isinstance(backend, MemoryBackend)
    assert mode == MountMode.WRITE

def test_parse_mount_uri_with_mode_default():
    backend, mode = parse_mount_uri_with_mode("memory")
    assert isinstance(backend, MemoryBackend)
    assert mode == MountMode.READ

def test_parse_mounts_from_cli_per_mount_mode():
    mounts = parse_mounts_from_cli(
        ["memory@write:/a/", "memory:/b/"],
        default_mode=MountMode.READ,
    )
    assert isinstance(mounts["/a/"], tuple)
    backend, mode = mounts["/a/"]
    assert isinstance(backend, MemoryBackend)
    assert mode == MountMode.WRITE
    assert isinstance(mounts["/b/"], MemoryBackend)

def test_split_mount_spec_memory():
    backend, path = split_mount_spec("memory:/data/")
    assert backend == "memory"
    assert path == "/data/"

def test_split_mount_spec_s3():
    backend, path = split_mount_spec("s3://bucket:/")
    assert backend == "s3://bucket"
    assert path == "/"

def test_split_mount_spec_with_mode():
    backend, path = split_mount_spec("memory@write:/a/")
    assert backend == "memory@write"
    assert path == "/a/"

def test_split_mount_spec_invalid():
    with pytest.raises(ValueError, match="Invalid mount spec"):
        split_mount_spec("nocolon")

def test_load_workspace_yaml_per_mount_mode(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("mounts:\n"
                        "  /data/: memory\n"
                        "  /scratch/:\n"
                        "    uri: memory\n"
                        "    mode: write\n")
    cfg = load_workspace_yaml(str(cfg_file))
    assert cfg["mounts"]["/data/"] == "memory"
    assert cfg["mounts"]["/scratch/"] == {"uri": "memory", "mode": "write"}

def test_parse_mount_uri_full_lazy():
    backend, mode, consistency = parse_mount_uri_full("memory@write#lazy")
    assert isinstance(backend, MemoryBackend)
    assert mode == MountMode.WRITE
    assert consistency == ConsistencyPolicy.LAZY

def test_parse_mount_uri_full_default():
    backend, mode, consistency = parse_mount_uri_full("memory")
    assert isinstance(backend, MemoryBackend)
    assert mode == MountMode.READ
    assert consistency == ConsistencyPolicy.NONE

def test_load_workspace_yaml_consistency(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("consistency: lazy\n"
                        "ttl: 60\n"
                        "mounts:\n"
                        "  /data/: memory\n"
                        "  /scratch/:\n"
                        "    uri: memory\n"
                        "    consistency: always\n")
    cfg = load_workspace_yaml(str(cfg_file))
    assert cfg["consistency"] == "lazy"
    assert cfg["ttl"] == 60
    assert cfg["mounts"]["/data/"] == "memory"
    assert cfg["mounts"]["/scratch/"] == {
        "uri": "memory",
        "consistency": "always",
    }

def test_load_workspace_yaml_custom_backend(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("mounts:\n"
                        "  /data/:\n"
                        "    backend: './my_backend.py:MyBackend'\n"
                        "    args:\n"
                        "      bucket: my-bucket\n"
                        "    mode: write\n"
                        "    consistency: lazy\n")
    cfg = load_workspace_yaml(str(cfg_file))
    mount = cfg["mounts"]["/data/"]
    assert mount["backend"] == "./my_backend.py:MyBackend"
    assert mount["args"] == {"bucket": "my-bucket"}
    assert mount["mode"] == "write"
    assert mount["consistency"] == "lazy"

def test_load_workspace_yaml_custom_backend_minimal(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("mounts:\n"
                        "  /data/:\n"
                        "    backend: 'mypackage:MyBackend'\n")
    cfg = load_workspace_yaml(str(cfg_file))
    mount = cfg["mounts"]["/data/"]
    assert mount == {"backend": "mypackage:MyBackend"}

def test_load_workspace_yaml_with_fuse_ops(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("mounts:\n"
                        "  /data/: memory\n"
                        "fuse_ops:\n"
                        "  '*.parquet': parquet\n"
                        "  '*.csv': csv\n")
    cfg = load_workspace_yaml(str(cfg_file))
    assert "fuse_ops" in cfg
    assert cfg["fuse_ops"]["*.parquet"] == "parquet"
    assert cfg["fuse_ops"]["*.csv"] == "csv"

def test_load_workspace_yaml_no_fuse_ops(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("mounts:\n  /data/: memory\n")
    cfg = load_workspace_yaml(str(cfg_file))
    assert "fuse_ops" not in cfg

def test_load_workspace_yaml_remote(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("runtime: remote\n"
                        "url: http://server:8000\n"
                        "ws_id: abc12345\n"
                        "mounts: {}\n")
    cfg = load_workspace_yaml(str(cfg_file))
    assert cfg["runtime"] == "remote"
    assert cfg["url"] == "http://server:8000"
    assert cfg["ws_id"] == "abc12345"

def test_load_workspace_yaml_remote_with_command_url(tmp_path):
    cfg_file = tmp_path / "ws.yaml"
    cfg_file.write_text("runtime: remote\n"
                        "url: http://data:8000\n"
                        "ws_id: abc12345\n"
                        "command_url: http://cmd:8001\n"
                        "fuse_ops_url: http://ops:8002\n"
                        "mounts: {}\n")
    cfg = load_workspace_yaml(str(cfg_file))
    assert cfg["runtime"] == "remote"
    assert cfg["url"] == "http://data:8000"
    assert cfg["command_url"] == "http://cmd:8001"
    assert cfg["fuse_ops_url"] == "http://ops:8002"

-    assert consistency == ConsistencyPolicy.NONE

+    assert consistency == ConsistencyPolicy.CACHED


Also need to update the `_CONSISTENCY_SUFFIXES` in config.py since the suffix `#none` maps to `ConsistencyPolicy("none")` but the enum value is now `"cached"`:


    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.CACHED,
   107→) -> tuple[BaseBackend, MountMode, ConsistencyPolicy]:
   108→    consistency = default_consistency
   109→    for suffix in _CONSISTENCY_SUFFIXES:
   110→        if uri.endswith(suffix):
   111→            consistency = ConsistencyPolicy(suffix[1:])
   112→            uri = uri[:-len(suffix)]
   113→            break
   114→    backend, mode = parse_mount_uri_with_mode(uri, default_mode)
   115→    return backend, mode, consistency
   116→
   117→

_CONSISTENCY_SUFFIXES = ("#none", "#lazy", "#ttl", "#always")

def parse_mount_uri_full(
    uri: str,
    default_mode: MountMode = MountMode.READ,
    default_consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED,
) -> tuple[BaseBackend, MountMode, ConsistencyPolicy]:
    consistency = default_consistency
    for suffix in _CONSISTENCY_SUFFIXES:
        if uri.endswith(suffix):
            consistency = ConsistencyPolicy(suffix[1:])
            uri = uri[:-len(suffix)]
            break
    backend, mode = parse_mount_uri_with_mode(uri, default_mode)
    return backend, mode, consistency


The `#none` suffix now needs to map to `"cached"` since the enum value changed. Let me update:


Edit

_CONSISTENCY_SUFFIXES = ("#none", "#lazy", "#ttl", "#always")

_CONSISTENCY_SUFFIXES = ("#cached", "#lazy", "#ttl", "#always")


The file /Users/agent/strukto/mirage/mirage/cli/config.py has been updated successfully.

import os

import yaml

from mirage.backend.base import BaseBackend
from mirage.backend.local.local import LocalBackend
from mirage.backend.local.memory import MemoryBackend
from mirage.types import BackendType, ConsistencyPolicy, MountMode

def _build_backend(type_name: str, connection: str) -> BaseBackend:
    if type_name == BackendType.MEMORY:
        return MemoryBackend()
    if type_name == BackendType.LOCAL:
        return LocalBackend(root=os.path.abspath(connection))
    if type_name == BackendType.S3:
        from mirage.backend.object_storage.s3 import S3Backend, S3Config
        rest = connection[len("s3://"):]
        bucket = rest.split("/", 1)[0]
        return S3Backend(S3Config(bucket=bucket))
    if type_name == BackendType.R2:
        from mirage.backend.object_storage.r2 import R2Backend, R2Config
        rest = connection[len("r2://"):]
        parts = rest.split("/", 1)
        return R2Backend(
            R2Config(
                account_id=parts[0],
                bucket=parts[1] if len(parts) > 1 else "",
                access_key_id=os.environ.get("R2_ACCESS_KEY_ID", ""),
                secret_access_key=os.environ.get("R2_SECRET_ACCESS_KEY", ""),
            ))
    if type_name in (BackendType.HTTP, BackendType.HTTPS):
        from mirage.backend.network.http import HTTPBackend, HTTPConfig
        return HTTPBackend(HTTPConfig(base_url=connection))
    raise ValueError(f"Unknown backend type: {type_name!r}. "
                     "Use built-ins: memory, local, s3, r2, http")

def parse_mount_uri(uri: str) -> BaseBackend:
    if uri == BackendType.MEMORY:
        return MemoryBackend()
    if uri == BackendType.LOCAL:
        return LocalBackend(root=os.path.abspath("."))

    colon = uri.find(":")
    if colon > 0 and "//" not in uri[:colon + 3]:
        return _build_backend(uri[:colon], uri[colon + 1:])

    if uri.startswith("s3://"):
        return _build_backend("s3", uri)
    if uri.startswith("r2://"):
        return _build_backend("r2", uri)
    if uri.startswith("http://") or uri.startswith("https://"):
        return _build_backend("http", uri)

    if "://" in uri:
        scheme = uri.split("://")[0]
        raise ValueError(
            f"Unknown URI scheme {scheme!r}. "
            "Accepted: s3://, r2://, https://. "
            "For simple backends use: memory, local, local:./path")
    return LocalBackend(root=os.path.abspath(uri))

def split_mount_spec(spec: str) -> tuple[str, str]:
    """Split 'backend:/path' into (backend, path).

    Args:
        spec (str): Mount spec like 'memory:/data/' or 's3://bucket:/data/'.

    Returns:
        tuple[str, str]: (backend, mount_path) pair.
    """
    i = len(spec) - 1
    while i >= 0:
        if spec[i] == ":" and i + 1 < len(spec) and spec[i + 1] == "/":
            if i >= 2 and spec[i - 1:i + 2] == "://":
                i -= 1
                continue
            return spec[:i], spec[i + 1:]
        i -= 1
    raise ValueError(
        f"Invalid mount spec: {spec!r}. "
        "Use format: backend:/path (e.g. memory:/data/, s3://bucket:/)")

_MODE_SUFFIXES = ("@read", "@write", "@exec")

def parse_mount_uri_with_mode(
    uri: str,
    default_mode: MountMode = MountMode.READ,
) -> tuple[BaseBackend, MountMode]:
    for suffix in _MODE_SUFFIXES:
        if uri.endswith(suffix):
            return parse_mount_uri(uri[:-len(suffix)]), MountMode(suffix[1:])
    return parse_mount_uri(uri), default_mode

_CONSISTENCY_SUFFIXES = ("#none", "#lazy", "#ttl", "#always")

def parse_mount_uri_full(
    uri: str,
    default_mode: MountMode = MountMode.READ,
    default_consistency: ConsistencyPolicy = ConsistencyPolicy.CACHED,
) -> tuple[BaseBackend, MountMode, ConsistencyPolicy]:
    consistency = default_consistency
    for suffix in _CONSISTENCY_SUFFIXES:
        if uri.endswith(suffix):
            consistency = ConsistencyPolicy(suffix[1:])
            uri = uri[:-len(suffix)]
            break
    backend, mode = parse_mount_uri_with_mode(uri, default_mode)
    return backend, mode, consistency

def parse_mounts_from_cli(
    mount_specs: list[str],
    default_mode: MountMode = MountMode.READ,
) -> dict[str, BaseBackend | tuple[BaseBackend, MountMode]]:
    mounts: dict[str, BaseBackend | tuple[BaseBackend, MountMode]] = {}
    for spec in mount_specs:
        uri, prefix = split_mount_spec(spec)
        backend, mode = parse_mount_uri_with_mode(uri, default_mode)
        if mode != default_mode:
            mounts[prefix] = (backend, mode)
        else:
            mounts[prefix] = backend
    return mounts

def load_yaml_config(path: str) -> dict:
    with open(path) as f:
        return yaml.safe_load(f)

def load_workspace_yaml(path: str) -> dict:
    with open(path) as f:
        data = yaml.safe_load(f)
    mounts_raw = data.get("mounts", {})
    mounts: dict[str, str | dict] = {}
    for prefix, value in mounts_raw.items():
        if isinstance(value, str):
            mounts[prefix] = value
        elif isinstance(value, dict):
            backend_spec = value.get("backend")
            if backend_spec:
                entry: dict[str, str | dict] = {"backend": backend_spec}
                if value.get("args"):
                    entry["args"] = value["args"]
                if value.get("mode"):
                    entry["mode"] = value["mode"]
                if value.get("consistency"):
                    entry["consistency"] = value["consistency"]
                mounts[prefix] = entry
            else:
                uri = value.get("uri", "")
                mount_mode = value.get("mode")
                mount_consistency = value.get("consistency")
                entry_uri: dict[str, str] = {"uri": uri}
                if mount_mode:
                    entry_uri["mode"] = mount_mode
                if mount_consistency:
                    entry_uri["consistency"] = mount_consistency
                if len(entry_uri) == 1:
                    mounts[prefix] = uri
                else:
                    mounts[prefix] = entry_uri
    result: dict = {"mounts": mounts, "commands": data.get("commands", {})}
    if "consistency" in data:
        result["consistency"] = data["consistency"]
    if "ttl" in data:
        result["ttl"] = data["ttl"]
    if "agent_id" in data:
        result["agent_id"] = data["agent_id"]
    fuse_ops = data.get("fuse_ops", {})
    if fuse_ops:
        result["fuse_ops"] = fuse_ops
    for key in ("runtime", "url", "ws_id", "command_url", "fuse_ops_url"):
        if key in data:
            result[key] = data[key]
    return result

-_CONSISTENCY_SUFFIXES = ("#none", "#lazy", "#ttl", "#always")

+_CONSISTENCY_SUFFIXES = ("#cached", "#lazy", "#ttl", "#always")


Also update the help text that references `#none`:


    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.CACHED),

def mount(
    mount_specs: Annotated[
        list[str] | None,
        typer.Argument(help="Mount specs: backend:/path "
                       "(e.g. memory:/data/, s3://bucket:/). "
                       "Append #none|#lazy|#ttl|#always for "
                       "consistency, @read|@write|@exec for mode.")] = None,
    sync_policy: str = typer.Option("staged", help="staged or immediate"),
    mode: MountMode = typer.Option(MountMode.WRITE, help="read or write"),
    consistency: ConsistencyPolicy = typer.Option(ConsistencyPolicy.CACHED),


"Append #cached|#lazy|#ttl|#always for "