feat(py): mirage mcp over stdio, and invert the mypy allowlist

Two cleanup-plan items, both Python-side.

Item 29 -- `mirage mcp`. TypeScript shipped a six-tool stdio MCP server;
Python had none, so a pip-install user could not point Cursor or Claude
Desktop at a workspace and `mirage --help` differed by distribution.
Adding the entry point alone would have duplicated the tools, because
this side kept them private inside the Claude Agent SDK integration, so
the shared layer comes first:

  - agents/tool_descriptions.py -- the six strings, one copy.
  - agents/tool_operations.py -- MirageToolOperations, lifted out of the
    SDK server's private _MirageTools.
  - agents/file_version.py -- stale-write protection, which this side
    lacked entirely. TS stamps stored bytes; here the stamp covers the
    rendered bytes, because this read tool has always rendered and an
    edit must search what the agent was actually shown.
  - agents/mcp/server.py + cli/mcp.py -- the server and `mirage mcp`.
  - server/workspace_config.py -- config discovery (candidates, env
    names, walk up from cwd), which Python had nowhere, so every entry
    point had to be handed an explicit path.

The server is the low-level MCP Server rather than FastMCP: FastMCP does
not forward a version, and TS advertises one. Handlers are bound methods,
not decorated closures, so nothing nests.

Item 28 -- the mypy allowlist. 54 modules opted *in* to annotation
checking against 1826, so the default was unchecked and every new file
joined the unchecked side. The default is now strict, with a list of
what is not yet annotated that only shrinks. 166 annotations cleared
along the way; the remainder is named module by module.

Two real defects surfaced by the annotations, neither of them typing:

  - Workspace._original_open / _original_os were invented by assignment
    in lifecycle.patch_process, so unpatch without a patch raised
    AttributeError. Declared, and the restore is guarded.
  - sed_generic declared a non-optional writer while its own docstring
    and its `write_bytes is None` branch said otherwise; the builder
    passes None whenever the backend cannot write.

