Files
wehub-resource-sync bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:44 +08:00

780 lines
32 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
import builtins
import logging
import sys
from collections.abc import AsyncIterator
from functools import partial
from typing import Any
from mirage.cache.file.config import CacheConfig, RedisCacheConfig
from mirage.cache.file.ram import RAMFileCacheStore
from mirage.cache.index import IndexConfig
from mirage.commands.builtin.utils.safeguard import (CommandTimeoutError,
run_with_timeout)
from mirage.commands.errors import FindParseError, UsageError
from mirage.commands.safeguard import resolve_safeguard
try:
from mirage.cache.file.redis import RedisFileCacheStore
except ImportError:
RedisFileCacheStore = None # type: ignore[misc, assignment]
from mirage.io import IOResult
from mirage.observe.context import RecordingScope
from mirage.observe.observer import Observer
from mirage.observe.record import OpRecord
from mirage.observe.store import ObserverStore
from mirage.ops import Ops
from mirage.ops.open import make_open
from mirage.ops.os_patch import make_os_module
from mirage.provision import ProvisionResult
from mirage.resource.base import BaseResource
from mirage.resource.history import HISTORY_PREFIX, HistoryViewResource
from mirage.resource.ram import RAMResource
from mirage.shell.job_table import JobTable
from mirage.shell.parse import find_syntax_error, parse
from mirage.types import (DEFAULT_AGENT_ID, DEFAULT_SESSION_ID,
ConsistencyPolicy, DriftPolicy, FileStat, MountMode,
PathSpec, StateKey)
from mirage.utils.errors import format_fs_error
from mirage.workspace.abort import MirageAbortError
from mirage.workspace.dispatcher import Dispatcher
from mirage.workspace.file_prompt import build_file_prompt
from mirage.workspace.fuse import FuseManager
from mirage.workspace.mount import MountEntry, MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.mount.spec import Mount
from mirage.workspace.node import provision_node, run_command_tree
from mirage.workspace.session import (Session, SessionManager,
reset_current_session,
set_current_session)
from mirage.workspace.snapshot import (ContentDriftError, apply_state_dict,
build_mount_args, check_drift,
install_fingerprints, norm_mount_prefix,
read_tar, requires_resource_override)
from mirage.workspace.snapshot import snapshot as _write_snapshot
from mirage.workspace.snapshot import to_state_dict
logger = logging.getLogger(__name__)
class Workspace:
"""Unified virtual filesystem over heterogeneous resources.
Manages mounts, caching, and command execution.
All ops are forwarded directly to the resolved resource.
"""
def __init__(
self,
resources: dict[str, BaseResource | tuple | Mount],
cache_limit: str | int = "512MB",
cache: CacheConfig | None = None,
index: IndexConfig | None = None,
mode: MountMode = MountMode.READ,
consistency: ConsistencyPolicy = ConsistencyPolicy.LAZY,
session_id: str = DEFAULT_SESSION_ID,
agent_id: str = DEFAULT_AGENT_ID,
observe: ObserverStore | None = None,
) -> None:
self._registry = MountRegistry()
if isinstance(cache, RedisCacheConfig):
if RedisFileCacheStore is None:
raise ImportError(
"RedisCacheConfig requires the 'redis' extra. "
"Install with: pip install mirage-ai[redis]")
self._cache = RedisFileCacheStore(
cache_limit=cache.limit,
url=cache.url,
key_prefix=cache.key_prefix,
max_drain_bytes=cache.max_drain_bytes,
)
else:
limit = cache.limit if cache is not None else cache_limit
max_drain = cache.max_drain_bytes if cache is not None else None
self._cache = RAMFileCacheStore(cache_limit=limit,
max_drain_bytes=max_drain)
self._locked_paths: set[str] = set()
self._closed = False
self._drift_policy: DriftPolicy = DriftPolicy.OFF
self._drift_check_pending: bool = False
# Queued at Workspace.load: (mount, path, expected_fingerprint).
# First dispatch/execute drains via asyncio.gather, then clears.
self._pending_drift: list[tuple[MountEntry, str, str]] = []
self.job_table = JobTable()
self._current_agent_id: str = agent_id
self._default_session_id = session_id
self._default_agent_id = agent_id
self._session_mgr = SessionManager(session_id)
self._consistency = consistency
self._registry.set_consistency(consistency)
self._registry.attach_file_cache(self._cache)
self._namespace = Namespace(self._registry)
self._dispatcher = Dispatcher(self._namespace, self._cache,
consistency)
fuse_targets: list[tuple[str, bool | str]] = []
for prefix, value in resources.items():
mount_safeguards: dict = {}
mount_fuse: bool | str = False
if isinstance(value, Mount):
prov = value.resource
mount_mode = value.mode if value.mode is not None else mode
if value.command_safeguards:
mount_safeguards = dict(value.command_safeguards)
mount_fuse = value.fuse
elif isinstance(value, tuple) and len(value) >= 2:
prov = value[0]
mount_mode = value[1]
if len(value) >= 3 and value[2]:
mount_safeguards = dict(value[2])
else:
prov = value
mount_mode = mode
if index is not None:
prov.set_index(index)
mount_obj = self._registry.mount(prefix, prov, mount_mode)
if mount_safeguards:
mount_obj.command_safeguards.update(mount_safeguards)
if mount_fuse:
fuse_targets.append((prefix, mount_fuse))
if self._registry.root_mount is None:
self._registry.mount("/", RAMResource(), mode)
self._fuse_mountpoints: dict[str, str] = {}
self._fuse_managers: dict[str, FuseManager] = {}
self.observer = Observer(store=observe)
self._registry.mount(HISTORY_PREFIX,
HistoryViewResource(self.observer),
MountMode.READ)
self._ops = Ops(self._registry.ops_mounts(),
on_write=self._invalidate_after_write_by_path,
observer=self.observer,
agent_id=agent_id,
session_id=session_id)
for prefix, fuse_target in fuse_targets:
mountpoint = fuse_target if isinstance(fuse_target, str) else None
self.add_fuse_mount(prefix, mountpoint)
async def history(self) -> list[dict]:
"""Command events recorded by the hidden recorder.
Returns:
list[dict]: All sessions' command events, timestamp order.
"""
return await self.observer.command_events()
@property
def ops(self) -> Ops:
return self._ops
@property
def namespace(self) -> Namespace:
return self._namespace
@property
def cache(self):
return self._cache
@property
def max_drain_bytes(self) -> int | None:
return self._cache.max_drain_bytes
@max_drain_bytes.setter
def max_drain_bytes(self, value: int | None) -> None:
self._cache.max_drain_bytes = value
def mounts(self) -> list:
return self._registry.mounts()
@property
def revisions(self) -> dict[str, str]:
"""Flat view of every mount's installed revision pins.
Derived (read-only) — the source of truth lives per-mount on
``mount.revisions``. Useful for tests, audit ("which paths got
pinned at load?"), and debugging. Empty until a snapshot is
loaded with revisions in its manifest.
"""
out: dict[str, str] = {}
for m in self._registry.mounts():
if m.revisions:
out.update(m.revisions)
return out
def mount(self, prefix: str):
return self._registry.mount_for(prefix)
async def unmount(self, prefix: str) -> None:
if self._closed:
raise RuntimeError("Workspace is closed")
stripped = prefix.strip("/")
norm = ("/" + stripped + "/" if stripped else "/")
if norm == "/":
raise ValueError(f"cannot unmount the virtual root: {prefix!r}")
if norm == "/dev/":
raise ValueError("cannot unmount reserved prefix: '/dev/'")
if norm == HISTORY_PREFIX + "/":
raise ValueError(f"cannot unmount history view: "
f"{HISTORY_PREFIX!r}")
removed = self._registry.unmount(prefix)
self._ops.unmount(prefix)
remaining = self._registry.mounts()
still_instance = any(m.resource is removed.resource for m in remaining)
still_kind = any(m.resource.name == removed.resource.name
for m in remaining)
if not still_kind:
self._ops._registry.unregister_resource(removed.resource.name)
if not still_instance:
close = getattr(removed.resource, "close", None)
if callable(close):
result = close()
if hasattr(result, "__await__"):
await result
def _register_fuse(self, prefix: str, mountpoint: str) -> None:
for other_prefix, other_mp in self._fuse_mountpoints.items():
if other_mp == mountpoint and other_prefix != prefix:
raise ValueError(
f"FUSE mountpoint {mountpoint!r} already used by "
f"prefix {other_prefix!r}; mounts need distinct paths")
self._fuse_mountpoints[prefix] = mountpoint
def _deregister_fuse(self, prefix: str) -> None:
self._fuse_mountpoints.pop(prefix, None)
def add_fuse_mount(self,
prefix: str,
mountpoint: str | None = None) -> str:
# Register a pinned path BEFORE mounting so a collision is rejected
# without leaving a partial mount. Each mount gets its own manager,
# so a workspace can expose any number of FUSE subtrees at once.
if mountpoint is not None:
self._register_fuse(prefix, mountpoint)
fm = FuseManager()
self._fuse_managers[prefix] = fm
try:
mp = fm.setup(self._ops, prefix, mountpoint)
except Exception:
# The mount never came up; drop the manager and any registered
# path so fuse_mountpoints does not misreport it as live.
self._fuse_managers.pop(prefix, None)
self._deregister_fuse(prefix)
raise
if mountpoint is None:
self._register_fuse(prefix, mp)
return mp
def remove_fuse_mount(self, prefix: str) -> None:
fm = self._fuse_managers.pop(prefix, None)
if fm is not None:
fm.unmount()
self._deregister_fuse(prefix)
@property
def fuse_mountpoint(self) -> str | None:
if not self._fuse_mountpoints:
return None
if len(self._fuse_mountpoints) > 1:
raise RuntimeError(
"multiple FUSE mounts active; use fuse_mountpoints to "
"select one by prefix")
return next(iter(self._fuse_mountpoints.values()))
@property
def fuse_mountpoints(self) -> dict[str, str]:
return dict(self._fuse_mountpoints)
@property
def _cwd(self) -> str:
return self._session_mgr.cwd
@_cwd.setter
def _cwd(self, value: str) -> None:
self._session_mgr.cwd = value
@property
def env(self) -> dict[str, str]:
return self._session_mgr.env
@env.setter
def env(self, value: dict[str, str]) -> None:
self._session_mgr.env = value
@property
def file_prompt(self) -> str:
return build_file_prompt(self._registry.mounts())
# ── lifecycle ───────────────────────────────────────────────────────────
def __enter__(self) -> "Workspace":
self._original_open = builtins.open
self._original_os = sys.modules["os"]
builtins.open = make_open(self._ops)
sys.modules["os"] = make_os_module(self._ops)
return self
def __exit__(self, *_: object) -> None:
builtins.open = self._original_open
sys.modules["os"] = self._original_os
self._close_parts()
def _close_parts(self) -> None:
if self._closed:
return
self._closed = True
for fm in list(self._fuse_managers.values()):
fm.unmount()
self._fuse_managers.clear()
self._fuse_mountpoints.clear()
for job in self.job_table.running_jobs():
self.job_table.kill(job.id)
for task in self._cache._drain_tasks.values():
task.cancel()
self._cache._drain_tasks.clear()
async def close(self) -> None:
drain_tasks = list(self._cache._drain_tasks.values())
self._close_parts()
for task in drain_tasks:
try:
await task
except asyncio.CancelledError:
pass
await self._cache.clear()
# ── snapshot / load / copy ─────────────────────────────────────────────
async def snapshot(self, target, *, compress: str | None = None) -> None:
"""Serialize this workspace to a tar.
Captured:
* Mount configs, sessions, history, finished jobs.
* Cache bytes for fast replay.
* One fingerprint entry per remote read (ETag-equivalent,
plus a backend-specific ``revision`` when the resource
exposes one — e.g. S3 ``VersionId``).
NOT captured:
* Live state of mounts with ``SUPPORTS_SNAPSHOT=False``
(Gmail, Slack, Linear, etc.). Load logs a warning naming
them.
* Files the agent never touched.
* Bytes of remote objects. Recovery of original bytes works
only when the resource accepts a revision pin (S3 family
today) and the recorded revision still exists on the
source.
Async because fingerprint capture stats each touched path on a
``SUPPORTS_SNAPSHOT`` mount.
Args:
target: filesystem path OR a writable file-like object.
compress: None | "gz" | "bz2" | "xz".
"""
await _write_snapshot(self, target, compress=compress)
@classmethod
async def load(
cls,
source,
*,
resources: dict | None = None,
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> "Workspace":
"""Reconstruct a Workspace from a tar.
For every recorded read:
1. If the manifest entry carries a ``revision`` (e.g. S3
``VersionId``), the load installs it into the owning
``mount.revisions``. Replay reads pin to that revision via
the ``revision_for`` contextvar lookup, so the original
bytes are served. Drift check is skipped for these paths —
the pin guarantees bytes match by construction.
2. If the entry carries only a ``fingerprint`` (no stable
revision), the load queues a drift check. STRICT raises
``ContentDriftError`` on the first mismatch; OFF skips the
check entirely and evicts the snapshot cache so reads serve
current state.
Drift check is eager (fires once on the first dispatch or
execute), so downstream code can rely on consistent state.
Args:
source: filesystem path OR a readable file-like object.
resources: {prefix: Resource} overrides for mounts saved
with redacted creds.
drift_policy: STRICT (default) raises on mismatch. OFF
disables drift checking and evicts snapshot cache for
fingerprinted paths.
"""
return await cls.from_state(read_tar(source),
resources=resources,
drift_policy=drift_policy)
@classmethod
async def from_state(
cls,
state: dict,
*,
resources: dict | None = None,
drift_policy: DriftPolicy = DriftPolicy.STRICT) -> "Workspace":
"""Reconstruct a Workspace directly from a state dict (no tar).
The in-process inverse of ``to_state_dict``: build the mounts,
restore content/cache/history, then install drift fingerprints.
``load`` is this plus a tar read; callers that already hold a
state dict (e.g. a version checkout) should use this and skip the
tar round-trip.
Args:
state: a state dict from ``to_state_dict`` or a version.
resources: {prefix: Resource} overrides for mounts saved
with redacted creds.
drift_policy: STRICT (default) raises on mismatch. OFF
disables drift checking and evicts snapshot cache for
fingerprinted paths.
"""
ws = await cls._from_state(state, resources=resources)
install_fingerprints(ws,
state.get(StateKey.FINGERPRINTS) or [],
drift_policy)
live_only = state.get(StateKey.LIVE_ONLY_MOUNTS) or []
if live_only:
logger.warning(
"Workspace.from_state: %s mount(s) opt out of snapshot "
"replay; reads against them will serve current state with "
"no drift detection: %s", len(live_only), live_only)
return ws
async def copy(self) -> "Workspace":
# Reuse this process's resources so remote backends (S3, Redis,
# GDrive) stay shared between original and copy. Local backends
# (RAM, Disk) restore their content fresh into the new resources
# — see snapshot.api.snapshot docstring for the rationale.
# Only reuse resources whose state has redacted secrets or connection
# material. Local content resources (RAM, Disk) are reconstructed
# fresh so the copy's writes don't clobber the original's data.
state = await to_state_dict(self)
auto_prefixes = {"/dev/", norm_mount_prefix(HISTORY_PREFIX)}
prefix_to_resource = {
m.prefix: m.resource
for m in self._registry.mounts() if m.prefix not in auto_prefixes
}
resources = {
m["prefix"]: prefix_to_resource[m["prefix"]]
for m in state["mounts"] if requires_resource_override(m)
and m["prefix"] in prefix_to_resource
}
return await type(self)._from_state(state, resources=resources)
@classmethod
async def _from_state(cls,
state: dict,
*,
resources: dict | None = None) -> "Workspace":
args = build_mount_args(state, resources)
ws = cls(args.mount_args,
consistency=args.consistency,
session_id=args.default_session_id,
agent_id=args.default_agent_id)
await apply_state_dict(ws, state)
return ws
def __deepcopy__(self, memo) -> "Workspace":
raise NotImplementedError(
"Workspace.copy is async (it captures fingerprints for replay). "
"Call `await ws.copy()` directly instead of `copy.deepcopy(ws)`.")
def __copy__(self) -> "Workspace":
raise NotImplementedError("Workspace has no useful shallow copy — "
"use `await ws.copy()`.")
# ── session lifecycle ──────────────────────────────────────────────────
def create_session(
self,
session_id: str,
allowed_mounts: frozenset[str] | None = None) -> Session:
if allowed_mounts is not None:
normalized = {("/" + m.strip("/")) for m in allowed_mounts}
normalized.update(self._infrastructure_mount_prefixes())
allowed_mounts = frozenset(normalized)
return self._session_mgr.create(session_id,
allowed_mounts=allowed_mounts)
def _infrastructure_mount_prefixes(self) -> set[str]:
"""Mount prefixes a session is always allowed to touch.
The virtual root (where text-processing commands like ``wc``
without a path argument resolve), the device mount, and the
history view are infrastructure: they hold no user
credentials, and rejecting them would break common shell
idioms or the history builtin.
"""
prefixes = {"/dev", HISTORY_PREFIX}
root_mount = self._registry.root_mount
if root_mount is not None:
prefixes.add("/" + root_mount.prefix.strip("/"))
return prefixes
def get_session(self, session_id: str) -> Session:
return self._session_mgr.get(session_id)
def list_sessions(self) -> list[Session]:
return self._session_mgr.list()
async def close_session(self, session_id: str) -> None:
await self._session_mgr.close(session_id)
async def close_all_sessions(self) -> None:
await self._session_mgr.close_all()
# ── mount management ────────────────────────────────────────────────────
async def dispatch(self, op: str, path: PathSpec,
**kwargs: Any) -> tuple[Any, IOResult]:
if self._drift_check_pending:
await self._run_pending_drift_check()
return await self._dispatcher.dispatch(op, path, **kwargs)
async def _run_pending_drift_check(self) -> None:
"""Drain the post-load drift check.
Called once on the first async entry point (``dispatch`` or
``execute``) after ``Workspace.load`` with a non-OFF drift
policy. Stats every queued ``(mount, path, expected_fingerprint)``
triple against the live source in parallel and raises
:class:`ContentDriftError` on the first mismatch. Subsequent
calls are no-ops.
Pinned paths (those whose manifest entry carried a stable
revision) are never enqueued, because the pin guarantees bytes
match by construction.
Stats are issued with ``asyncio.gather`` so first-op latency
does not scale linearly with the number of recorded reads.
"""
self._drift_check_pending = False
if not self._pending_drift:
return
checks = [
check_drift(self, path, fingerprint)
for _, path, fingerprint in self._pending_drift
]
self._pending_drift.clear()
results = await asyncio.gather(*checks, return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
raise r
async def stat(self, path: str) -> FileStat:
scope = PathSpec(virtual=path,
directory=path,
resource_path="",
resolved=True)
result, _ = await self.dispatch("stat", scope)
return result
async def readdir(self, path: str) -> list[str]:
scope = PathSpec(virtual=path,
directory=path,
resource_path="",
resolved=False)
raw, _ = await self.dispatch("readdir", scope)
return raw
# ── execution ────────────────────────────────────────────────────────────
async def apply_io(self,
io: IOResult,
records: list[OpRecord] | None = None) -> None:
await self._dispatcher.apply_io(io, records=records)
async def _invalidate_after_write_by_path(self, path: str) -> None:
await self._dispatcher.invalidate_after_write_by_path(path)
def _session_cwd(self, session_id: str) -> str | None:
try:
return self._session_mgr.get(session_id).cwd
except KeyError:
return None
async def _plan_eval_stub(self, cmd: str, **opts: Any) -> IOResult:
"""Inert evaluator for provision walks.
A dry run must never execute: a command substitution with side
effects ($(tee ...)) would otherwise run while "estimating".
Substitutions expand to empty, so affected words degrade the
plan to honest UNKNOWN instead of resolving via execution.
"""
return IOResult()
async def _exec_recursion(self, cancel: asyncio.Event | 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 its own recording context (GNU: history is appended by
# the line reader, the evaluator can't touch it).
return await self.execute(cmd, cancel=cancel, record=False, **opts)
async def execute(
self,
command: str,
session_id: str | None = None,
stdin: AsyncIterator[bytes] | bytes | None = None,
provision: bool = False,
agent_id: str = DEFAULT_AGENT_ID,
cwd: str | None = None,
env: dict[str, str] | None = None,
cancel: asyncio.Event | None = None,
record: bool = True,
) -> IOResult | ProvisionResult:
"""Execute a shell command in the workspace.
Args:
command: The shell command string to execute.
session_id: Session whose persistent state hosts the command.
stdin: Optional stdin payload (bytes or async byte iterator).
provision: If True, return a ProvisionResult instead of running.
agent_id: Agent identifier for observability and history.
cwd: Per-call working directory override. When provided, the
command runs in an ephemeral session clone (bash subshell
semantics): the persistent session's cwd is unchanged and
any `cd` inside the command does not leak.
env: Per-call environment overrides layered on top of the
session's env. Like cwd, these apply only to an ephemeral
clone, so `export` inside the command does not leak back
to the persistent session.
cancel: Optional asyncio.Event used to abort execution
mid-flight. When set, the executor raises MirageAbortError
at the next gate (entry to each node) and races inside
blocking sleeps so cancellation is observed promptly.
record: When False, run without logging a history entry or
opening a recording context; ops emitted by the command
flow into the caller's recorder. Used by the executor's
internal evaluations and available to SDK callers that
need an unrecorded run.
"""
if cancel is not None and cancel.is_set():
raise MirageAbortError()
if self._drift_check_pending:
await self._run_pending_drift_check()
if session_id is None:
session_id = self._session_mgr.default_id
session = self._session_mgr.get(session_id)
use_override = cwd is not None or env is not None
if use_override:
overrides: dict[str, Any] = {}
if cwd is not None:
overrides["cwd"] = cwd
if env is not None:
overrides["env"] = {**session.env, **env}
effective_session = session.fork(**overrides)
else:
effective_session = session
self._current_agent_id = agent_id
io = IOResult()
# The line-reader decision (GNU: history is appended where the
# typed line is read, never inside the evaluator). Internal
# evaluations and provision runs get an inert scope.
is_line = record and not provision
scope = RecordingScope(active=is_line)
exec_recursion = partial(self._exec_recursion, cancel)
session_token = set_current_session(effective_session)
try:
ast = parse(command)
offending = find_syntax_error(ast)
if offending is not None:
snippet = offending.strip()[:40]
err = (f"mirage: syntax error near {snippet!r}\n".encode()
if snippet else b"mirage: syntax error in command\n")
io = IOResult(exit_code=2, stderr=err)
return io
if provision:
prov_name = command.strip().split()[0] if command.strip(
) else None
prov_resolved = (resolve_safeguard(prov_name)
if prov_name else None)
prov_timeout = (prov_resolved.timeout_seconds
if prov_resolved is not None else None)
return await run_with_timeout(
provision_node(self._registry, self.dispatch,
self._plan_eval_stub, self._namespace, ast,
effective_session), prov_timeout, prov_name)
io, _ = await run_command_tree(
self.dispatch,
self._registry,
self._namespace,
self.job_table,
exec_recursion,
self._current_agent_id,
ast,
effective_session,
stdin,
cancel,
)
session.last_exit_code = io.exit_code
await self.apply_io(io, records=scope.records)
return io
except CommandTimeoutError as exc:
logger.debug("command %r timed out after %ss", exc.command,
exc.seconds)
if cancel is not None:
cancel.set()
msg = (str(exc) + "\n").encode()
io = IOResult(exit_code=124, stderr=msg)
session.last_exit_code = 124
return io
except (MirageAbortError, ContentDriftError):
raise
except FindParseError as exc:
msg = f"{exc}\n".encode()
io = IOResult(exit_code=1, stderr=msg)
return io
except UsageError as exc:
msg = f"{exc}\n".encode()
io = IOResult(exit_code=exc.exit_code, stderr=msg)
return io
except OSError as exc:
cmd_name = command.split()[0] if command.split() else command
msg = format_fs_error(cmd_name, exc)
io = IOResult(exit_code=1, stderr=msg)
return io
except Exception as exc:
io = IOResult(exit_code=1, stderr=str(exc).encode())
return io
finally:
# One rule on every path: an op that happened is always
# accounted, in byte accounting (which feeds snapshot
# fingerprints/drift) and as observer op events. The
# command event's exit_code says whether the line that
# emitted them succeeded.
scope.close()
reset_current_session(session_token)
self._ops.records.extend(scope.records)
if is_line:
await self.observer.log_execution(
command, io, scope.records, agent_id, session_id,
self._session_cwd(session_id))