mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-23 05:35:30 +00:00
1142 lines
51 KiB
Python
1142 lines
51 KiB
Python
"""AgentScope DockerWorkspace orchestration for isolated ReMe cases."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import asdict
|
|
from datetime import datetime, timezone
|
|
import gzip
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
from pathlib import PurePosixPath
|
|
import re
|
|
import tarfile
|
|
from typing import Any, Callable, Literal, Protocol
|
|
import uuid
|
|
|
|
from ._cleanup import cleanup_on_error
|
|
from .candidate import ImageCandidate, SourceCandidate
|
|
from .models import ActionRecord, EvaluationQuery, JobRequest, JobResult
|
|
|
|
CASE_ROOT = "/workspace/case"
|
|
CANDIDATE_ROOT = "/workspace/candidate"
|
|
CANDIDATE_VENV = "/workspace/candidate-venv"
|
|
HARNESS_ROOT = "/workspace/harness"
|
|
SOURCE_ARCHIVE = "/workspace/candidate.tar.gz"
|
|
EXPORT_ARCHIVE = "/tmp/reme-case-export.tar.gz"
|
|
WORKSPACE_ARCHIVE = "/tmp/reme-workspace-upload.tar.gz"
|
|
WORKSPACE_EXPORT_ARCHIVE = "/tmp/reme-workspace-export.tar.gz"
|
|
RUNTIME_LAYOUT = f"{CASE_ROOT}/runtime-layout.json"
|
|
_CASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
_CASE_ARCHIVE_FORMAT = "reme-case-v1"
|
|
_CLEANUP_TIMEOUT = 30.0
|
|
_LOG_TIMEOUT = 5.0
|
|
_PRIVATE_EVALUATION_FIELDS = frozenset(
|
|
{"query", "question", "golden_answer", "question_type", "rubric", "judge_arguments"},
|
|
)
|
|
|
|
|
|
class Backend(Protocol):
|
|
"""Subset of AgentScope's backend API used by this module."""
|
|
|
|
async def exec_shell(
|
|
self,
|
|
command: list[str],
|
|
*,
|
|
cwd: str | None = None,
|
|
timeout: float | None = None,
|
|
) -> Any:
|
|
"""Execute an argv vector in the sandbox."""
|
|
raise NotImplementedError
|
|
|
|
async def read_file(self, path: str) -> bytes:
|
|
"""Read a sandbox file."""
|
|
raise NotImplementedError
|
|
|
|
async def write_file(self, path: str, data: bytes) -> None:
|
|
"""Write a sandbox file."""
|
|
raise NotImplementedError
|
|
|
|
|
|
class Workspace(Protocol):
|
|
"""Subset of AgentScope's DockerWorkspace used by this module."""
|
|
|
|
async def initialize(self) -> None:
|
|
"""Start the workspace."""
|
|
raise NotImplementedError
|
|
|
|
async def close(self) -> None:
|
|
"""Close the workspace."""
|
|
raise NotImplementedError
|
|
|
|
async def stop(self) -> None:
|
|
"""Force-stop container processes while keeping files available."""
|
|
raise NotImplementedError
|
|
|
|
def get_backend(self) -> Backend:
|
|
"""Return the execution backend."""
|
|
raise NotImplementedError
|
|
|
|
|
|
WorkspaceBuilder = Callable[[str, str, dict[str, str]], Workspace]
|
|
|
|
|
|
class SandboxCommandError(RuntimeError):
|
|
"""A required command failed inside a case sandbox."""
|
|
|
|
|
|
class SandboxCommandTimeoutError(SandboxCommandError):
|
|
"""A command timed out; cleanup failures are attached as exception notes."""
|
|
|
|
|
|
def _validate_case_id(case_id: str) -> None:
|
|
"""Reject identifiers that are unsafe for Docker names and manifests."""
|
|
if not _CASE_ID_RE.fullmatch(case_id):
|
|
raise ValueError("case_id must contain only letters, numbers, '.', '_' or '-' and be <= 128 characters")
|
|
|
|
|
|
def _utc_now() -> str:
|
|
"""Return an audit-friendly UTC timestamp."""
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _decode(value: bytes | str | None) -> str:
|
|
"""Decode backend process output without losing diagnostics."""
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace")
|
|
return value or ""
|
|
|
|
|
|
def _workspace_member_path(name: str) -> PurePosixPath | None:
|
|
"""Validate and normalize one relative path in a workspace archive."""
|
|
path = PurePosixPath(name)
|
|
if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
|
|
raise ValueError(f"workspace archive contains unsafe path: {name!r}")
|
|
if path == PurePosixPath("."):
|
|
return None
|
|
return path
|
|
|
|
|
|
def _case_archive_workspace_root(
|
|
archive: tarfile.TarFile,
|
|
members: dict[PurePosixPath, tarfile.TarInfo],
|
|
) -> PurePosixPath | None:
|
|
"""Distinguish marked case artifacts from raw workspace contents."""
|
|
workspace_root = PurePosixPath("reme_workspace")
|
|
has_workspace = any(path.parts[0] == workspace_root.name for path in members)
|
|
manifest = members.get(PurePosixPath("manifest.json"))
|
|
if manifest is None:
|
|
if has_workspace:
|
|
raise ValueError(
|
|
"unmarked case archive; upload its reme_workspace directory or recreate it with a current case export",
|
|
)
|
|
return None
|
|
if not manifest.isfile():
|
|
raise ValueError("workspace archive manifest.json must be a regular file")
|
|
content = archive.extractfile(manifest)
|
|
assert content is not None
|
|
try:
|
|
payload = json.loads(content.read())
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
if has_workspace:
|
|
raise ValueError("case archive contains an invalid manifest.json") from exc
|
|
return None # A raw workspace may contain an unrelated manifest file.
|
|
if not isinstance(payload, dict) or "archive_format" not in payload:
|
|
if has_workspace and not isinstance(payload, dict):
|
|
raise ValueError("case archive manifest.json must be an object")
|
|
# Older full case exports have a manifest without archive_format.
|
|
return workspace_root if has_workspace else None
|
|
if payload["archive_format"] != _CASE_ARCHIVE_FORMAT:
|
|
raise ValueError(f"unsupported case archive format: {payload['archive_format']!r}")
|
|
root = payload.get("workspace_root")
|
|
if root is None:
|
|
raise ValueError("case archive has no reusable workspace")
|
|
if root != workspace_root.as_posix():
|
|
raise ValueError(f"case archive has an invalid workspace root: {root!r}")
|
|
if not has_workspace:
|
|
raise ValueError("case archive workspace is missing")
|
|
return workspace_root
|
|
|
|
|
|
def _archive_workspace(source: str | Path) -> bytes:
|
|
"""Create a safe, deterministic gzip tar archive from a host workspace.
|
|
|
|
``source`` may be a workspace directory or an archive previously returned
|
|
by :meth:`DockerReMeSandbox.export_workspace`, or a marked case archive
|
|
containing a workspace. Incoming archives are read and rebuilt so links,
|
|
special files, and traversal paths never reach the sandbox extractor.
|
|
"""
|
|
supplied_path = Path(source).expanduser()
|
|
if supplied_path.is_symlink():
|
|
raise ValueError("workspace source must not be a symbolic link")
|
|
source_path = supplied_path.resolve(strict=True)
|
|
entries: list[tuple[tarfile.TarInfo, bytes | None]] = []
|
|
if source_path.is_dir():
|
|
for item in sorted(source_path.rglob("*"), key=lambda value: value.relative_to(source_path).as_posix()):
|
|
relative = item.relative_to(source_path)
|
|
if item.is_symlink():
|
|
raise ValueError(f"workspace source contains a symbolic link: {relative}")
|
|
path = _workspace_member_path(relative.as_posix())
|
|
assert path is not None
|
|
info = tarfile.TarInfo(path.as_posix())
|
|
info.mode = item.stat().st_mode & 0o777
|
|
if item.is_dir():
|
|
info.type = tarfile.DIRTYPE
|
|
data = None
|
|
elif item.is_file():
|
|
data = item.read_bytes()
|
|
info.size = len(data)
|
|
else:
|
|
raise ValueError(f"workspace source contains unsupported file: {relative}")
|
|
entries.append((info, data))
|
|
elif source_path.is_file():
|
|
with tarfile.open(source_path, mode="r:*") as archive:
|
|
members: dict[PurePosixPath, tarfile.TarInfo] = {}
|
|
for member in archive.getmembers():
|
|
path = _workspace_member_path(member.name)
|
|
if path is None:
|
|
continue
|
|
if path in members:
|
|
raise ValueError(f"workspace archive contains duplicate path: {path}")
|
|
members[path] = member
|
|
workspace_root = _case_archive_workspace_root(archive, members)
|
|
for path, member in members.items():
|
|
if workspace_root is not None:
|
|
if path != workspace_root and workspace_root not in path.parents:
|
|
continue
|
|
if path == workspace_root:
|
|
if not member.isdir():
|
|
raise ValueError("case export has a non-directory reme_workspace entry")
|
|
continue
|
|
path = path.relative_to(workspace_root)
|
|
info = tarfile.TarInfo(path.as_posix())
|
|
info.mode = member.mode & 0o777
|
|
if member.isdir():
|
|
info.type = tarfile.DIRTYPE
|
|
data = None
|
|
elif member.isfile():
|
|
content = archive.extractfile(member)
|
|
if content is None:
|
|
raise ValueError(f"workspace archive cannot read file: {path}")
|
|
data = content.read()
|
|
info.size = len(data)
|
|
else:
|
|
raise ValueError(f"workspace archive contains unsupported entry: {member.name!r}")
|
|
entries.append((info, data))
|
|
else:
|
|
raise ValueError(f"workspace source is neither a directory nor a file: {source_path}")
|
|
|
|
raw = io.BytesIO()
|
|
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as compressed:
|
|
with tarfile.open(fileobj=compressed, mode="w") as archive:
|
|
for info, data in entries:
|
|
info.mtime = 0
|
|
info.uid = info.gid = 0
|
|
info.uname = info.gname = ""
|
|
archive.addfile(info, io.BytesIO(data) if data is not None else None)
|
|
return raw.getvalue()
|
|
|
|
|
|
def _default_workspace_builder(workspace_id: str, image: str, env: dict[str, str]) -> Workspace:
|
|
"""Create the AgentScope 2.0.9 Docker backend lazily."""
|
|
import agentscope
|
|
|
|
from .direct_docker_workspace import DirectDockerWorkspace
|
|
|
|
if agentscope.__version__ != "2.0.9":
|
|
raise RuntimeError(f"benchmark Docker host requires agentscope 2.0.9, got {agentscope.__version__}")
|
|
return DirectDockerWorkspace(
|
|
workspace_id=workspace_id,
|
|
base_image=image,
|
|
env=env,
|
|
)
|
|
|
|
|
|
class DockerReMeSandboxFactory:
|
|
"""Create independent case sandboxes that share one candidate definition.
|
|
|
|
A :class:`SourceCandidate` stores one immutable source archive in host
|
|
memory. The same bytes are uploaded to every case, while each case gets a
|
|
new Docker container and a fresh ReMe runtime workspace. An
|
|
:class:`ImageCandidate` skips source upload and uses an image in which ReMe
|
|
is already installed.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
candidate: SourceCandidate | ImageCandidate,
|
|
*,
|
|
env: dict[str, str] | None = None,
|
|
config: str = "benchmark.yaml",
|
|
plugins: tuple[str, ...] | list[str] = (),
|
|
command_timeout: float = 1800.0,
|
|
workspace_builder: WorkspaceBuilder | None = None,
|
|
) -> None:
|
|
self.candidate = candidate
|
|
self.env = dict(env or {})
|
|
self.config = config
|
|
self.plugins = tuple(plugins)
|
|
if not all(isinstance(name, str) and name for name in self.plugins):
|
|
raise TypeError("sandbox plugins must be non-empty strings")
|
|
if len(self.plugins) != len(set(self.plugins)):
|
|
raise ValueError("sandbox plugins must be unique")
|
|
self.command_timeout = command_timeout
|
|
self._workspace_builder = workspace_builder or _default_workspace_builder
|
|
self._workspace_prefix = f"reme-benchmark-{uuid.uuid4().hex[:12]}"
|
|
self._workspace_sequence = 0
|
|
|
|
def _next_workspace_id(self) -> str:
|
|
"""Allocate a factory-scoped container identity independent of case IDs."""
|
|
self._workspace_sequence += 1
|
|
return f"{self._workspace_prefix}-{self._workspace_sequence:04d}"
|
|
|
|
async def create_case(self, case_id: str) -> "DockerReMeSandbox":
|
|
"""Start and prepare a new container for one benchmark case."""
|
|
_validate_case_id(case_id)
|
|
image = self.candidate.base_image if isinstance(self.candidate, SourceCandidate) else self.candidate.image
|
|
container_id = self._next_workspace_id()
|
|
workspace = self._workspace_builder(container_id, image, self.env)
|
|
case = DockerReMeSandbox(
|
|
case_id=case_id,
|
|
container_id=container_id,
|
|
candidate=self.candidate,
|
|
workspace=workspace,
|
|
config=self.config,
|
|
plugins=self.plugins,
|
|
command_timeout=self.command_timeout,
|
|
environment_names=sorted(self.env),
|
|
)
|
|
try:
|
|
await case.initialize()
|
|
except BaseException as exc:
|
|
await cleanup_on_error([(f"workspace {container_id!r}", workspace.close())], exc)
|
|
raise
|
|
return case
|
|
|
|
async def create_cases(self, case_ids: list[str], *, concurrency: int = 4) -> list["DockerReMeSandbox"]:
|
|
"""Create several independent cases while bounding Docker pressure."""
|
|
if concurrency < 1:
|
|
raise ValueError("concurrency must be at least 1")
|
|
if len(case_ids) != len(set(case_ids)):
|
|
raise ValueError("case_ids must be unique")
|
|
semaphore = asyncio.Semaphore(concurrency)
|
|
|
|
async def create(case_id: str) -> DockerReMeSandbox | BaseException:
|
|
try:
|
|
async with semaphore:
|
|
return await self.create_case(case_id)
|
|
except BaseException as exc:
|
|
# Preserve cleanup notes on CancelledError instead of letting
|
|
# gather replace it with a fresh cancellation exception.
|
|
return exc
|
|
|
|
tasks = [asyncio.create_task(create(case_id)) for case_id in case_ids]
|
|
error: BaseException | None = None
|
|
try:
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
except BaseException as exc:
|
|
error = exc
|
|
for task in tasks:
|
|
if not task.done() and not task.cancelling():
|
|
task.cancel()
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
created = [result for result in results if isinstance(result, DockerReMeSandbox)]
|
|
failures = [result for result in results if isinstance(result, BaseException)]
|
|
if error is None:
|
|
if not failures:
|
|
return created
|
|
error = failures.pop(0)
|
|
for failure in failures:
|
|
error.add_note(f"additional creation failure: {type(failure).__name__}: {failure}")
|
|
for failure in failures:
|
|
for note in getattr(failure, "__notes__", ()):
|
|
error.add_note(note)
|
|
await cleanup_on_error(((f"workspace {case.container_id!r}", case.close()) for case in created), error)
|
|
raise error
|
|
|
|
|
|
class DockerReMeSandbox:
|
|
"""One isolated Docker container and one fresh ReMe runtime workspace."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
case_id: str,
|
|
container_id: str,
|
|
candidate: SourceCandidate | ImageCandidate,
|
|
workspace: Workspace,
|
|
config: str,
|
|
plugins: tuple[str, ...],
|
|
command_timeout: float,
|
|
environment_names: list[str],
|
|
) -> None:
|
|
self.case_id = case_id
|
|
self.container_id = container_id
|
|
self.candidate = candidate
|
|
self.workspace = workspace
|
|
self.config = config
|
|
self.plugins = plugins
|
|
self.command_timeout = command_timeout
|
|
self.environment_names = environment_names
|
|
self.backend: Backend | None = None
|
|
self.python = "python3"
|
|
self.actions: list[ActionRecord] = []
|
|
self._closed = False
|
|
self._reusable = True
|
|
self._operation_lock = asyncio.Lock()
|
|
self._build_log: bytes | None = None
|
|
|
|
@property
|
|
def reusable(self) -> bool:
|
|
"""Whether another case can safely use this container."""
|
|
return self._reusable and not self._closed
|
|
|
|
@property
|
|
def runtime_workspace(self) -> str:
|
|
"""ReMe-owned runtime workspace inside this case container."""
|
|
return f"{CASE_ROOT}/reme_workspace"
|
|
|
|
@property
|
|
def logs_dir(self) -> str:
|
|
"""Directory containing ReMe and orchestration logs."""
|
|
return f"{CASE_ROOT}/logs"
|
|
|
|
@property
|
|
def results_dir(self) -> str:
|
|
"""Directory containing job, answer and score artifacts."""
|
|
return f"{CASE_ROOT}/results"
|
|
|
|
async def initialize(self) -> None:
|
|
"""Start Docker, upload the worker, and prepare the selected candidate."""
|
|
await self.workspace.initialize()
|
|
self.backend = self.workspace.get_backend()
|
|
await self._create_case_layout()
|
|
worker = Path(__file__).with_name("worker.py").read_bytes()
|
|
await self._write_file(f"{HARNESS_ROOT}/worker.py", worker)
|
|
|
|
if isinstance(self.candidate, SourceCandidate):
|
|
await self._install_source_candidate()
|
|
else:
|
|
await self._validate_candidate(self.python)
|
|
|
|
async def _create_case_layout(self) -> None:
|
|
"""Create the disposable directories owned by the active case."""
|
|
await self._exec_checked(
|
|
"create_case_layout",
|
|
[
|
|
"mkdir",
|
|
"-p",
|
|
f"{CASE_ROOT}/inbox",
|
|
f"{CASE_ROOT}/tmp",
|
|
self.runtime_workspace,
|
|
self.logs_dir,
|
|
self.results_dir,
|
|
f"{CASE_ROOT}/build_log",
|
|
f"{CASE_ROOT}/queries",
|
|
HARNESS_ROOT,
|
|
],
|
|
)
|
|
await self._exec_checked(
|
|
"initialize_memory_history",
|
|
["git", "init", "--quiet"],
|
|
cwd=self.runtime_workspace,
|
|
)
|
|
|
|
async def _install_source_candidate(self) -> None:
|
|
assert self.backend is not None
|
|
await self._write_file(SOURCE_ARCHIVE, self.candidate.snapshot.archive)
|
|
await self._exec_checked("create_candidate_dir", ["mkdir", "-p", CANDIDATE_ROOT])
|
|
await self._exec_checked(
|
|
"extract_candidate",
|
|
["tar", "-xzf", SOURCE_ARCHIVE, "-C", CANDIDATE_ROOT],
|
|
)
|
|
await self._exec_checked(
|
|
"create_candidate_venv",
|
|
["python3", "-m", "venv", "--system-site-packages", CANDIDATE_VENV],
|
|
)
|
|
self.python = f"{CANDIDATE_VENV}/bin/python"
|
|
for index, project in enumerate(self.candidate.install_projects):
|
|
project_path = CANDIDATE_ROOT if project == "." else f"{CANDIDATE_ROOT}/{project}"
|
|
await self._exec_checked(
|
|
"install_candidate" if index == 0 else f"install_candidate:{project}",
|
|
[
|
|
self.python,
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--no-deps",
|
|
"--no-build-isolation",
|
|
"--editable",
|
|
project_path,
|
|
],
|
|
)
|
|
await self._validate_candidate(self.python)
|
|
|
|
async def _validate_candidate(self, python: str) -> None:
|
|
plugin_names = json.dumps(self.plugins)
|
|
script = (
|
|
"import agentscope, importlib, json, reme; "
|
|
"from importlib.metadata import entry_points; "
|
|
"from reme.plugin import PluginManager; "
|
|
f"names={plugin_names}; "
|
|
"eps=entry_points().select(group='reme.plugins'); "
|
|
"selected={name: next((ep for ep in eps if ep.name == name), None) for name in names}; "
|
|
"PluginManager.discover(names); "
|
|
"paths={name: importlib.import_module(ep.value.split(':', 1)[0]).__file__ "
|
|
"for name, ep in selected.items() if ep is not None}; "
|
|
"print(json.dumps({'reme': reme.__file__, 'agentscope': agentscope.__version__, 'plugins': paths}))"
|
|
)
|
|
result = await self._exec_checked("validate_candidate", [python, "-c", script])
|
|
validation = json.loads(_decode(result.stdout))
|
|
if validation.get("agentscope") != "2.0.9":
|
|
raise SandboxCommandError(
|
|
f"candidate environment requires agentscope 2.0.9, got {validation.get('agentscope')!r}",
|
|
)
|
|
candidate_root = PurePosixPath(CANDIDATE_ROOT)
|
|
|
|
def is_candidate_path(value: Any) -> bool:
|
|
return isinstance(value, str) and candidate_root in PurePosixPath(value).parents
|
|
|
|
if isinstance(self.candidate, SourceCandidate) and not is_candidate_path(validation.get("reme")):
|
|
raise SandboxCommandError(
|
|
f"candidate import did not resolve under {CANDIDATE_ROOT}: {validation.get('reme')}",
|
|
)
|
|
plugin_paths = validation.get("plugins")
|
|
if not isinstance(plugin_paths, dict) or set(plugin_paths) != set(self.plugins):
|
|
expected = list(self.plugins)
|
|
raise SandboxCommandError(
|
|
f"candidate did not expose the requested plugins: expected {expected!r}, got {plugin_paths!r}",
|
|
)
|
|
if isinstance(self.candidate, SourceCandidate):
|
|
escaped = {name: path for name, path in plugin_paths.items() if not is_candidate_path(path)}
|
|
if escaped:
|
|
raise SandboxCommandError(f"candidate plugin imports did not resolve under {CANDIDATE_ROOT}: {escaped}")
|
|
|
|
async def _write_file(self, path: str, data: bytes) -> None:
|
|
"""Reject writes after an interrupted or uncertain Docker upload."""
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
if not self.reusable:
|
|
raise SandboxCommandError("sandbox container is no longer reusable")
|
|
try:
|
|
await self.backend.write_file(path, data)
|
|
except (Exception, asyncio.CancelledError):
|
|
# Docker may have accepted the upload before the client failed.
|
|
self._reusable = False
|
|
raise
|
|
|
|
async def _exec(self, name: str, command: list[str], *, cwd: str | None = None) -> Any:
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
if not self.reusable:
|
|
raise SandboxCommandError("sandbox container is no longer reusable")
|
|
started = _utc_now()
|
|
try:
|
|
async with asyncio.timeout(self.command_timeout):
|
|
result = await self.backend.exec_shell(command, cwd=cwd, timeout=self.command_timeout)
|
|
except TimeoutError as exc:
|
|
error = SandboxCommandTimeoutError(f"sandbox action {name!r} timed out")
|
|
await cleanup_on_error([(f"container {self.container_id!r}", self._abort())], error)
|
|
raise error from exc
|
|
except (Exception, asyncio.CancelledError) as exc:
|
|
# A backend failure may occur after Docker started the command.
|
|
await cleanup_on_error([(f"container {self.container_id!r}", self._abort())], exc)
|
|
raise
|
|
self.actions.append(
|
|
ActionRecord(
|
|
name=name,
|
|
exit_code=int(result.exit_code),
|
|
stdout=_decode(result.stdout),
|
|
stderr=_decode(result.stderr),
|
|
started_at=started,
|
|
finished_at=_utc_now(),
|
|
),
|
|
)
|
|
if result.exit_code == -1:
|
|
error = SandboxCommandTimeoutError(f"sandbox action {name!r} timed out")
|
|
await cleanup_on_error([(f"container {self.container_id!r}", self._abort())], error)
|
|
raise error
|
|
return result
|
|
|
|
async def _abort(self) -> None:
|
|
"""Retire an uncertain command while its caller holds the operation lock."""
|
|
self._reusable = False
|
|
error = None
|
|
try:
|
|
await asyncio.wait_for(self.workspace.stop(), timeout=_CLEANUP_TIMEOUT)
|
|
# Read files only after the candidate has stopped. Diagnostics must
|
|
# never delay termination or prevent container removal.
|
|
try:
|
|
assert self.backend is not None
|
|
self._build_log = await asyncio.wait_for(
|
|
self.backend.read_file(f"{CASE_ROOT}/build_log/build.log"),
|
|
timeout=_LOG_TIMEOUT,
|
|
)
|
|
except Exception:
|
|
pass
|
|
except BaseException as exc:
|
|
error = exc
|
|
raise
|
|
finally:
|
|
if error is None:
|
|
await self._close_workspace()
|
|
else:
|
|
await cleanup_on_error([("container removal", self._close_workspace())], error)
|
|
|
|
async def _exec_checked(self, name: str, command: list[str], *, cwd: str | None = None) -> Any:
|
|
result = await self._exec(name, command, cwd=cwd)
|
|
if not result.ok():
|
|
raise SandboxCommandError(
|
|
f"sandbox action {name!r} failed with exit {result.exit_code}: {_decode(result.stderr)}",
|
|
)
|
|
return result
|
|
|
|
async def _run_job(self, job: str, arguments: dict[str, Any] | None = None) -> JobResult:
|
|
"""Run one ReMe job while the caller holds the operation lock."""
|
|
response = await self._run_worker_request("job", {"job": job, "arguments": arguments or {}})
|
|
return JobResult.from_dict(response)
|
|
|
|
async def _run_worker_request(self, mode: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Run one structured worker request and return its JSON response."""
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
request_id = uuid.uuid4().hex
|
|
request_path = f"{CASE_ROOT}/inbox/{request_id}.json"
|
|
response_path = f"{self.results_dir}/{request_id}.json"
|
|
request = {
|
|
"mode": mode,
|
|
"config": self.config,
|
|
"plugins": list(self.plugins),
|
|
"case_id": self.case_id,
|
|
"case_root": CASE_ROOT,
|
|
"workspace_dir": self.runtime_workspace,
|
|
**payload,
|
|
}
|
|
await self._write_file(request_path, json.dumps(request, ensure_ascii=False).encode("utf-8"))
|
|
action_name = f"job:{payload['job']}" if mode == "job" else f"worker:{mode}"
|
|
await self._exec(
|
|
action_name,
|
|
[self.python, f"{HARNESS_ROOT}/worker.py", "--request", request_path, "--response", response_path],
|
|
cwd=CASE_ROOT,
|
|
)
|
|
try:
|
|
result = json.loads((await self.backend.read_file(response_path)).decode("utf-8"))
|
|
except FileNotFoundError as exc:
|
|
description = f"job {payload['job']!r}" if mode == "job" else f"worker mode {mode!r}"
|
|
raise SandboxCommandError(f"{description} did not produce {response_path}") from exc
|
|
if not isinstance(result, dict):
|
|
raise SandboxCommandError(f"worker mode {mode!r} returned a non-object response")
|
|
return result
|
|
|
|
async def run_job(self, job: str, arguments: dict[str, Any] | None = None) -> JobResult:
|
|
"""Run one ReMe job without overlapping another operation."""
|
|
async with self._operation_lock:
|
|
return await self._run_job(job, arguments)
|
|
|
|
async def run_build(self, jobs: list[JobRequest | dict[str, Any]]) -> dict[str, Any]:
|
|
"""Run all construction jobs in one Application and capture ``build.log``."""
|
|
serialized: list[dict[str, Any]] = []
|
|
for specification in jobs:
|
|
value = asdict(specification) if isinstance(specification, JobRequest) else dict(specification)
|
|
if not isinstance(value.get("job"), str) or not value["job"]:
|
|
raise ValueError("every build job requires a non-empty job name")
|
|
if not isinstance(value.get("arguments", {}), dict):
|
|
raise ValueError("build job arguments must be an object")
|
|
leaked = _PRIVATE_EVALUATION_FIELDS.intersection(value.get("arguments", {}))
|
|
if leaked:
|
|
names = ", ".join(sorted(leaked))
|
|
raise ValueError(f"construction job arguments contain private query fields: {names}")
|
|
checkpoint = value.get("memory_checkpoint")
|
|
if checkpoint is not None and (not isinstance(checkpoint, str) or not checkpoint.strip()):
|
|
raise ValueError("build job memory_checkpoint must be a non-empty string or null")
|
|
serialized.append(value)
|
|
if not serialized:
|
|
raise ValueError("run_build requires at least one job")
|
|
async with self._operation_lock:
|
|
return await self._run_worker_request("build", {"jobs": serialized})
|
|
|
|
@staticmethod
|
|
def _validate_query_artifact_id(query_id: str) -> None:
|
|
"""Keep verbatim query-directory names inside the artifact root."""
|
|
if not query_id or len(query_id.encode("utf-8")) > 255:
|
|
raise ValueError("query_id must be non-empty and no longer than 255 UTF-8 bytes")
|
|
if query_id in {".", ".."} or "/" in query_id or "\\" in query_id or "\x00" in query_id:
|
|
raise ValueError(f"query_id is not a safe directory name: {query_id!r}")
|
|
if query_id == "summary.json":
|
|
raise ValueError("query_id conflicts with the queries summary: 'summary.json'")
|
|
|
|
async def run_queries(self, queries: list[EvaluationQuery | dict[str, Any]]) -> dict[str, Any]:
|
|
"""Answer multiple queries in one candidate Application with isolated logs."""
|
|
serialized, query_ids = self._serialize_queries(queries)
|
|
if not serialized:
|
|
raise ValueError("run_queries requires at least one query")
|
|
if len(query_ids) != len(set(query_ids)):
|
|
raise ValueError("query IDs must be unique")
|
|
async with self._operation_lock:
|
|
return await self._run_worker_request("queries", {"queries": serialized})
|
|
|
|
async def run_query(self, query: EvaluationQuery | dict[str, Any]) -> dict[str, Any]:
|
|
"""Answer one append-only query for a host-side lease."""
|
|
|
|
serialized, _ = self._serialize_queries([query])
|
|
async with self._operation_lock:
|
|
response = await self._run_worker_request("query", {"queries": serialized})
|
|
results = response.get("queries")
|
|
if not isinstance(results, list) or len(results) != 1 or not isinstance(results[0], dict):
|
|
raise SandboxCommandError("single-query worker returned an invalid result")
|
|
return results[0]
|
|
|
|
def _serialize_queries(
|
|
self,
|
|
queries: list[EvaluationQuery | dict[str, Any]],
|
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
"""Validate and serialize query payloads shared by batch and lease APIs."""
|
|
|
|
serialized: list[dict[str, Any]] = []
|
|
query_ids: list[str] = []
|
|
for query in queries:
|
|
value = (
|
|
{
|
|
"query_id": query.query_id,
|
|
"question": query.question,
|
|
"answer_arguments": query.answer_arguments,
|
|
"answer_job": query.answer_job,
|
|
}
|
|
if isinstance(query, EvaluationQuery)
|
|
else dict(query)
|
|
)
|
|
query_id = value.get("query_id")
|
|
if not isinstance(query_id, str):
|
|
raise ValueError("every evaluation query requires a string query_id")
|
|
self._validate_query_artifact_id(query_id)
|
|
if not isinstance(value.get("question"), str):
|
|
raise ValueError(f"query {query_id!r} requires a string question")
|
|
answer_arguments = value.get("answer_arguments") or {}
|
|
if not isinstance(answer_arguments, dict):
|
|
raise ValueError(f"query {query_id!r} answer_arguments must be an object")
|
|
leaked = _PRIVATE_EVALUATION_FIELDS.intersection(answer_arguments)
|
|
if leaked:
|
|
names = ", ".join(sorted(leaked))
|
|
raise ValueError(f"query {query_id!r} answer_arguments contain private judge fields: {names}")
|
|
query_ids.append(query_id)
|
|
# This allowlist is a security boundary. Golden answers, rubrics,
|
|
# question types and scoring configuration remain on the host and
|
|
# are not written to the candidate container's inbox.
|
|
serialized.append(
|
|
{
|
|
"query_id": query_id,
|
|
"question": value["question"],
|
|
"answer_arguments": dict(answer_arguments),
|
|
"answer_job": str(value.get("answer_job") or "agentic_answer"),
|
|
},
|
|
)
|
|
return serialized, query_ids
|
|
|
|
async def ingest_session(
|
|
self,
|
|
*,
|
|
messages: list[dict[str, Any]],
|
|
session_id: str,
|
|
date: str = "",
|
|
memory_hint: str = "",
|
|
update_index: bool = True,
|
|
) -> JobResult:
|
|
"""Add one session, optionally making it searchable immediately."""
|
|
async with self._operation_lock:
|
|
result = await self._run_job(
|
|
"auto_memory",
|
|
{"messages": messages, "session_id": session_id, "date": date, "memory_hint": memory_hint},
|
|
)
|
|
if result.success and update_index:
|
|
index_result = await self._run_job("index_update")
|
|
if not index_result.success:
|
|
return index_result
|
|
return result
|
|
|
|
async def commit_memory_history(self, message: str) -> None:
|
|
"""Create a host-requested checkpoint containing durable file-native memory."""
|
|
if not isinstance(message, str) or not message.strip():
|
|
raise ValueError("memory history commit message must be a non-empty string")
|
|
async with self._operation_lock:
|
|
result = await self._run_worker_request("checkpoint", {"message": message})
|
|
if result.get("success") is not True:
|
|
raise SandboxCommandError(result.get("error") or "memory history checkpoint failed")
|
|
|
|
async def answer(self, *, query: str, query_time: str = "") -> JobResult:
|
|
"""Generate an answer with the candidate's agentic_answer job."""
|
|
async with self._operation_lock:
|
|
result = await self._run_job("agentic_answer", {"query": query, "query_time": query_time})
|
|
await self._write_file(
|
|
f"{self.results_dir}/answer.json",
|
|
json.dumps(asdict(result), ensure_ascii=False, default=str).encode("utf-8"),
|
|
)
|
|
return result
|
|
|
|
async def export(
|
|
self,
|
|
destination: str | Path,
|
|
*,
|
|
profile: Literal["analysis", "full"] = "analysis",
|
|
include_candidate: bool = False,
|
|
) -> Path:
|
|
"""Download the case archive and, for analysis, separate build/query logs.
|
|
|
|
Analysis logs are saved under ``<destination>.artifacts`` without
|
|
changing the archive's contents.
|
|
"""
|
|
if profile not in ("analysis", "full"):
|
|
raise ValueError("export profile must be 'analysis' or 'full'")
|
|
async with self._operation_lock:
|
|
return await self._export(destination, profile=profile, include_candidate=include_candidate)
|
|
|
|
async def export_full(self, destination: str | Path, *, include_candidate: bool = False) -> Path:
|
|
"""Download every file under the disposable case root."""
|
|
return await self.export(destination, profile="full", include_candidate=include_candidate)
|
|
|
|
async def export_workspace(self, destination: str | Path) -> Path:
|
|
"""Download a portable snapshot of this runtime workspace to the host.
|
|
|
|
The resulting gzip tar archive can be passed directly to
|
|
:meth:`upload_workspace` on any initialized case. It contains the
|
|
workspace contents (including its local Git history), not the enclosing
|
|
case directory, logs, or candidate source.
|
|
"""
|
|
async with self._operation_lock:
|
|
return await self._export_archive(
|
|
destination,
|
|
"export_workspace",
|
|
self.runtime_workspace,
|
|
["."],
|
|
archive_path=WORKSPACE_EXPORT_ARCHIVE,
|
|
)
|
|
|
|
async def export_build_log(self, destination: str | Path) -> Path:
|
|
"""Download the memory-construction log without wrapping it in an archive."""
|
|
async with self._operation_lock:
|
|
content = await self._read_build_log()
|
|
return self._save_host_file(destination, content)
|
|
|
|
async def read_build_log(self) -> str:
|
|
"""Return the build log, including when construction failed."""
|
|
async with self._operation_lock:
|
|
return (await self._read_build_log()).decode("utf-8", errors="replace")
|
|
|
|
async def _read_build_log(self) -> bytes:
|
|
if self._build_log is not None:
|
|
return self._build_log
|
|
if not self.reusable:
|
|
raise SandboxCommandError("build log unavailable after interrupted command")
|
|
return await self._require_artifact(f"{CASE_ROOT}/build_log/build.log", "build log")
|
|
|
|
async def _require_artifact(self, path: str, description: str) -> bytes:
|
|
"""Read a required sandbox artifact, preserving its export-specific error."""
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
try:
|
|
return await self.backend.read_file(path)
|
|
except FileNotFoundError as exc:
|
|
raise SandboxCommandError(f"{description} is missing required artifact: {path}") from exc
|
|
|
|
async def export_memory_construction(self, destination: str | Path) -> Path:
|
|
"""Download the workspace and build log; reusable with upload_workspace."""
|
|
async with self._operation_lock:
|
|
return await self._export_archive(
|
|
destination,
|
|
"export_memory_construction",
|
|
CASE_ROOT,
|
|
["reme_workspace", "build_log"],
|
|
required=("build_log/build.log", "memory construction export"),
|
|
)
|
|
|
|
async def export_queries(self, destination: str | Path) -> Path:
|
|
"""Download per-query logs, results, and their aggregate summary."""
|
|
async with self._operation_lock:
|
|
return await self._export_archive(
|
|
destination,
|
|
"export_queries",
|
|
CASE_ROOT,
|
|
["queries"],
|
|
required=("queries/summary.json", "query export"),
|
|
)
|
|
|
|
async def export_query(self, query_id: str, destination: str | Path) -> Path:
|
|
"""Download one leased query's logs and result without a case summary."""
|
|
|
|
self._validate_query_artifact_id(query_id)
|
|
async with self._operation_lock:
|
|
return await self._export_archive(
|
|
destination,
|
|
"export_query",
|
|
CASE_ROOT,
|
|
[f"queries/{query_id}"],
|
|
required=(f"queries/{query_id}/result.json", "query export"),
|
|
)
|
|
|
|
async def export_evaluation(self, destination: str | Path) -> Path:
|
|
"""Download reusable workspace and query artifacts, plus any build log."""
|
|
async with self._operation_lock:
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
await self._require_artifact(f"{CASE_ROOT}/queries/summary.json", "evaluation export")
|
|
members = ["reme_workspace", "queries"]
|
|
try:
|
|
await self.backend.read_file(f"{CASE_ROOT}/build_log/build.log")
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
members.append("build_log")
|
|
return await self._export_archive(destination, "export_evaluation", CASE_ROOT, members)
|
|
|
|
@staticmethod
|
|
def _save_host_file(destination: str | Path, content: bytes) -> Path:
|
|
target = Path(destination).expanduser().resolve()
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(content)
|
|
return target
|
|
|
|
async def _export_archive(
|
|
self,
|
|
destination: str | Path,
|
|
name: str,
|
|
root: str,
|
|
members: list[str],
|
|
*,
|
|
archive_path: str = EXPORT_ARCHIVE,
|
|
required: tuple[str, str] | None = None,
|
|
) -> Path:
|
|
"""Archive selected sandbox paths and download the archive while locked."""
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
if required is not None:
|
|
path, description = required
|
|
await self._require_artifact(f"{root}/{path}", description)
|
|
if root == CASE_ROOT:
|
|
await self._write_export_manifest(
|
|
name.removeprefix("export_"),
|
|
workspace_root="reme_workspace" if "reme_workspace" in members else None,
|
|
)
|
|
members = ["manifest.json", *members]
|
|
await self._exec_checked(name, ["tar", "-czf", archive_path, "-C", root, *members])
|
|
return self._save_host_file(destination, await self.backend.read_file(archive_path))
|
|
|
|
async def upload_workspace(self, source: str | Path, *, clear: bool = True) -> None:
|
|
"""Copy a host workspace directory or exported memory archive into this case.
|
|
|
|
Case archives restore only their workspace; logs and query artifacts
|
|
are excluded. Query-only archives cannot seed a runtime workspace.
|
|
By default the existing runtime workspace is deleted first, ensuring a
|
|
query case receives only the supplied memory state. Set ``clear=False``
|
|
only when intentionally merging files into the current workspace.
|
|
The copy is made through a validated archive, so each sandbox receives
|
|
an independent filesystem tree and can run queries in parallel.
|
|
"""
|
|
archive = await asyncio.to_thread(_archive_workspace, source)
|
|
async with self._operation_lock:
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
if clear:
|
|
await self._exec_checked("clear_runtime_workspace", ["rm", "-rf", self.runtime_workspace])
|
|
await self._exec_checked("create_runtime_workspace", ["mkdir", "-p", self.runtime_workspace])
|
|
await self._write_file(WORKSPACE_ARCHIVE, archive)
|
|
await self._exec_checked(
|
|
"upload_workspace",
|
|
["tar", "-xzf", WORKSPACE_ARCHIVE, "-C", self.runtime_workspace],
|
|
)
|
|
# A directory copied from the host may not have Git metadata. Git
|
|
# init is idempotent and preserves history when the snapshot has it.
|
|
await self._exec_checked(
|
|
"initialize_uploaded_memory_history",
|
|
["git", "init", "--quiet"],
|
|
cwd=self.runtime_workspace,
|
|
)
|
|
await self._exec_checked("remove_workspace_upload", ["rm", "-f", WORKSPACE_ARCHIVE])
|
|
|
|
@staticmethod
|
|
def _validated_workspace_path(value: Any, workspace_root: PurePosixPath, *, allow_root: bool) -> str:
|
|
"""Validate one case-relative path received from candidate-controlled state."""
|
|
if not isinstance(value, str):
|
|
raise SandboxCommandError("sandbox runtime layout contains a non-string path")
|
|
path = PurePosixPath(value)
|
|
is_root = path == workspace_root
|
|
is_descendant = workspace_root in path.parents
|
|
is_unsafe = path.is_absolute() or ".." in path.parts
|
|
if is_unsafe or (is_root and not allow_root) or (not is_root and not is_descendant):
|
|
raise SandboxCommandError(f"sandbox runtime layout contains unsafe path: {value!r}")
|
|
return path.as_posix()
|
|
|
|
async def _analysis_archive_spec(self) -> tuple[str, list[str]]:
|
|
"""Read and validate the worker-resolved workspace export rules."""
|
|
assert self.backend is not None
|
|
try:
|
|
payload = json.loads((await self.backend.read_file(RUNTIME_LAYOUT)).decode("utf-8"))
|
|
except FileNotFoundError as exc:
|
|
raise SandboxCommandError("analysis export requires at least one completed job") from exc
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise SandboxCommandError(f"invalid sandbox runtime layout: {exc}") from exc
|
|
|
|
if not isinstance(payload, dict):
|
|
raise SandboxCommandError("sandbox runtime layout must be an object")
|
|
expected_root = PurePosixPath(self.runtime_workspace).relative_to(CASE_ROOT)
|
|
workspace_root_value = self._validated_workspace_path(
|
|
payload.get("workspace_root"),
|
|
expected_root,
|
|
allow_root=True,
|
|
)
|
|
if PurePosixPath(workspace_root_value) != expected_root:
|
|
raise SandboxCommandError(
|
|
f"sandbox runtime layout changed workspace root: {workspace_root_value!r}",
|
|
)
|
|
|
|
values = payload.get("analysis_excludes")
|
|
if not isinstance(values, list):
|
|
raise SandboxCommandError("sandbox runtime layout has invalid analysis_excludes")
|
|
excludes: list[str] = []
|
|
for value in values:
|
|
normalized = self._validated_workspace_path(value, expected_root, allow_root=False)
|
|
if normalized not in excludes:
|
|
excludes.append(normalized)
|
|
return workspace_root_value, excludes
|
|
|
|
async def _write_export_manifest(self, profile: str, *, workspace_root: str | None) -> None:
|
|
"""Identify case archive layout without adding artifacts to its workspace."""
|
|
candidate_id = (
|
|
self.candidate.candidate_id
|
|
if isinstance(self.candidate, SourceCandidate)
|
|
else self.candidate.resolved_candidate_id
|
|
)
|
|
manifest = {
|
|
"archive_format": _CASE_ARCHIVE_FORMAT,
|
|
"workspace_root": workspace_root,
|
|
"case_id": self.case_id,
|
|
"container_id": self.container_id,
|
|
"candidate_id": candidate_id,
|
|
"candidate_mode": "source" if isinstance(self.candidate, SourceCandidate) else "image",
|
|
"config": self.config,
|
|
"plugins": list(self.plugins),
|
|
"install_projects": (
|
|
list(self.candidate.install_projects) if isinstance(self.candidate, SourceCandidate) else []
|
|
),
|
|
"environment_names": self.environment_names,
|
|
"export_profile": profile,
|
|
"exported_at": _utc_now(),
|
|
}
|
|
await self._write_file(
|
|
f"{CASE_ROOT}/manifest.json",
|
|
json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"),
|
|
)
|
|
|
|
async def _export(
|
|
self,
|
|
destination: str | Path,
|
|
*,
|
|
profile: Literal["analysis", "full"],
|
|
include_candidate: bool,
|
|
) -> Path:
|
|
"""Export the active case while the caller holds the operation lock."""
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
target = Path(destination).expanduser().resolve()
|
|
artifacts = Path(f"{target}.artifacts")
|
|
if profile == "analysis" and artifacts.exists():
|
|
raise FileExistsError(f"analysis artifacts already exist: {artifacts}")
|
|
if include_candidate and isinstance(self.candidate, ImageCandidate):
|
|
raise ValueError("an image candidate has no source directory to export")
|
|
await self._write_export_manifest(profile, workspace_root="reme_workspace")
|
|
await self._write_file(
|
|
f"{self.logs_dir}/actions.jsonl",
|
|
b"".join(
|
|
(json.dumps(asdict(action), ensure_ascii=False) + "\n").encode("utf-8") for action in self.actions
|
|
),
|
|
)
|
|
if profile == "analysis":
|
|
workspace_root, analysis_excludes = await self._analysis_archive_spec()
|
|
command = [
|
|
"tar",
|
|
"-czf",
|
|
EXPORT_ARCHIVE,
|
|
"-C",
|
|
CASE_ROOT,
|
|
*(f"--exclude={path}" for path in analysis_excludes),
|
|
"manifest.json",
|
|
"runtime-layout.json",
|
|
"logs",
|
|
"results",
|
|
workspace_root,
|
|
]
|
|
else:
|
|
command = ["tar", "-czf", EXPORT_ARCHIVE, "-C", CASE_ROOT, "."]
|
|
if include_candidate:
|
|
command.extend(["-C", "/workspace", "candidate"])
|
|
await self._exec_checked("export_case", command)
|
|
self._save_host_file(target, await self.backend.read_file(EXPORT_ARCHIVE))
|
|
if profile == "analysis":
|
|
await self._export_analysis_logs(artifacts)
|
|
return target
|
|
|
|
async def _export_analysis_logs(self, destination: Path) -> None:
|
|
"""Save available build and query artifacts alongside the analysis archive."""
|
|
assert self.backend is not None
|
|
try:
|
|
build_log = await self.backend.read_file(f"{CASE_ROOT}/build_log/build.log")
|
|
except FileNotFoundError:
|
|
build_log = None
|
|
if build_log is not None:
|
|
log_path = destination / "build_log" / "build.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log_path.write_bytes(build_log)
|
|
|
|
try:
|
|
await self.backend.read_file(f"{CASE_ROOT}/queries/summary.json")
|
|
except FileNotFoundError:
|
|
return
|
|
await self._exec_checked(
|
|
"export_analysis_queries",
|
|
["tar", "-czf", EXPORT_ARCHIVE, "-C", CASE_ROOT, "queries"],
|
|
)
|
|
archive = await self.backend.read_file(EXPORT_ARCHIVE)
|
|
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as source:
|
|
for member in source:
|
|
path = _workspace_member_path(member.name)
|
|
if path is None or not path.parts or path.parts[0] != "queries":
|
|
raise SandboxCommandError(f"query export contains unsafe path: {member.name!r}")
|
|
output = destination.joinpath(*path.parts)
|
|
if member.isdir():
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
elif member.isfile():
|
|
content = source.extractfile(member)
|
|
if content is None:
|
|
raise SandboxCommandError(f"query export cannot read: {member.name!r}")
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_bytes(content.read())
|
|
else:
|
|
raise SandboxCommandError(f"query export contains unsupported member: {member.name!r}")
|
|
|
|
async def reset_case(self, case_id: str) -> None:
|
|
"""Discard the active case and prepare this container for another.
|
|
|
|
Candidate code, its virtual environment, and the uploaded worker stay
|
|
intact. The runtime workspace, requests, logs, results, manifest, and
|
|
previous export archive are removed. Operations are serialized so a
|
|
reset cannot overlap a job or export.
|
|
"""
|
|
_validate_case_id(case_id)
|
|
async with self._operation_lock:
|
|
assert self.backend is not None, "sandbox is not initialized"
|
|
await self._exec_checked(
|
|
"clear_case",
|
|
["rm", "-rf", CASE_ROOT, EXPORT_ARCHIVE, WORKSPACE_ARCHIVE, WORKSPACE_EXPORT_ARCHIVE],
|
|
)
|
|
await self._create_case_layout()
|
|
self.case_id = case_id
|
|
self.actions = []
|
|
|
|
async def close(self) -> None:
|
|
"""Destroy the case container. Export before calling this method."""
|
|
async with self._operation_lock:
|
|
await self._close_workspace()
|
|
|
|
async def _close_workspace(self) -> None:
|
|
"""Close without acquiring the operation lock a second time."""
|
|
if not self._closed:
|
|
self._reusable = False
|
|
await asyncio.wait_for(self.workspace.close(), timeout=_CLEANUP_TIMEOUT)
|
|
self._closed = True
|
|
|
|
async def __aenter__(self) -> "DockerReMeSandbox":
|
|
return self
|
|
|
|
async def __aexit__(self, _exc_type: Any, _exc: Any, _tb: Any) -> None:
|
|
await self.close()
|