Tests keep the PathSpec rule instead of full strict: measured, full
strict on python/tests is 2374 errors, of which 634 are `str` where a
pydantic field declares SecretStr -- which pydantic coerces at runtime --
and most of the rest is the monkeypatched-fake pattern CLAUDE.md
sanctions. The rule that is violated for real is PathSpec, 19 times, and
scripts/check_test_pathspec.py now holds that line. One of the 19 was a
latent AttributeError: tests/e2e passes a str to s3 write_bytes, which
reads .mount_path, and the test skips without a live versioned bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bytecii
2026-08-16 03:33:16 -07:00
parent 283da2c8fb
commit 3293cb5ff4
114 changed files with 2487 additions and 406 deletions
+7 -1
View File
@@ -160,5 +160,11 @@ repos:
name: Type check (mypy)
entry: bash -c 'cd python && uv run mypy'
language: system
files: ^python/(mirage/.*\.py|pyproject\.toml)$
files: ^python/(mirage/.*\.py|tests/.*\.py|pyproject\.toml)$
pass_filenames: false
- id: py-test-pathspec
name: PathSpec discipline (tests)
entry: python3 scripts/check_test_pathspec.py
language: system
files: ^python/(mirage/.*\.py|tests/.*\.py)$
pass_filenames: false
+5 -2
View File
@@ -13,8 +13,9 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import shlex
from collections.abc import Awaitable
from posixpath import dirname
from typing import Any, Callable
from typing import Any, Callable, TypeVar
try:
from agno.tools import Toolkit
@@ -27,6 +28,8 @@ from mirage.agents.io_text import io_to_str
from mirage.bridge.sync import run_async_from_sync
from mirage.workspace.workspace import Workspace
T = TypeVar("T")
class MirageToolkit(Toolkit):
"""Agno toolkit backed by a Mirage Workspace.
@@ -55,7 +58,7 @@ class MirageToolkit(Toolkit):
async_tools=async_tools,
**kwargs)
def _run(self, coro):
def _run(self, coro: Awaitable[T]) -> T:
return run_async_from_sync(coro)
# -- execute ---------------------------------------------------------
@@ -13,35 +13,11 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
EXECUTE_DESCRIPTION = (
"Run a shell-style command on the Mirage virtual filesystem. "
"Supports cat, grep, find, head, tail, ls, wc, sort, uniq, tee, pipe, "
"and any other Unix command on mounted resources (S3, disk, RAM, etc.). "
"Files with no registered renderer, such as .parquet or .orc, read back "
"as raw bytes.")
READ_DESCRIPTION = (
"Read the contents of a file on the Mirage virtual filesystem. "
"Returns line-numbered text. "
"Optionally pass 'offset' (default 0) to start at a given line "
"and 'limit' (default 2000) to cap the number of lines returned.")
WRITE_DESCRIPTION = (
"Write content to a new file on the Mirage virtual filesystem. "
"Fails if the file already exists; use edit to modify an existing file.")
EDIT_DESCRIPTION = (
"Replace a string in an existing file on the Mirage virtual filesystem. "
"Fails if old_string is not found or appears more than once. "
"Pass replace_all=true (default false) to replace every occurrence.")
LS_DESCRIPTION = ("List files and directories at the given path "
"on the Mirage virtual filesystem.")
GREP_DESCRIPTION = (
"Search for a pattern in files on the Mirage virtual filesystem. "
"Supports regex. Searches recursively under path.")
from mirage.agents.tool_descriptions import EDIT_DESCRIPTION # yapf: disable
from mirage.agents.tool_descriptions import (EXECUTE_DESCRIPTION,
GREP_DESCRIPTION, LS_DESCRIPTION,
READ_DESCRIPTION,
WRITE_DESCRIPTION)
__all__ = [
"MIRAGE_SYSTEM_PROMPT",
+38 -93
View File
@@ -12,7 +12,6 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import shlex
from typing import Any
try:
@@ -26,128 +25,74 @@ from mirage import __version__
from mirage.agents.claude_agent_sdk.prompt import ( # yapf: disable
EDIT_DESCRIPTION, EXECUTE_DESCRIPTION, GREP_DESCRIPTION, LS_DESCRIPTION,
READ_DESCRIPTION, WRITE_DESCRIPTION)
from mirage.agents.io_text import io_to_str
from mirage.io.types import IOResult
from mirage.agents.tool_operations import (DEFAULT_READ_LIMIT,
MirageToolOperations, ToolResult)
from mirage.workspace.workspace import Workspace
def _text(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}]}
def _error(text: str) -> dict[str, Any]:
return {"content": [{"type": "text", "text": text}], "is_error": True}
def _io_to_result(io: IOResult) -> dict[str, Any]:
result = _text(io_to_str(io))
if io.exit_code != 0:
result["is_error"] = True
return result
def _to_sdk(result: ToolResult) -> dict[str, Any]:
payload: dict[str, Any] = {
"content": [{
"type": "text",
"text": result.text
}]
}
if result.is_error:
payload["is_error"] = True
return payload
class _MirageTools:
"""Unpacks the SDK's argument dicts onto the shared operations.
def __init__(self, workspace: Workspace) -> None:
self._ws = workspace
Args:
workspace (Workspace): The workspace to serve.
stale_write_protection (bool): False lets an agent overwrite a
file that changed since it read it.
"""
def __init__(self,
workspace: Workspace,
stale_write_protection: bool = True) -> None:
self._ops = MirageToolOperations(workspace, stale_write_protection)
async def execute_command(self, args: dict[str, Any]) -> dict[str, Any]:
io = await self._ws.execute(args["command"])
return _io_to_result(io)
async def _ensure_parents(self, path: str) -> None:
parts = [p for p in path.split("/") if p]
ops = self._ws.ops
current = ""
for part in parts[:-1]:
current += "/" + part
try:
await ops.mkdir(current)
except FileExistsError:
continue
return _to_sdk(await self._ops.execute(args["command"]))
async def read(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
offset = int(args.get("offset", 0))
limit = int(args.get("limit", 2000))
ops = self._ws.ops
try:
data = await ops.read(path)
except FileNotFoundError:
return _error(f"Error: file '{path}' not found")
except ValueError as exc:
return _error(f"Error: {exc}")
text = data.decode("utf-8", errors="replace")
lines = text.splitlines(keepends=True)
sliced = lines[offset:offset + limit]
numbered = [
f"{i + offset + 1:>6}\t{line}" for i, line in enumerate(sliced)
]
return _text("".join(numbered))
limit = int(args.get("limit", DEFAULT_READ_LIMIT))
return _to_sdk(await self._ops.read(args["path"], offset, limit))
async def write(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
content = args["content"]
ops = self._ws.ops
try:
await ops.stat(path)
except FileNotFoundError:
# missing file is the good path: creation may proceed
pass
else:
return _error(f"Error: file '{path}' already exists")
await self._ensure_parents(path)
data = content.encode("utf-8") if isinstance(content, str) else content
await ops.write(path, data)
return _text(f"Written: {path}")
return _to_sdk(await self._ops.write(args["path"], args["content"]))
async def edit(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
old_string = args["old_string"]
new_string = args["new_string"]
replace_all = bool(args.get("replace_all", False))
ops = self._ws.ops
try:
data = await ops.read(path)
except FileNotFoundError:
return _error(f"Error: file '{path}' not found")
content = data.decode("utf-8", errors="replace")
count = content.count(old_string)
if count == 0:
return _error(f"Error: string not found in file: '{old_string}'")
if count > 1 and not replace_all:
return _error(
f"Error: string appears {count} times. Pass replace_all=true")
new_content = content.replace(
old_string, new_string) if replace_all else content.replace(
old_string, new_string, 1)
await ops.write(path, new_content.encode("utf-8"))
occurrences = count if replace_all else 1
return _text(f"Edited: {path} ({occurrences} occurrence(s))")
return _to_sdk(await self._ops.edit(args["path"], args["old_string"],
args["new_string"], replace_all))
async def ls(self, args: dict[str, Any]) -> dict[str, Any]:
path = args["path"]
io = await self._ws.execute(f"ls {shlex.quote(path)}")
return _io_to_result(io)
return _to_sdk(await self._ops.ls(args["path"]))
async def grep(self, args: dict[str, Any]) -> dict[str, Any]:
pattern = args["pattern"]
path = args["path"]
io = await self._ws.execute(
f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}")
return _text(io_to_str(io))
return _to_sdk(await self._ops.grep(args["pattern"], args["path"]))
def MirageServer(workspace: Workspace):
def MirageServer(workspace: Workspace,
stale_write_protection: bool = True) -> Any:
"""Create an in-process Mirage server for the Claude Agent SDK.
Args:
workspace (Workspace): The workspace to serve.
stale_write_protection (bool): False lets an agent overwrite a
file that changed since it read it.
Returns:
An SDK server object to pass to ClaudeAgentOptions(mcp_servers=...).
Any: An SDK server object to pass to
ClaudeAgentOptions(mcp_servers=...).
"""
tools_impl = _MirageTools(workspace)
tools_impl = _MirageTools(workspace, stale_write_protection)
return create_sdk_mcp_server(
name="mirage",
version=__version__,
+150
View File
@@ -0,0 +1,150 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
import hashlib
from mirage.workspace.workspace import Workspace
class StaleMirageFileError(Exception):
def __init__(self, path: str) -> None:
super().__init__(f"File changed since it was last read: {path}. "
f"Read the file again before modifying it.")
self.path = path
def fingerprint(content: bytes) -> str:
"""Version stamp for one file's stored bytes.
Args:
content (bytes): The bytes to stamp.
Returns:
str: A base64url digest, matching the TypeScript tracker's stamp.
"""
digest = hashlib.sha256(content).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
class FileVersionTracker:
"""Refuses a write to a file that moved under the agent.
Two stamps are kept per path because a read and an edit are
different promises: `read` records what the agent was shown, and
`read_for_edit` records what it is about to rewrite. A plain write
is checked against the read stamp, an edit against the edit stamp.
Stamps cover the rendered bytes, which is what the read tool hands
the agent. The TypeScript tracker stamps the stored bytes instead;
here that would let `edit` search bytes the agent never saw, since
this side's read tool has always rendered.
Args:
workspace (Workspace): The workspace to read and write through.
enabled (bool): False serves every call unchecked, which is
what `mirage mcp --no-stale-write-protection` asks for.
"""
def __init__(self, workspace: Workspace, enabled: bool = True) -> None:
self._ws = workspace
self._enabled = enabled
self._read_versions: dict[str, str] = {}
self._edit_versions: dict[str, str] = {}
async def _current_version(self, path: str) -> str | None:
if not await self._ws.ops.exists(path):
return None
return fingerprint(await self._ws.ops.read(path))
async def _assert_version(self, path: str, expected: str) -> None:
if await self._current_version(path) != expected:
raise StaleMirageFileError(path)
def _record_write(self, path: str, content: str) -> None:
if not self._enabled:
return
self._read_versions[path] = fingerprint(content.encode("utf-8"))
self._edit_versions.pop(path, None)
async def read(self, path: str) -> bytes:
"""Read a file and record what the agent was shown.
Args:
path (str): Virtual path.
Returns:
bytes: The stored bytes.
"""
content = await self._ws.ops.read(path)
if self._enabled:
self._read_versions[path] = fingerprint(content)
return content
async def read_for_edit(self, path: str) -> bytes:
"""Read a file the agent is about to rewrite.
Args:
path (str): Virtual path.
Returns:
bytes: The stored bytes.
Raises:
StaleMirageFileError: The file moved since it was last read.
"""
content = await self._ws.ops.read(path)
if not self._enabled:
return content
version = fingerprint(content)
read_version = self._read_versions.get(path)
if read_version is not None and read_version != version:
raise StaleMirageFileError(path)
self._edit_versions[path] = version
return content
async def write(self, path: str, content: str) -> None:
"""Write a file, refusing if it moved since it was read.
Args:
path (str): Virtual path.
content (str): Text to write.
Raises:
StaleMirageFileError: The file moved since it was last read.
"""
if self._enabled:
read_version = self._read_versions.get(path)
if read_version is not None:
await self._assert_version(path, read_version)
await self._ws.ops.write(path, content.encode("utf-8"))
self._record_write(path, content)
async def write_edit(self, path: str, content: str) -> None:
"""Write an edit, refusing if it moved since it was read for edit.
Args:
path (str): Virtual path.
content (str): Text to write.
Raises:
StaleMirageFileError: The file moved since it was read for edit.
"""
if self._enabled:
edit_version = self._edit_versions.get(path)
if edit_version is not None:
await self._assert_version(path, edit_version)
await self._ws.ops.write(path, content.encode("utf-8"))
self._record_write(path, content)
+5 -1
View File
@@ -14,7 +14,9 @@
import base64
import shlex
from collections.abc import Awaitable
from pathlib import PurePosixPath
from typing import TypeVar
from deepagents.backends.protocol import (EditResult, ExecuteResponse,
FileData, FileDownloadResponse,
@@ -30,6 +32,8 @@ from mirage.bridge.sync import run_async_from_sync
from mirage.io.types import IOResult
from mirage.workspace.workspace import Workspace
T = TypeVar("T")
BINARY_EXTENSIONS = frozenset({
".3gpp",
".aac",
@@ -119,7 +123,7 @@ class LangchainWorkspace(SandboxBackendProtocol):
self._id = sandbox_id
self._session_id = session_id
def _run(self, coro):
def _run(self, coro: Awaitable[T]) -> T:
return run_async_from_sync(coro)
@property
+23
View File
@@ -0,0 +1,23 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.agents.mcp.server import (MirageMcpServer,
create_mirage_mcp_server,
serve_mirage_mcp)
__all__ = [
"MirageMcpServer",
"create_mirage_mcp_server",
"serve_mirage_mcp",
]
+235
View File
@@ -0,0 +1,235 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from typing import Any
from mcp.server.lowlevel import Server
from mcp.server.stdio import stdio_server
from mcp.types import CallToolResult, TextContent, Tool, ToolAnnotations
from mirage import __version__
from mirage.agents.tool_descriptions import EDIT_DESCRIPTION # yapf: disable
from mirage.agents.tool_descriptions import (EXECUTE_DESCRIPTION,
GREP_DESCRIPTION, LS_DESCRIPTION,
READ_DESCRIPTION,
WRITE_DESCRIPTION)
from mirage.agents.tool_operations import (DEFAULT_READ_LIMIT,
MirageToolOperations, ToolResult)
from mirage.workspace.workspace import Workspace
READ_ONLY = ToolAnnotations(readOnlyHint=True)
TOOLS = [
Tool(name="execute_command",
description=EXECUTE_DESCRIPTION,
inputSchema={
"type": "object",
"properties": {
"command": {
"type": "string"
}
},
"required": ["command"],
}),
Tool(name="read",
description=READ_DESCRIPTION,
annotations=READ_ONLY,
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string"
},
"offset": {
"type": "integer",
"minimum": 0
},
"limit": {
"type": "integer",
"minimum": 1
},
},
"required": ["path"],
}),
Tool(name="write",
description=WRITE_DESCRIPTION,
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string"
},
"content": {
"type": "string"
},
},
"required": ["path", "content"],
}),
Tool(name="edit",
description=EDIT_DESCRIPTION,
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string"
},
"old_string": {
"type": "string"
},
"new_string": {
"type": "string"
},
"replace_all": {
"type": "boolean"
},
},
"required": ["path", "old_string", "new_string"],
}),
Tool(name="ls",
description=LS_DESCRIPTION,
annotations=READ_ONLY,
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string"
}
},
"required": ["path"],
}),
Tool(name="grep",
description=GREP_DESCRIPTION,
annotations=READ_ONLY,
inputSchema={
"type": "object",
"properties": {
"pattern": {
"type": "string"
},
"path": {
"type": "string"
},
},
"required": ["pattern", "path"],
}),
]
def _to_mcp(result: ToolResult) -> CallToolResult:
return CallToolResult(content=[TextContent(type="text", text=result.text)],
isError=result.is_error)
class MirageMcpServer:
"""Serves one workspace's six tools over the MCP protocol.
The handlers are bound methods rather than decorated closures, so
the tool table stays readable and nothing nests.
Args:
workspace (Workspace): The workspace to serve.
stale_write_protection (bool): False lets an agent overwrite a
file that changed since it read it.
name (str): Server name advertised to the client.
version (str): Server version advertised to the client.
"""
def __init__(self,
workspace: Workspace,
stale_write_protection: bool = True,
name: str = "mirage",
version: str = __version__) -> None:
self._ops = MirageToolOperations(workspace, stale_write_protection)
self.server: Server[object, object] = Server(name, version=version)
self.server.list_tools()(self.list_tools)
self.server.call_tool()(self.call_tool)
async def list_tools(self) -> list[Tool]:
"""Report the tool table.
Returns:
list[Tool]: Every tool this server serves.
"""
return list(TOOLS)
async def call_tool(self, name: str,
arguments: dict[str, Any]) -> CallToolResult:
"""Run one tool call.
Args:
name (str): The tool being called.
arguments (dict[str, Any]): Arguments, already validated
against the tool's input schema.
Returns:
CallToolResult: The tool's answer.
Raises:
ValueError: The tool name is not one this server serves.
"""
if name == "execute_command":
return _to_mcp(await self._ops.execute(arguments["command"]))
if name == "read":
return _to_mcp(await self._ops.read(
arguments["path"], int(arguments.get("offset", 0)),
int(arguments.get("limit", DEFAULT_READ_LIMIT))))
if name == "write":
return _to_mcp(await self._ops.write(arguments["path"],
arguments["content"]))
if name == "edit":
return _to_mcp(await self._ops.edit(
arguments["path"], arguments["old_string"],
arguments["new_string"],
bool(arguments.get("replace_all", False))))
if name == "ls":
return _to_mcp(await self._ops.ls(arguments["path"]))
if name == "grep":
return _to_mcp(await self._ops.grep(arguments["pattern"],
arguments["path"]))
raise ValueError(f"unknown tool: {name}")
async def run_stdio(self) -> None:
"""Serve the workspace over stdio until the client disconnects."""
async with stdio_server() as (read_stream, write_stream):
await self.server.run(read_stream, write_stream,
self.server.create_initialization_options())
def create_mirage_mcp_server(
workspace: Workspace,
stale_write_protection: bool = True) -> MirageMcpServer:
"""Build an MCP server for a workspace without serving it.
Args:
workspace (Workspace): The workspace to serve.
stale_write_protection (bool): False lets an agent overwrite a
file that changed since it read it.
Returns:
MirageMcpServer: The unserved server.
"""
return MirageMcpServer(workspace, stale_write_protection)
async def serve_mirage_mcp(workspace: Workspace,
stale_write_protection: bool = True) -> None:
"""Serve a workspace as MCP tools over stdio.
Args:
workspace (Workspace): The workspace to serve.
stale_write_protection (bool): False lets an agent overwrite a
file that changed since it read it.
"""
await create_mirage_mcp_server(workspace,
stale_write_protection).run_stdio()
+5 -1
View File
@@ -13,6 +13,8 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import shlex
from collections.abc import Awaitable
from typing import TypeVar
from pydantic_ai_backends.protocol import SandboxProtocol
from pydantic_ai_backends.types import (EditResult, ExecuteResponse, FileInfo,
@@ -25,6 +27,8 @@ from mirage.bridge.sync import run_async_from_sync
from mirage.io.types import IOResult
from mirage.workspace.workspace import Workspace
T = TypeVar("T")
class PydanticAIWorkspace(SandboxProtocol):
"""Pydantic AI backend backed by a Mirage Workspace.
@@ -44,7 +48,7 @@ class PydanticAIWorkspace(SandboxProtocol):
self._id = sandbox_id
self._session_id = session_id
def _run(self, coro):
def _run(self, coro: Awaitable[T]) -> T:
return run_async_from_sync(coro)
@property
+52
View File
@@ -0,0 +1,52 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
EXECUTE_DESCRIPTION = (
"Run a shell-style command on the Mirage virtual filesystem. "
"Supports cat, grep, find, head, tail, ls, wc, sort, uniq, tee, pipe, "
"and any other Unix command on mounted resources (S3, disk, RAM, etc.). "
"Files with no registered renderer, such as .parquet or .orc, read back "
"as raw bytes.")
READ_DESCRIPTION = (
"Read the contents of a file on the Mirage virtual filesystem. "
"Returns line-numbered text. "
"Optionally pass 'offset' (default 0) to start at a given line "
"and 'limit' (default 2000) to cap the number of lines returned.")
WRITE_DESCRIPTION = (
"Write content to a new file on the Mirage virtual filesystem. "
"Fails if the file already exists; use edit to modify an existing file.")
EDIT_DESCRIPTION = (
"Replace a string in an existing file on the Mirage virtual filesystem. "
"Fails if the file changed since it was last read, old_string is not "
"found, or old_string appears more than once. "
"Pass replace_all=true (default false) to replace every occurrence.")
LS_DESCRIPTION = ("List files and directories at the given path "
"on the Mirage virtual filesystem.")
GREP_DESCRIPTION = (
"Search for a pattern in files on the Mirage virtual filesystem. "
"Supports regex. Searches recursively under path.")
__all__ = [
"EXECUTE_DESCRIPTION",
"READ_DESCRIPTION",
"WRITE_DESCRIPTION",
"EDIT_DESCRIPTION",
"LS_DESCRIPTION",
"GREP_DESCRIPTION",
]
+224
View File
@@ -0,0 +1,224 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import shlex
from dataclasses import dataclass
from mirage.agents.file_version import FileVersionTracker, StaleMirageFileError
from mirage.agents.io_text import decode, io_to_str
from mirage.io.types import IOResult
from mirage.utils.path import gnu_dirname
from mirage.workspace.workspace import Workspace
DEFAULT_READ_LIMIT = 2000
@dataclass(frozen=True, slots=True)
class ToolResult:
"""One tool's answer, before any framework's result shape.
The two servers spell the failure flag differently -- MCP puts
`isError` on the wire, the Claude Agent SDK takes `is_error` -- so
the shared layer carries the fact and each server renders it.
Args:
text (str): The text handed back to the agent.
is_error (bool): True when the call failed.
"""
text: str
is_error: bool = False
def _io_result(io: IOResult) -> ToolResult:
return ToolResult(io_to_str(io), io.exit_code != 0)
def number_lines(text: str, offset: int, limit: int) -> str:
"""Render a slice of a file the way the read tool reports it.
Splits on newlines only. `str.splitlines` would also break on
\\v, \\f, \\x1c-\\x1e, \\x85 and the Unicode separators, which
would number a file containing any of them differently from the
TypeScript tool.
Args:
text (str): The decoded file content.
offset (int): First line to show, zero-based.
limit (int): Maximum number of lines to show.
Returns:
str: The numbered lines, joined.
"""
if not text:
lines: list[str] = []
else:
parts = text.split("\n")
lines = [part + "\n" for part in parts[:-1]]
if parts[-1]:
lines.append(parts[-1])
sliced = lines[offset:offset + limit]
return "".join(f"{i + offset + 1:>6}\t{line}"
for i, line in enumerate(sliced))
async def ensure_parents(ws: Workspace, path: str) -> None:
"""Create the directories a new file needs, parents first.
Args:
ws (Workspace): The workspace to create them in.
path (str): Virtual path of the file about to be written.
"""
parent = gnu_dirname(path)
if parent in ("/", "", "."):
return
if await ws.ops.exists(parent):
return
await ensure_parents(ws, parent)
try:
await ws.ops.mkdir(parent)
except OSError:
if not await ws.ops.exists(parent):
raise
class MirageToolOperations:
"""The six agent tools, independent of any agent framework.
Args:
workspace (Workspace): The workspace the tools act on.
stale_write_protection (bool): False lets an agent overwrite a
file that changed since it read it.
"""
def __init__(self,
workspace: Workspace,
stale_write_protection: bool = True) -> None:
self._ws = workspace
self._versions = FileVersionTracker(workspace, stale_write_protection)
async def execute(self, command: str) -> ToolResult:
"""Run a shell-style command line.
Args:
command (str): The command line to run.
Returns:
ToolResult: The command's rendered output.
"""
return _io_result(await self._ws.execute(command))
async def read(self,
path: str,
offset: int = 0,
limit: int = DEFAULT_READ_LIMIT) -> ToolResult:
"""Read a file as line-numbered text.
Args:
path (str): Virtual path.
offset (int): First line to show, zero-based.
limit (int): Maximum number of lines to show.
Returns:
ToolResult: The numbered lines, or the failure.
"""
try:
data = await self._versions.read(path)
except (OSError, ValueError) as exc:
if not await self._ws.ops.exists(path):
return ToolResult(f"Error: file '{path}' not found", True)
return ToolResult(f"Error: {exc}", True)
return ToolResult(number_lines(decode(data), offset, limit))
async def write(self, path: str, content: str) -> ToolResult:
"""Create a file, refusing to clobber an existing one.
Args:
path (str): Virtual path.
content (str): Text to write.
Returns:
ToolResult: The confirmation, or the failure.
"""
if await self._ws.ops.exists(path):
return ToolResult(f"Error: file '{path}' already exists", True)
await ensure_parents(self._ws, path)
await self._versions.write(path, content)
return ToolResult(f"Written: {path}")
async def edit(self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False) -> ToolResult:
"""Replace a string in an existing file.
Args:
path (str): Virtual path.
old_string (str): The text to find.
new_string (str): The text to put in its place.
replace_all (bool): True replaces every occurrence.
Returns:
ToolResult: The confirmation, or the failure.
"""
try:
content = decode(await self._versions.read_for_edit(path))
except StaleMirageFileError as exc:
return ToolResult(f"Error: {exc}", True)
except (OSError, ValueError) as exc:
if not await self._ws.ops.exists(path):
return ToolResult(f"Error: file '{path}' not found", True)
return ToolResult(f"Error: {exc}", True)
count = content.count(old_string)
if count == 0:
return ToolResult(
f"Error: string not found in file: '{old_string}'", True)
if count > 1 and not replace_all:
return ToolResult(
f"Error: string appears {count} times. Pass replace_all=true",
True)
new_content = content.replace(old_string, new_string,
-1 if replace_all else 1)
try:
await self._versions.write_edit(path, new_content)
except StaleMirageFileError as exc:
return ToolResult(f"Error: {exc}", True)
occurrences = count if replace_all else 1
return ToolResult(f"Edited: {path} ({occurrences} occurrence(s))")
async def ls(self, path: str) -> ToolResult:
"""List a directory.
Args:
path (str): Virtual path.
Returns:
ToolResult: The listing, or the failure.
"""
return _io_result(await self._ws.execute(f"ls {shlex.quote(path)}"))
async def grep(self, pattern: str, path: str) -> ToolResult:
"""Search recursively for a pattern.
Args:
pattern (str): The regex to search for.
path (str): Virtual path to search under.
Returns:
ToolResult: The matches.
"""
io = await self._ws.execute(
f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}")
return ToolResult(io_to_str(io))
+2
View File
@@ -18,6 +18,7 @@ from mirage.cli import config as config_module
from mirage.cli import daemon as daemon_module
from mirage.cli import execute as execute_module
from mirage.cli import job as job_module
from mirage.cli import mcp as mcp_module
from mirage.cli import provision as provision_module
from mirage.cli import session as session_module
from mirage.cli import workspace as workspace_module
@@ -35,6 +36,7 @@ app.add_typer(execute_module.app, name="execute")
app.add_typer(provision_module.app, name="provision")
app.add_typer(daemon_module.app, name="daemon")
app.add_typer(config_module.app, name="config")
app.add_typer(mcp_module.app, name="mcp")
def main() -> None:
+79
View File
@@ -0,0 +1,79 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
from pathlib import Path
import typer
from mirage.agents.mcp.server import serve_mirage_mcp
from mirage.server.workspace_config import (build_workspace_from_config,
resolve_workspace_config)
MCP_ENV_NAMES = ("MIRAGE_MCP_CONFIG", "MIRAGE_CONFIG")
app = typer.Typer(invoke_without_command=True,
help="Serve a Mirage workspace as MCP tools over stdio.")
def resolve_mcp_config(config: str | None = None,
cwd: str | Path | None = None,
env: dict[str, str] | None = None) -> Path:
"""Find the config `mirage mcp` should serve.
Args:
config (str | None): explicit path, relative to cwd.
cwd (str | Path | None): directory to resolve from.
env (dict[str, str] | None): environment mapping to read.
Returns:
Path: the resolved config path.
"""
return resolve_workspace_config(config,
cwd=cwd,
env=env,
env_names=MCP_ENV_NAMES)
async def run_mcp_server(config: str | None,
stale_write_protection: bool) -> None:
"""Build the workspace and serve it until the client disconnects.
Args:
config (str | None): explicit config path, or None to discover.
stale_write_protection (bool): False lets an agent overwrite a
file that changed since it read it.
"""
workspace = await build_workspace_from_config(resolve_mcp_config(config))
try:
await serve_mirage_mcp(workspace, stale_write_protection)
finally:
await workspace.close()
@app.callback(invoke_without_command=True)
def mcp_cmd(
config: str | None = typer.Argument(None,
help="Mirage workspace YAML config."),
stale_write_protection: bool = typer.Option(
True,
"--stale-write-protection/--no-stale-write-protection",
help="Refuse an edit when the file changed since it was read."),
) -> None:
"""Serve a Mirage workspace as MCP tools over stdio."""
try:
asyncio.run(run_mcp_server(config, stale_write_protection))
except FileNotFoundError as e:
typer.echo(str(e), err=True)
raise SystemExit(2) from e
@@ -1,6 +1,7 @@
from dataclasses import replace
from functools import partial
from mirage.accessor.chroma import ChromaAccessor
from mirage.commands.builtin.chroma.io import resolve_glob
from mirage.commands.builtin.generic.find import find_generic
from mirage.commands.builtin.utils.output import format_records
@@ -52,7 +53,7 @@ async def _normalize_find_output(
@command("find", resource="chroma", spec=SPECS["find"])
async def find(
accessor,
accessor: ChromaAccessor,
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
@@ -1,3 +1,4 @@
from mirage.accessor.chroma import ChromaAccessor
from mirage.commands.builtin.chroma.io import resolve_glob
from mirage.commands.builtin.utils.paths import default_paths
from mirage.commands.config import CommandOpts
@@ -18,7 +19,7 @@ def is_mount_root(path: PathSpec) -> bool:
@command("chroma-query", resource="chroma", spec=SPECS["search"])
async def search(
accessor,
accessor: ChromaAccessor,
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
+7 -2
View File
@@ -1,3 +1,4 @@
from mirage.accessor.dify import DifyAccessor
from mirage.commands.builtin.generic.cat import cat_generic
from mirage.commands.builtin.generic_bind import CommandIO
from mirage.commands.builtin.generic_bind.adapter import (bound_op,
@@ -20,8 +21,12 @@ def make_cat(ops: CommandIO):
"""
@command("cat", resource="dify", spec=SPECS["cat"])
async def cat(accessor, paths: list[PathSpec], texts: list[str],
opts: CommandOpts) -> tuple[ByteSource | None, IOResult]:
async def cat(
accessor: DifyAccessor,
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
) -> tuple[ByteSource | None, IOResult]:
resolved = await resolve_or_empty(ops, accessor, paths, opts.index)
return await cat_generic(resolved,
list(texts),
+7 -2
View File
@@ -1,6 +1,7 @@
from dataclasses import replace
from functools import partial
from mirage.accessor.dify import DifyAccessor
from mirage.commands.builtin.dify.io import resolve_glob
from mirage.commands.builtin.generic.find import find_generic
from mirage.commands.builtin.utils.output import format_records
@@ -51,8 +52,12 @@ async def _normalize_find_output(
@command("find", resource="dify", spec=SPECS["find"])
async def find(accessor, paths: list[PathSpec], texts: list[str],
opts: CommandOpts) -> tuple[ByteSource | None, IOResult]:
async def find(
accessor: DifyAccessor,
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
) -> tuple[ByteSource | None, IOResult]:
paths = default_paths(paths, opts.cwd)
paths = await resolve_glob(accessor, paths, opts.index)
search_path = paths[0]
@@ -1,3 +1,4 @@
from mirage.accessor.dify import DifyAccessor
from mirage.commands.builtin.dify.io import resolve_glob
from mirage.commands.builtin.utils.paths import default_paths
from mirage.commands.config import CommandOpts
@@ -18,7 +19,7 @@ def is_mount_root(path: PathSpec) -> bool:
@command("search", resource="dify", spec=SPECS["search"])
async def search(
accessor,
accessor: DifyAccessor,
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
@@ -8,7 +8,7 @@ from mirage.commands.spec import SPECS
from mirage.commands.spec.types import CommandName, FlagValue, FlagView
from mirage.commands.spec.usage import extra_operand_error
from mirage.io.types import ByteSource, IOResult
from mirage.types import PathSpec
from mirage.types import PathSpec, ReadStreamFn
async def _base64_encode_stream(source: AsyncIterator[bytes],
@@ -83,7 +83,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> Base64Flags:
)
async def base64_generic(paths, texts, opts: CommandOpts, read_stream):
async def base64_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_stream: ReadStreamFn,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await base64_cmd(paths,
read_stream=read_stream,
@@ -91,7 +91,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> CmpFlags:
)
async def cmp_generic(paths, texts, opts: CommandOpts, read_bytes):
async def cmp_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await cmp_cmd(paths,
read_bytes=read_bytes,
@@ -83,7 +83,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> ColumnFlags:
)
async def column_generic(paths, texts, opts: CommandOpts, read_bytes):
async def column_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await column(paths,
read_bytes=read_bytes,
@@ -134,7 +134,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> CommFlags:
)
async def comm_generic(paths, texts, opts: CommandOpts, read_bytes):
async def comm_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await comm(paths,
read_bytes=read_bytes,
@@ -10,7 +10,7 @@ from mirage.commands.spec import SPECS
from mirage.commands.spec.types import FlagValue, FlagView
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import LinkView
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec, StatFn
from mirage.utils.path import CycleError
_logger = logging.getLogger(__name__)
@@ -107,7 +107,13 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> FileFlags:
return FileFlags(brief=fl.as_bool("b"), mime=fl.as_bool("i"))
async def file_generic(paths, texts, opts: CommandOpts, read_bytes, stat_fn):
async def file_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
stat_fn: StatFn,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await file_cmd(paths,
read_bytes=read_bytes,
@@ -87,8 +87,14 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> GunzipFlags:
)
async def gunzip_generic(paths, texts, opts: CommandOpts, read_bytes,
write_bytes, unlink):
async def gunzip_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]],
unlink: Callable[..., Awaitable[None]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await gunzip(paths,
read_bytes=read_bytes,
@@ -129,8 +129,14 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> GzipFlags:
)
async def gzip_generic(paths, texts, opts: CommandOpts, read_bytes,
write_bytes, unlink):
async def gzip_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]],
unlink: Callable[..., Awaitable[None]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await gzip(paths,
read_bytes=read_bytes,
@@ -56,8 +56,13 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> IconvFlags:
)
async def iconv_generic(paths, texts, opts: CommandOpts, read_bytes,
write_bytes):
async def iconv_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await iconv(paths,
read_bytes=read_bytes,
@@ -232,7 +232,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> JoinFlags:
)
async def join_generic(paths, texts, opts: CommandOpts, read_bytes):
async def join_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await join_cmd(paths,
read_bytes=read_bytes,
@@ -52,7 +52,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> LookFlags:
return LookFlags(fold_case=fl.as_bool("f"))
async def look_generic(paths, texts, opts: CommandOpts, read_bytes):
async def look_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
) -> tuple[ByteSource | None, IOResult]:
if not texts:
raise ValueError("look: missing prefix")
parsed = parse_flags(opts.flags)
@@ -186,8 +186,14 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> PatchFlags:
)
async def patch_generic(paths, texts, opts: CommandOpts, read_bytes,
write_bytes, has_resource):
async def patch_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]],
has_resource: bool,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await patch(paths,
read_bytes=read_bytes,
@@ -53,7 +53,11 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> ReadlinkFlags:
)
async def readlink_generic(paths, texts, opts: CommandOpts):
async def readlink_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
) -> tuple[ByteSource | None, IOResult]:
if not paths:
raise ValueError("readlink: missing operand")
parsed = parse_flags(opts.flags)
@@ -58,7 +58,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> RealpathFlags:
allow_missing=fl.as_bool("m"))
async def realpath_generic(paths, texts, opts: CommandOpts, stat_fn):
async def realpath_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
stat_fn: StatFn,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await realpath(paths,
stat_fn=stat_fn,
@@ -198,8 +198,14 @@ def _positional_as_paths(texts: list[str],
return out
async def sed_generic(paths, texts, opts: CommandOpts, resolve_glob,
read_bytes, write_bytes):
async def sed_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
resolve_glob: Callable[..., Awaitable[list[PathSpec]]],
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]] | None,
) -> tuple[ByteSource | None, IOResult]:
"""Run sed over the given operands; mirrors sedGeneric.
The script comes from -e expressions and -f script files (joined
@@ -10,7 +10,7 @@ from mirage.commands.spec.types import FlagValue, FlagView
from mirage.core.timeutil import iso_to_epoch
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import LinkView
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec
from mirage.types import LINK_TARGET_KEY, FileStat, FileType, PathSpec, StatFn
from mirage.utils.errors import FS_ERRORS, fs_error_line
_STR_DIRECTIVES = frozenset("nNF")
@@ -427,7 +427,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> StatFlags:
)
async def stat_generic(paths, texts, opts: CommandOpts, stat_fn):
async def stat_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
stat_fn: StatFn,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await stat(paths,
stat_fn=stat_fn,
@@ -246,8 +246,17 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> TarFlags:
)
async def tar_generic(paths, texts, opts: CommandOpts, read_bytes, write_bytes,
mkdir_fn, stat, walk, is_dir):
async def tar_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]],
mkdir_fn: Callable[..., Awaitable[None]],
stat: StatFn,
walk: WalkFn,
is_dir: DirProbe,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await tar(paths,
read_bytes=read_bytes,
@@ -9,9 +9,9 @@ from mirage.commands.config import CommandOpts
from mirage.commands.spec import SPECS
from mirage.commands.spec.types import FlagValue, FlagView
from mirage.context import mount_allowed
from mirage.io.types import IOResult
from mirage.io.types import ByteSource, IOResult
from mirage.ops.types import MountView, ReaddirPath, StatPath
from mirage.types import FileStat, FileType, PathSpec
from mirage.types import FileStat, FileType, PathSpec, ReaddirFn
from mirage.utils.errors import WALK_ERRORS
from mirage.utils.fnmatch import fnmatch
from mirage.utils.key_prefix import rekey
@@ -327,7 +327,13 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> TreeFlags:
)
async def tree_generic(paths, texts, opts: CommandOpts, readdir, stat):
async def tree_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
readdir: ReaddirFn,
stat: Stat,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await tree(paths[0],
readdir=readdir,
@@ -187,8 +187,14 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> UnzipFlags:
)
async def unzip_generic(paths, texts, opts: CommandOpts, read_bytes,
write_bytes, mkdir_fn):
async def unzip_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]],
mkdir_fn: Callable[..., Awaitable[None]],
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await unzip(paths,
read_bytes=read_bytes,
@@ -10,7 +10,7 @@ from mirage.commands.spec import SPECS
from mirage.commands.spec.types import CommandName, FlagValue, FlagView
from mirage.commands.spec.usage import extra_operand_error
from mirage.io.types import ByteSource, IOResult
from mirage.types import PathSpec
from mirage.types import PathSpec, ReadStreamFn
async def _xxd_dump_stream(source: AsyncIterator[bytes], cols: int, group: int,
@@ -182,7 +182,12 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> XxdFlags:
)
async def xxd_generic(paths, texts, opts: CommandOpts, read_stream):
async def xxd_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_stream: ReadStreamFn,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await xxd(paths,
read_stream=read_stream,
@@ -285,8 +285,15 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> ZipFlags:
)
async def zip_generic(paths, texts, opts: CommandOpts, read_bytes, write_bytes,
stat, walk):
async def zip_generic(
paths: list[PathSpec],
texts: list[str],
opts: CommandOpts,
read_bytes: Callable[..., Awaitable[bytes]],
write_bytes: Callable[..., Awaitable[None]],
stat: StatFn,
walk: WalkFn,
) -> tuple[ByteSource | None, IOResult]:
parsed = parse_flags(opts.flags)
return await zip_cmd(
paths,
+17 -6
View File
@@ -1,11 +1,13 @@
from collections.abc import AsyncIterator
from typing import Any
from mirage.accessor.chroma import ChromaAccessor
PATH_TREE_ID = "__path_tree__"
PAGE_CHUNK_BATCH_SIZE = 100
async def fetch_path_tree(accessor) -> str:
async def fetch_path_tree(accessor: ChromaAccessor, ) -> str:
collection = await accessor.get_collection()
result = await collection.get(ids=[PATH_TREE_ID])
documents = result.get("documents") or []
@@ -19,18 +21,27 @@ async def fetch_path_tree(accessor) -> str:
return str(value)
async def fetch_page_chunks(accessor, slug: str) -> str:
async def fetch_page_chunks(
accessor: ChromaAccessor,
slug: str,
) -> str:
chunks = await page_chunks(accessor, slug)
return "\n".join(chunk["document"] for chunk in chunks)
async def iter_page_chunks(accessor, slug: str) -> AsyncIterator[str]:
async def iter_page_chunks(
accessor: ChromaAccessor,
slug: str,
) -> AsyncIterator[str]:
chunks = await page_chunks(accessor, slug)
for chunk in chunks:
yield chunk["document"]
async def page_chunks(accessor, slug: str) -> list[dict[str, Any]]:
async def page_chunks(
accessor: ChromaAccessor,
slug: str,
) -> list[dict[str, Any]]:
collection = await accessor.get_collection()
chunks: list[dict[str, Any]] = []
offset = 0
@@ -57,7 +68,7 @@ async def page_chunks(accessor, slug: str) -> list[dict[str, Any]]:
async def pages_chunks(
accessor,
accessor: ChromaAccessor,
slugs: list[str],
) -> dict[str, list[dict[str, Any]]]:
"""Fetch every chunk of several pages in one scan.
@@ -108,7 +119,7 @@ async def pages_chunks(
async def query_contains(
accessor,
accessor: ChromaAccessor,
pattern: str,
candidate_slugs: list[str],
*,
+8 -5
View File
@@ -1,3 +1,4 @@
from mirage.accessor.chroma import ChromaAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.commands.builtin.grep_helper import compile_pattern, grep_lines
from mirage.commands.builtin.utils.lines import split_lines
@@ -9,7 +10,7 @@ from mirage.utils.key_prefix import mount_key, mount_prefix_of, rekey
async def grep_bytes(
accessor,
accessor: ChromaAccessor,
paths: list[PathSpec],
pattern: str,
index: IndexCacheStore = NULL_INDEX,
@@ -80,7 +81,7 @@ async def grep_bytes(
async def coarse_filter_slugs(
accessor,
accessor: ChromaAccessor,
pattern: str,
targets: dict[str, str],
*,
@@ -97,9 +98,11 @@ async def coarse_filter_slugs(
regex=not fixed_string)
async def target_slugs(accessor,
paths: list[PathSpec],
index: IndexCacheStore = NULL_INDEX) -> dict[str, str]:
async def target_slugs(
accessor: ChromaAccessor,
paths: list[PathSpec],
index: IndexCacheStore = NULL_INDEX,
) -> dict[str, str]:
targets: dict[str, str] = {}
for path in paths:
resolved = await resolve_path(accessor, path, index)
+10 -6
View File
@@ -1,6 +1,7 @@
import errno
from collections.abc import AsyncIterator
from mirage.accessor.chroma import ChromaAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.chroma._client import iter_page_chunks, page_chunks
from mirage.core.chroma.path import resolve_path
@@ -8,9 +9,11 @@ from mirage.core.chroma.render import render_page
from mirage.types import PathSpec
async def read_bytes(accessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX) -> bytes:
async def read_bytes(
accessor: ChromaAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
) -> bytes:
resolved = await resolve_path(accessor, path, index)
if resolved.is_dir:
raise IsADirectoryError(errno.EISDIR, "Is a directory", path.virtual)
@@ -19,9 +22,10 @@ async def read_bytes(accessor,
async def read_stream(
accessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX) -> AsyncIterator[bytes]:
accessor: ChromaAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
) -> AsyncIterator[bytes]:
resolved = await resolve_path(accessor, path, index)
if resolved.is_dir:
raise IsADirectoryError(errno.EISDIR, "Is a directory", path.virtual)
+6 -3
View File
@@ -1,12 +1,15 @@
from mirage.accessor.chroma import ChromaAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.chroma.path import resolve_path
from mirage.types import PathSpec
from mirage.utils.errors import enoent, enotdir
async def readdir(accessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX) -> list[str]:
async def readdir(
accessor: ChromaAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
) -> list[str]:
resolved = await resolve_path(accessor, path, index)
if not resolved.is_dir:
raise enotdir(path)
+2 -1
View File
@@ -1,10 +1,11 @@
from mirage.accessor.chroma import ChromaAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore, IndexEntry
from mirage.core.chroma._client import pages_chunks
from mirage.core.chroma.render import render_page
async def ensure_dir_sizes(
accessor,
accessor: ChromaAccessor,
directory: str,
index: IndexCacheStore = NULL_INDEX,
) -> None:
+6 -3
View File
@@ -3,14 +3,17 @@ import gzip
import json
from typing import Any
from mirage.accessor.chroma import ChromaAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore, IndexEntry
from mirage.core.chroma._client import fetch_path_tree
from mirage.utils.path import gnu_basename, parent
async def ensure_tree(accessor,
index: IndexCacheStore = NULL_INDEX,
prefix: str = "") -> None:
async def ensure_tree(
accessor: ChromaAccessor,
index: IndexCacheStore = NULL_INDEX,
prefix: str = '',
) -> None:
root_key = mount_root(prefix)
listing = await index.list_dir(root_key)
if listing.entries is not None:
+2 -1
View File
@@ -1,3 +1,4 @@
from mirage.accessor.chroma import ChromaAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.chroma.path import resolve_path
from mirage.core.chroma.readdir import readdir
@@ -6,7 +7,7 @@ from mirage.utils.key_prefix import rekey
async def walk(
accessor,
accessor: ChromaAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
*,
+6 -3
View File
@@ -1,12 +1,15 @@
from mirage.accessor.dify import DifyAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.dify.path import resolve_path
from mirage.types import PathSpec
from mirage.utils.errors import enoent, enotdir
async def readdir(accessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX) -> list[str]:
async def readdir(
accessor: DifyAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
) -> list[str]:
resolved = await resolve_path(accessor, path, index)
if not resolved.is_dir:
raise enotdir(path)
+2 -1
View File
@@ -1,3 +1,4 @@
from mirage.accessor.dify import DifyAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.dify.path import resolve_path
from mirage.core.dify.readdir import readdir
@@ -6,7 +7,7 @@ from mirage.utils.key_prefix import rekey
async def walk(
accessor,
accessor: DifyAccessor,
path: PathSpec,
index: IndexCacheStore = NULL_INDEX,
*,
+13 -8
View File
@@ -14,6 +14,7 @@
import logging
from mirage.accessor.github import GitHubAccessor
from mirage.cache.index import (NULL_INDEX, IndexCacheStore, IndexEntry,
LookupStatus)
from mirage.core.github.tree import (ensure_live_index, fetch_dir_tree,
@@ -25,9 +26,11 @@ from mirage.utils.key_prefix import mount_prefix_of
log = logging.getLogger(__name__)
async def readdir(accessor,
path_spec: PathSpec,
index: IndexCacheStore = NULL_INDEX) -> list[str]:
async def readdir(
accessor: GitHubAccessor,
path_spec: PathSpec,
index: IndexCacheStore = NULL_INDEX,
) -> list[str]:
virtual = path_spec.virtual
prefix = mount_prefix_of(path_spec.virtual, path_spec.resource_path)
path = (path_spec.dir if path_spec.pattern else path_spec).mount_path
@@ -53,7 +56,7 @@ async def readdir(accessor,
async def _fallback_readdir(
accessor,
accessor: GitHubAccessor,
virtual_key: str,
index: IndexCacheStore,
virtual: str,
@@ -94,10 +97,12 @@ async def _fallback_readdir(
return sorted(child_keys)
async def _resolve_dir_sha(accessor,
virtual_key: str,
index: IndexCacheStore,
prefix: str = "") -> str | None:
async def _resolve_dir_sha(
accessor: GitHubAccessor,
virtual_key: str,
index: IndexCacheStore,
prefix: str = '',
) -> str | None:
"""Get the tree SHA for a directory path.
Walks from root if needed, fetching per-directory trees.
+2 -1
View File
@@ -15,6 +15,7 @@
import base64
from dataclasses import dataclass
from mirage.accessor.github import GitHubAccessor
from mirage.core.github._client import (GitHubApiError, github_get,
github_request)
from mirage.core.github.config import GhConfig, GitHubConfig
@@ -37,7 +38,7 @@ async def fetch_default_branch(config: GitHubConfig, owner: str,
return data["default_branch"]
async def ensure_default_branch(accessor) -> str:
async def ensure_default_branch(accessor: GitHubAccessor, ) -> str:
"""Fetch the repo's default branch once, on the first read needing it.
The mount names a repository without contacting it, so this is the
+6 -3
View File
@@ -14,6 +14,7 @@
import logging
from mirage.accessor.github import GitHubAccessor
from mirage.cache.index import NULL_INDEX, IndexCacheStore
from mirage.core.github.readdir import readdir as _readdir
from mirage.types import FileStat, FileType, PathSpec
@@ -24,9 +25,11 @@ from mirage.utils.key_prefix import mount_key, mount_prefix_of
logger = logging.getLogger(__name__)
async def stat(accessor,
path_spec: PathSpec,
index: IndexCacheStore = NULL_INDEX) -> FileStat:
async def stat(
accessor: GitHubAccessor,
path_spec: PathSpec,
index: IndexCacheStore = NULL_INDEX,
) -> FileStat:
virtual = path_spec.virtual
prefix = mount_prefix_of(path_spec.virtual, path_spec.resource_path)
rel = path_spec.mount_path.strip("/")
+21 -7
View File
@@ -17,6 +17,7 @@ from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
from mirage.accessor.github import GitHubAccessor
from mirage.cache.index import (NULL_INDEX, IndexCacheStore, IndexEntry,
LookupStatus)
from mirage.core.github._client import github_get
@@ -156,7 +157,11 @@ def index_rows(
return entries, children
def seed_index(accessor, index: IndexCacheStore, prefix: str) -> None:
def seed_index(
accessor: GitHubAccessor,
index: IndexCacheStore,
prefix: str,
) -> None:
"""Write the accessor's tree into ``index`` under ``prefix``.
Args:
@@ -169,7 +174,11 @@ def seed_index(accessor, index: IndexCacheStore, prefix: str) -> None:
datetime.now(timezone.utc) + timedelta(days=365))
async def refill_index(accessor, index: IndexCacheStore, prefix: str) -> bool:
async def refill_index(
accessor: GitHubAccessor,
index: IndexCacheStore,
prefix: str,
) -> bool:
"""Refetch the recursive tree and re-seed the index from it.
The mount fetches the whole tree once and seeds the index with it, so
@@ -201,8 +210,11 @@ async def refill_index(accessor, index: IndexCacheStore, prefix: str) -> bool:
return True
async def ensure_live_index(accessor, index: IndexCacheStore,
prefix: str) -> bool:
async def ensure_live_index(
accessor: GitHubAccessor,
index: IndexCacheStore,
prefix: str,
) -> bool:
"""Refetch when the index holds no listing at all.
Every reader here treats a missing listing as a real absence, which
@@ -250,9 +262,11 @@ async def ensure_live_index(accessor, index: IndexCacheStore,
return await refill_index(accessor, index, prefix)
async def ensure_tree(accessor,
index: IndexCacheStore = NULL_INDEX,
prefix: str = "") -> None:
async def ensure_tree(
accessor: GitHubAccessor,
index: IndexCacheStore = NULL_INDEX,
prefix: str = '',
) -> None:
"""Fetch the recursive tree if this mount has not got one yet.
The mount is constructed without touching the network, so readers
+9 -2
View File
@@ -1,10 +1,17 @@
from mirage.accessor.chroma import ChromaAccessor
from mirage.core.chroma.grep import grep_bytes
from mirage.ops.registry import op
from mirage.types import PathSpec
@op("grep", resource="chroma")
async def grep(accessor, paths: list[PathSpec], pattern: str, *, index,
**kwargs) -> bytes:
async def grep(
accessor: ChromaAccessor,
paths: list[PathSpec],
pattern: str,
*,
index,
**kwargs,
) -> bytes:
output, _reads = await grep_bytes(accessor, paths, pattern, index)
return output
+9 -2
View File
@@ -1,3 +1,4 @@
from mirage.accessor.chroma import ChromaAccessor
from mirage.core.chroma import search as search_core
from mirage.ops.registry import op
from mirage.types import PathSpec
@@ -5,8 +6,14 @@ from mirage.utils.key_prefix import mount_prefix_of
@op("search", resource="chroma")
async def search(accessor, paths: list[PathSpec], query: str, *, index,
**kwargs) -> bytes:
async def search(
accessor: ChromaAccessor,
paths: list[PathSpec],
query: str,
*,
index,
**kwargs,
) -> bytes:
explicit_prefix = kwargs.pop("mount_prefix", "")
mount_prefix = mount_prefix_of(
paths[0].virtual, paths[0].resource_path) if paths else explicit_prefix
+9 -2
View File
@@ -1,9 +1,16 @@
from mirage.accessor.dify import DifyAccessor
from mirage.core.dify.grep import grep_bytes
from mirage.ops.registry import op
from mirage.types import PathSpec
@op("grep", resource="dify")
async def grep(accessor, paths: list[PathSpec], pattern: str, *, index,
**kwargs) -> bytes:
async def grep(
accessor: DifyAccessor,
paths: list[PathSpec],
pattern: str,
*,
index,
**kwargs,
) -> bytes:
return await grep_bytes(accessor, paths, pattern, index)
+9 -2
View File
@@ -1,3 +1,4 @@
from mirage.accessor.dify import DifyAccessor
from mirage.core.dify import search as search_core
from mirage.ops.registry import op
from mirage.types import PathSpec
@@ -5,8 +6,14 @@ from mirage.utils.key_prefix import mount_prefix_of
@op("search", resource="dify")
async def search(accessor, paths: list[PathSpec], query: str, *, index,
**kwargs) -> bytes:
async def search(
accessor: DifyAccessor,
paths: list[PathSpec],
query: str,
*,
index,
**kwargs,
) -> bytes:
explicit_prefix = kwargs.pop("mount_prefix", "")
mount_prefix = mount_prefix_of(
paths[0].virtual, paths[0].resource_path) if paths else explicit_prefix
+5 -1
View File
@@ -56,7 +56,11 @@ class BoxResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -9,7 +9,7 @@ from mirage.ops.chroma import OPS as CHROMA_VFS_OPS
from mirage.resource.base import BaseResource
from mirage.resource.chroma.config import ChromaConfig
from mirage.resource.chroma.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -45,7 +45,11 @@ class ChromaResource(BaseResource):
for fn in CHROMA_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
@@ -84,7 +84,11 @@ class DatabricksVolumeResource(BaseResource):
for op in DATABRICKS_VOLUME_OPS:
self.register_op(op)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -9,7 +9,7 @@ from mirage.ops.dify import OPS as DIFY_VFS_OPS
from mirage.resource.base import BaseResource
from mirage.resource.dify.config import DifyConfig
from mirage.resource.dify.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -41,7 +41,11 @@ class DifyResource(BaseResource):
for fn in DIFY_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.discord.config import DiscordConfig
from mirage.core.discord.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.discord.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -49,7 +49,11 @@ class DiscordResource(BaseResource):
for fn in DISCORD_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+5 -1
View File
@@ -96,7 +96,11 @@ class DiskResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -29,7 +29,7 @@ from mirage.core.dropbox.write import write_bytes
from mirage.resource.base import BaseResource
from mirage.resource.dropbox.config import DropboxConfig
from mirage.resource.dropbox.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
from mirage.watch.base import DeltaHook
@@ -77,7 +77,11 @@ class DropboxResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.email.config import EmailConfig
from mirage.core.email.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.email.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -53,7 +53,11 @@ class EmailResource(BaseResource):
for fn in OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -22,7 +22,7 @@ from mirage.ops.gcal import OPS as GCAL_VFS_OPS
from mirage.resource.base import BaseResource
from mirage.resource.gcal.config import GCalConfig
from mirage.resource.gcal.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -50,7 +50,11 @@ class GCalResource(BaseResource):
for fn in GCAL_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -20,7 +20,7 @@ from mirage.core.google._client import TokenManager
from mirage.resource.base import BaseResource
from mirage.resource.gdocs.config import GDocsConfig
from mirage.resource.gdocs.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -51,7 +51,11 @@ class GDocsResource(BaseResource):
for fn in GDOCS_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -21,7 +21,7 @@ from mirage.core.google._client import TokenManager
from mirage.resource.base import BaseResource
from mirage.resource.gdrive.config import GoogleDriveConfig
from mirage.resource.gdrive.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
from mirage.watch.base import DeltaHook
@@ -56,7 +56,11 @@ class GoogleDriveResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -21,7 +21,7 @@ from mirage.core.github.tree_entry import TreeEntry
from mirage.core.github.watch import build_delta_hook
from mirage.resource.base import BaseResource
from mirage.resource.github.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
from mirage.watch.base import DeltaHook
@@ -119,7 +119,11 @@ class GitHubResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, self._index)
@property
+6 -2
View File
@@ -20,7 +20,7 @@ from mirage.core.google._client import TokenManager
from mirage.resource.base import BaseResource
from mirage.resource.gmail.config import GmailConfig
from mirage.resource.gmail.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -55,7 +55,11 @@ class GmailResource(BaseResource):
for fn in GMAIL_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+5 -1
View File
@@ -91,7 +91,11 @@ class GridFSResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -20,7 +20,7 @@ from mirage.core.gsheets.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.gsheets.config import GSheetsConfig
from mirage.resource.gsheets.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -51,7 +51,11 @@ class GSheetsResource(BaseResource):
for fn in GSHEETS_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -22,7 +22,7 @@ from mirage.ops.gslides import OPS as GSLIDES_VFS_OPS
from mirage.resource.base import BaseResource
from mirage.resource.gslides.config import GSlidesConfig
from mirage.resource.gslides.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -51,7 +51,11 @@ class GSlidesResource(BaseResource):
for fn in GSLIDES_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
@@ -83,7 +83,11 @@ class HfBucketsResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
@@ -82,7 +82,11 @@ class HfDatasetsResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
@@ -82,7 +82,11 @@ class HfModelsResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
@@ -82,7 +82,11 @@ class HfSpacesResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -21,7 +21,7 @@ from mirage.ops.jaeger import OPS as JAEGER_VFS_OPS
from mirage.resource.base import BaseResource
from mirage.resource.jaeger.config import JaegerConfig
from mirage.resource.jaeger.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -47,7 +47,11 @@ class JaegerResource(BaseResource):
for op in JAEGER_VFS_OPS:
self.register_op(op)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(
self.accessor,
paths,
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.lancedb.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.lancedb.config import LanceDBConfig
from mirage.resource.lancedb.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -52,7 +52,11 @@ class LanceDBResource(BaseResource):
for fn in LANCEDB_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.langfuse.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.langfuse.config import LangfuseConfig
from mirage.resource.langfuse.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -44,7 +44,11 @@ class LangfuseResource(BaseResource):
for fn in LANGFUSE_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(
self.accessor,
paths,
+6 -2
View File
@@ -21,7 +21,7 @@ from mirage.core.linear.readdir import readdir
from mirage.core.linear.stat import stat
from mirage.resource.base import BaseResource
from mirage.resource.linear.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -58,7 +58,11 @@ class LinearResource(BaseResource):
for op in LINEAR_VFS_OPS:
self.register_op(op)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.mongodb.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.mongodb.config import MongoDBConfig
from mirage.resource.mongodb.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -47,7 +47,11 @@ class MongoDBResource(BaseResource):
for op in MONGODB_VFS_OPS:
self.register_op(op)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
@@ -55,7 +55,11 @@ class NextcloudResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -21,7 +21,7 @@ from mirage.core.notion.readdir import readdir
from mirage.core.notion.stat import stat
from mirage.resource.base import BaseResource
from mirage.resource.notion.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -54,7 +54,11 @@ class NotionResource(BaseResource):
for op in NOTION_VFS_OPS:
self.register_op(op)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, self._index)
def get_state(self) -> dict[str, Any]:
+5 -1
View File
@@ -96,7 +96,11 @@ class OneDriveResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.postgres.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.postgres.config import PostgresConfig
from mirage.resource.postgres.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -47,7 +47,11 @@ class PostgresResource(BaseResource):
for op in POSTGRES_VFS_OPS:
self.register_op(op)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.qdrant.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.qdrant.config import QdrantConfig
from mirage.resource.qdrant.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -48,7 +48,11 @@ class QdrantResource(BaseResource):
for fn in QDRANT_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+5 -1
View File
@@ -87,7 +87,11 @@ class RAMResource(BaseResource):
for ro in RAM_OPS:
self.register_op(ro)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+5 -1
View File
@@ -108,7 +108,11 @@ class RedisResource(BaseResource):
base = f"{self.name}:{self.url}"
return f"{base}/{prefix}" if prefix else base
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+5 -1
View File
@@ -102,7 +102,11 @@ class S3Resource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
@@ -82,7 +82,11 @@ class SharePointResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
if prefix:
paths = [
dataclasses.replace(p,
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.slack.config import SlackConfig
from mirage.core.slack.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.slack.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -50,7 +50,11 @@ class SlackResource(BaseResource):
for fn in SLACK_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -40,7 +40,7 @@ from mirage.core.ssh.write import write_bytes
from mirage.ops.ssh import OPS as SSH_OPS
from mirage.resource.base import BaseResource
from mirage.resource.ssh.prompt import PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
from mirage.watch.base import DeltaHook
@@ -95,7 +95,11 @@ class SSHResource(BaseResource):
def delta_hook(self) -> DeltaHook:
return build_delta_hook(self.accessor)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, self._index)
def get_state(self) -> dict[str, Any]:
+6 -2
View File
@@ -19,7 +19,7 @@ from mirage.core.trello.readdir import readdir
from mirage.resource.base import BaseResource
from mirage.resource.trello.config import TrelloConfig
from mirage.resource.trello.prompt import PROMPT, WRITE_PROMPT
from mirage.types import ResourceName
from mirage.types import PathSpec, ResourceName
from mirage.utils.glob_walk import make_resolve_glob
_resolve_glob = make_resolve_glob(readdir)
@@ -45,7 +45,11 @@ class TrelloResource(BaseResource):
for fn in TRELLO_VFS_OPS:
self.register_op(fn)
async def resolve_glob(self, paths, prefix: str = ""):
async def resolve_glob(
self,
paths: list[str | PathSpec],
prefix: str = '',
) -> list[PathSpec]:
return await _resolve_glob(self.accessor, paths, index=self._index)
def get_state(self) -> dict[str, Any]:
+16 -9
View File
@@ -12,7 +12,7 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from typing import Any
from typing import TYPE_CHECKING, Any
from mirage.server.version.errors import NoSuchBranchError
from mirage.server.version.state_tree import (CONTROL_PREFIX, META_PATH,
@@ -23,6 +23,9 @@ from mirage.types import DriftPolicy, StateKey
from mirage.workspace.snapshot import (apply_state_dict, install_fingerprints,
to_state_dict)
if TYPE_CHECKING:
from mirage.workspace.workspace import Workspace
async def snapshot_tree_from_state(store: VersionStore,
state: dict[str, Any]) -> bytes:
@@ -34,10 +37,12 @@ async def snapshot_tree_from_state(store: VersionStore,
return await store.write_tree(tree_entries)
async def commit(store: VersionStore,
ws,
branch: str = "main",
message: str = "") -> bytes:
async def commit(
store: VersionStore,
ws: "Workspace",
branch: str = 'main',
message: str = '',
) -> bytes:
return await commit_state(store, await to_state_dict(ws), branch, message)
@@ -86,10 +91,12 @@ async def resolve_ref(store: VersionStore, ref) -> bytes:
return ref
async def checkout(store: VersionStore,
ws,
ref,
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> None:
async def checkout(
store: VersionStore,
ws: "Workspace",
ref,
drift_policy: DriftPolicy = DriftPolicy.STRICT,
) -> None:
version = await resolve_ref(store, ref)
entries, meta = await read_version(store, version)
state = to_state(entries, meta)
+12 -8
View File
@@ -12,7 +12,7 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from typing import Any
from typing import TYPE_CHECKING, Any
from mirage.server.version.api import read_version, resolve_ref
from mirage.server.version.state_tree import CATEGORIES, to_state
@@ -22,6 +22,9 @@ from mirage.utils.path import norm
from mirage.workspace.snapshot import (apply_state_dict, install_fingerprints,
to_state_dict)
if TYPE_CHECKING:
from mirage.workspace.workspace import Workspace
def _selected_file(path: str, wanted: list[str]) -> bool:
p = norm(path)
@@ -52,13 +55,14 @@ def _merge_mount_files(live_mount: dict[str, Any], target_mount: dict[str,
async def restore(
store: VersionStore,
ws,
ref,
*,
paths: list[str] | None = None,
categories: list[str] | None = None,
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> dict[str, Any]:
store: VersionStore,
ws: "Workspace",
ref,
*,
paths: list[str] | None = None,
categories: list[str] | None = None,
drift_policy: DriftPolicy = DriftPolicy.STRICT,
) -> dict[str, Any]:
"""Surgical restore: whole world, chosen categories, or chosen paths.
Scope rules: no arguments = the whole world (checkout semantics);
+104
View File
@@ -0,0 +1,104 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import os
from pathlib import Path
from mirage.config import load_config
from mirage.utils.ids import new_workspace_id
from mirage.workspace.workspace import Workspace
WORKSPACE_CONFIG_CANDIDATES = (
".mirage/workspace.yaml",
".mirage/workspace.yml",
"workspace.yaml",
"workspace.yml",
"mirage.yaml",
"mirage.yml",
)
DEFAULT_ENV_NAMES = ("MIRAGE_CONFIG", )
def _require_config(path: Path) -> Path:
if not path.exists():
raise FileNotFoundError(f"Mirage workspace config not found: {path}")
return path
def resolve_workspace_config(
config: str | Path | None = None,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
env_names: tuple[str, ...] = DEFAULT_ENV_NAMES) -> Path:
"""Find the workspace config a command should load.
An explicit path wins, then the first environment variable that is
set, then the first candidate filename found walking up from cwd.
Args:
config (str | Path | None): explicit path, relative to cwd.
cwd (str | Path | None): directory to resolve from. Defaults to
the process working directory.
env (dict[str, str] | None): environment mapping to read.
Defaults to ``os.environ``.
env_names (tuple[str, ...]): variables to consult, in order.
Returns:
Path: the resolved config path.
Raises:
FileNotFoundError: a named path does not exist, or the walk
reached the root without finding a candidate.
"""
base = Path(cwd).resolve() if cwd is not None else Path.cwd().resolve()
use_env = env if env is not None else dict(os.environ)
if config is not None:
return _require_config((base / config).resolve())
for name in env_names:
value = use_env.get(name)
if value is not None:
return _require_config((base / value).resolve())
for directory in (base, *base.parents):
for candidate in WORKSPACE_CONFIG_CANDIDATES:
path = directory / candidate
if path.exists():
return path
raise FileNotFoundError(
"No Mirage workspace config found. Pass a config path or set "
f"{' or '.join(env_names)}.")
async def build_workspace_from_config(config_path: str | Path) -> Workspace:
"""Build a workspace from a config file, kernel mounts included.
Args:
config_path (str | Path): path to the YAML config.
Returns:
Workspace: the constructed workspace.
"""
config = load_config(config_path)
kwargs = config.to_workspace_kwargs()
kwargs["workspace_id"] = kwargs.get("workspace_id") or new_workspace_id()
workspace = Workspace(**kwargs)
try:
for prefix, (backend, mountpoint) in config.kernel_mounts().items():
workspace.add_fuse_mount(prefix, mountpoint, backend=backend)
except Exception:
await workspace.close()
raise
return workspace
+11 -1
View File
@@ -12,12 +12,22 @@
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from typing import TYPE_CHECKING
from mirage.workspace.snapshot.manifest import split_manifest_and_blobs
from mirage.workspace.snapshot.state import to_state_dict
from mirage.workspace.snapshot.tar_io import write_tar
if TYPE_CHECKING:
from mirage.workspace.workspace import Workspace
async def snapshot(ws, target, *, compress: str | None = None) -> None:
async def snapshot(
ws: "Workspace",
target,
*,
compress: str | None = None,
) -> None:
"""Serialize a Workspace to a tar archive.
Fingerprints come from ``ws._ops.records`` (each read carries the
+11 -5
View File
@@ -14,11 +14,14 @@
import asyncio
import logging
from typing import Any, Callable
from typing import TYPE_CHECKING, Any, Callable
from mirage.types import DriftPolicy, FingerprintKey
from mirage.workspace.mount.mount import MountEntry
if TYPE_CHECKING:
from mirage.workspace.workspace import Workspace
TryMountFor = Callable[[str], MountEntry | None]
logger = logging.getLogger(__name__)
@@ -113,7 +116,7 @@ class DriftQueue:
raise result
def capture_fingerprints(ws) -> list[dict[str, Any]]:
def capture_fingerprints(ws: "Workspace", ) -> list[dict[str, Any]]:
"""Walk session ops and emit one entry per distinct read on a
``SUPPORTS_SNAPSHOT`` mount.
@@ -161,8 +164,11 @@ def capture_fingerprints(ws) -> list[dict[str, Any]]:
return out
def install_fingerprints(ws, fingerprint_entries: list[dict[str, Any]],
drift_policy: DriftPolicy) -> None:
def install_fingerprints(
ws: "Workspace",
fingerprint_entries: list[dict[str, Any]],
drift_policy: DriftPolicy,
) -> None:
"""Install snapshot fingerprints/revisions onto a reconstructed ws.
Revisions pin replay reads to exact backend versions; bare
@@ -193,7 +199,7 @@ def install_fingerprints(ws, fingerprint_entries: list[dict[str, Any]],
ws._drift.queue(path, fingerprint)
def live_only_mount_prefixes(ws) -> list[str]:
def live_only_mount_prefixes(ws: "Workspace", ) -> list[str]:
"""Return mount prefixes whose resource opts out of snapshot replay.
These mounts will serve current state at load time with no drift
+16 -6
View File
@@ -15,7 +15,7 @@
import asyncio
import logging
from functools import partial
from typing import Any
from typing import TYPE_CHECKING, Any
from mirage.commands.builtin.utils.limit import (CommandTimeoutError,
run_with_timeout)
@@ -37,6 +37,9 @@ from mirage.workspace.workspace.failure import failure_result
from mirage.workspace.workspace.line import run_whole_line
from mirage.workspace.workspace.utils import command_name, fork_for_call
if TYPE_CHECKING:
from mirage.workspace.workspace import Workspace
logger = logging.getLogger(__name__)
@@ -54,9 +57,13 @@ async def plan_eval_stub(cmd: str, **opts: Any) -> IOResult:
return IOResult()
async def recurse(ws, cancel: asyncio.Event | None,
routing_decision: PolicyDecision | None, cmd: str,
**opts: Any) -> Any:
async def recurse(
ws: "Workspace",
cancel: asyncio.Event | None,
routing_decision: PolicyDecision | None,
cmd: str,
**opts: Any,
) -> Any:
"""The executor's internal eval ($(), source, eval, xargs, ...).
Never a typed line, so it must not record a history entry or open
@@ -78,7 +85,10 @@ async def recurse(ws, cancel: asyncio.Event | None,
**opts)
def session_cwd(ws, session_id: str) -> str | None:
def session_cwd(
ws: "Workspace",
session_id: str,
) -> str | None:
"""The session's cwd for history, None once the session is gone.
Args:
@@ -104,7 +114,7 @@ def syntax_error_result(offending: str) -> IOResult:
async def execute_line(
ws,
ws: "Workspace",
command: str,
session_id: str | None,
stdin: ByteSource | None,
+12 -7
View File
@@ -15,14 +15,17 @@
import asyncio
import builtins
import sys
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from mirage.ops.open import make_open
from mirage.ops.os_patch import make_os_module
from mirage.shell.job_table import cancel_job
if TYPE_CHECKING:
from mirage.workspace.workspace import Workspace
def patch_process(ws) -> None:
def patch_process(ws: "Workspace", ) -> None:
"""Point ``open`` and ``os`` at the workspace for a ``with`` block.
Args:
@@ -34,17 +37,19 @@ def patch_process(ws) -> None:
sys.modules["os"] = make_os_module(ws._ops)
def unpatch_process(ws) -> None:
def unpatch_process(ws: "Workspace", ) -> None:
"""Restore the process-level ``open`` and ``os`` patched on entry.
Args:
ws: the workspace leaving context-manager scope.
"""
builtins.open = ws._original_open
sys.modules["os"] = ws._original_os
if ws._original_open is not None:
builtins.open = ws._original_open
if ws._original_os is not None:
sys.modules["os"] = ws._original_os
def close_sync_parts(ws) -> None:
def close_sync_parts(ws: "Workspace", ) -> None:
"""Tear down everything that needs no event loop (idempotent).
Kernel mounts, running jobs, and in-flight cache drains; the
@@ -68,7 +73,7 @@ def close_sync_parts(ws) -> None:
ws._cache._drain_tasks.clear()
async def close_async(ws) -> None:
async def close_async(ws: "Workspace", ) -> None:
"""Release everything the workspace owns, exactly once.
Order matters: the watch runtime goes first (it reads mounts), then
@@ -14,8 +14,9 @@
import asyncio
import logging
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
from types import TracebackType
from collections.abc import (AsyncIterator, Callable, Iterable, Mapping,
Sequence)
from types import ModuleType, TracebackType
from typing import Any, Literal, overload
from mirage.bridge.sync import run_async_from_sync
@@ -182,6 +183,12 @@ class Workspace:
links=self._namespace,
dispatch=self._dispatcher.dispatch)
self._kernel_mounts = KernelMounts(self._ops, self._session_mgr)
# Held only while the workspace is a context manager; set by
# lifecycle.patch_process. Declared here because the pair was
# invented by assignment, so an unpatch without a patch raised
# AttributeError instead of restoring nothing.
self._original_open: Callable[..., Any] | None = None
self._original_os: ModuleType | None = None
self._runtimes, self._policy_router = wire_runtime_world(
self._registry, self.dispatch,

Some files were not shown because too many files have changed in this diff Show More