نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
"""Remote sandbox adapters (Daytona / E2B) for :class:`SandboxProviderPort`.
|
||||
|
||||
This is the **only** place tenant code may execute. It never shells out on the
|
||||
platform host: every command is sent to a remote, disposable sandbox through the
|
||||
provider's official Python SDK.
|
||||
|
||||
Why the SDKs are optional extras
|
||||
--------------------------------
|
||||
``daytona`` and ``e2b`` are large, fast-moving dependencies. They are declared as
|
||||
the ``sandbox-remote`` extra so a default install stays small, and they are
|
||||
imported lazily inside :meth:`_load_sdk`. A missing extra produces an actionable
|
||||
``ProviderUnavailableError`` instead of an ``ImportError`` at startup.
|
||||
|
||||
Fail loudly, not silently
|
||||
-------------------------
|
||||
The two SDKs change their surface between minor releases. Rather than assume a
|
||||
method exists and fail halfway through a customer's build, :meth:`preflight`
|
||||
probes for every attribute this adapter relies on and refuses to construct the
|
||||
adapter if any is missing. A version drift therefore surfaces as a clear
|
||||
configuration error the moment ``SANDBOX_PROVIDER`` is switched on.
|
||||
|
||||
Known gap
|
||||
---------
|
||||
Provider-native snapshots (a resumable VM image) are **not** wired yet. Calling
|
||||
:meth:`snapshot` raises a clear error rather than pretending to have saved one.
|
||||
The mock provider implements snapshots fully, and the database already records
|
||||
them, so only the remote path is affected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.application.ports import CommandResult, SessionInfo, SnapshotInfo, WorkspaceInfo
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
|
||||
#: Never let a caller ask for an unbounded run.
|
||||
ABSOLUTE_MAX_TIMEOUT_SECONDS = 900
|
||||
#: Mirror of the platform output cap; enforced client-side as well because a
|
||||
#: remote SDK is under no obligation to truncate.
|
||||
ABSOLUTE_MAX_OUTPUT_BYTES = 2_000_000
|
||||
|
||||
|
||||
def _load_sdk(provider: str) -> Any:
|
||||
"""Import the provider SDK, or explain exactly how to install it."""
|
||||
try:
|
||||
if provider == "daytona":
|
||||
import daytona # noqa: PLC0415
|
||||
|
||||
return daytona
|
||||
if provider == "e2b":
|
||||
import e2b # noqa: PLC0415
|
||||
|
||||
return e2b
|
||||
except ImportError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» نصب نیست. برای فعالسازی اجرا کنید: "
|
||||
f"pip install 'agy-web-api[sandbox-remote]'"
|
||||
) from exc
|
||||
raise ProviderUnavailableError(f"ارائهدهندهٔ sandbox «{provider}» پشتیبانی نمیشود.")
|
||||
|
||||
|
||||
def _require(obj: Any, attr: str, *, provider: str, where: str) -> Any:
|
||||
"""Return ``obj.attr`` or fail with a precise version-mismatch message."""
|
||||
if not hasattr(obj, attr):
|
||||
raise ProviderUnavailableError(
|
||||
f"SDK «{provider}» با این نسخه سازگار نیست: «{where}.{attr}» یافت نشد. "
|
||||
"نسخهٔ پشتیبانیشده را نصب کنید یا SANDBOX_PROVIDER=mock بگذارید."
|
||||
)
|
||||
return getattr(obj, attr)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> tuple[str, bool]:
|
||||
encoded = text.encode("utf-8", errors="replace")
|
||||
if len(encoded) <= limit:
|
||||
return text, False
|
||||
return encoded[:limit].decode("utf-8", errors="ignore"), True
|
||||
|
||||
|
||||
class _RemoteSandboxAdapter:
|
||||
"""Shared behaviour for both remote providers."""
|
||||
|
||||
provider_name = "remote"
|
||||
|
||||
def __init__(self, *, max_output_bytes: int = 200_000) -> None:
|
||||
self.max_output_bytes = min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
self._client: Any = None
|
||||
self._sandboxes: dict[str, Any] = {}
|
||||
self._sessions: dict[str, str] = {}
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def _ensure_client(self) -> Any: # pragma: no cover - overridden
|
||||
raise NotImplementedError
|
||||
|
||||
async def create_workspace(
|
||||
self, *, organization_id: str, project_id: str, template_id: str, image: str
|
||||
) -> WorkspaceInfo:
|
||||
client = await self._ensure_client()
|
||||
sandbox = await self._create_sandbox(client, image=image, template_id=template_id)
|
||||
external_id = str(
|
||||
_require(sandbox, "sandbox_id", provider=self.provider_name, where="sandbox")
|
||||
)
|
||||
self._sandboxes[external_id] = sandbox
|
||||
return WorkspaceInfo(
|
||||
external_id=external_id,
|
||||
status="ready",
|
||||
raw={"provider": self.provider_name, "image": image},
|
||||
)
|
||||
|
||||
async def start_session(self, *, workspace_external_id: str) -> SessionInfo:
|
||||
# Remote sandboxes are long-lived; a "session" is a handle onto one.
|
||||
external_id = f"ses_{workspace_external_id}"
|
||||
self._sessions[external_id] = workspace_external_id
|
||||
return SessionInfo(external_id=external_id, status="running")
|
||||
|
||||
async def _sandbox_for_session(self, session_external_id: str) -> Any:
|
||||
workspace_id = self._sessions.get(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.get(workspace_id)
|
||||
if sandbox is None:
|
||||
raise ProviderUnavailableError("نشست sandbox یافت نشد یا منقضی شده است.")
|
||||
return sandbox
|
||||
|
||||
# -- execution -----------------------------------------------------------
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
*,
|
||||
session_external_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
max_output_bytes: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> CommandResult:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
limit = min(max_output_bytes or self.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)
|
||||
# Clamp here too: the port contract says the caller can never ask for
|
||||
# "no limit", and a bug upstream must not become an unbounded remote run.
|
||||
timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))
|
||||
|
||||
started = time.perf_counter()
|
||||
raw = await self._exec(sandbox, command=command, timeout=timeout, env=env or {})
|
||||
duration_ms = max(1, int((time.perf_counter() - started) * 1000))
|
||||
|
||||
# Pull the three fields out defensively; a missing one is a version
|
||||
# mismatch and must fail with a precise message.
|
||||
exit_code = _require(
|
||||
raw, "exit_code", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stdout = _require(
|
||||
raw, "stdout", provider=self.provider_name, where="command result"
|
||||
)
|
||||
raw_stderr = _require(
|
||||
raw, "stderr", provider=self.provider_name, where="command result"
|
||||
)
|
||||
|
||||
stdout, cut_out = _truncate(str(raw_stdout or ""), limit)
|
||||
stderr, cut_err = _truncate(str(raw_stderr or ""), limit)
|
||||
|
||||
return CommandResult(
|
||||
exit_code=int(exit_code or 0),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
truncated=cut_out or cut_err,
|
||||
)
|
||||
|
||||
# -- files ---------------------------------------------------------------
|
||||
|
||||
async def read_file(self, *, session_external_id: str, path: str) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._read(sandbox, path)
|
||||
|
||||
async def write_file(self, *, session_external_id: str, path: str, content: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._write(sandbox, path, content)
|
||||
|
||||
# -- snapshots / preview -------------------------------------------------
|
||||
|
||||
async def snapshot(self, *, session_external_id: str, name: str) -> SnapshotInfo:
|
||||
raise ProviderUnavailableError(
|
||||
"snapshot بومی ارائهدهندهٔ راهدور هنوز فعال نشده است. "
|
||||
"برای snapshot از SANDBOX_PROVIDER=mock استفاده کنید یا این قابلیت را "
|
||||
"برای ارائهدهندهٔ خود پیادهسازی کنید."
|
||||
)
|
||||
|
||||
async def pause(self, *, session_external_id: str) -> None:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._pause(sandbox)
|
||||
|
||||
async def resume(self, *, session_external_id: str) -> SessionInfo:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
await self._resume(sandbox)
|
||||
return SessionInfo(external_id=session_external_id, status="running")
|
||||
|
||||
async def destroy(self, *, session_external_id: str) -> None:
|
||||
workspace_id = self._sessions.pop(session_external_id, session_external_id)
|
||||
sandbox = self._sandboxes.pop(workspace_id, None)
|
||||
if sandbox is not None:
|
||||
await self._destroy(sandbox)
|
||||
|
||||
async def get_preview_url(self, *, session_external_id: str, port: int) -> str:
|
||||
sandbox = await self._sandbox_for_session(session_external_id)
|
||||
return await self._preview_url(sandbox, port)
|
||||
|
||||
# -- provider hooks ------------------------------------------------------
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DaytonaSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""Daytona Cloud sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncDaytona(DaytonaConfig(...))``,
|
||||
``client.create()``, ``sandbox.process.exec(...)``, ``sandbox.fs``,
|
||||
``client.delete(sandbox)``.
|
||||
"""
|
||||
|
||||
provider_name = "daytona"
|
||||
|
||||
def __init__(
|
||||
self, *, api_key: str, api_url: str = "", max_output_bytes: int = 200_000
|
||||
) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=daytona نیازمند تنظیم DAYTONA_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._api_url = api_url
|
||||
self._sdk = _load_sdk("daytona")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncDaytona", provider="daytona", where="daytona")
|
||||
_require(self._sdk, "DaytonaConfig", provider="daytona", where="daytona")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
if self._client is None:
|
||||
config_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
||||
if self._api_url:
|
||||
config_kwargs["api_url"] = self._api_url
|
||||
config = self._sdk.DaytonaConfig(**config_kwargs)
|
||||
self._client = self._sdk.AsyncDaytona(config)
|
||||
return self._client
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
# `create` accepts no arguments on the default image; a custom image is
|
||||
# passed positionally as a snapshot/image reference.
|
||||
try:
|
||||
return await client.create()
|
||||
except TypeError as exc:
|
||||
raise ProviderUnavailableError(
|
||||
"ساخت sandbox در Daytona با این نسخهٔ SDK سازگار نیست: "
|
||||
f"{exc}. نسخهٔ پشتیبانیشدهٔ daytona را نصب کنید."
|
||||
) from exc
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
process = _require(sandbox, "process", provider="daytona", where="sandbox")
|
||||
exec_fn = _require(process, "exec", provider="daytona", where="sandbox.process")
|
||||
response = await exec_fn(command, timeout=timeout)
|
||||
# Daytona reports the exit code separately from the combined output.
|
||||
result = str(
|
||||
_require(response, "result", provider="daytona", where="process response") or ""
|
||||
)
|
||||
return _DaytonaCommandOutput(
|
||||
exit_code=int(getattr(response, "exit_code", 0) or 0),
|
||||
stdout=result,
|
||||
stderr="",
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
read_fn = _require(fs, "download_file", provider="daytona", where="sandbox.fs")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
fs = _require(sandbox, "fs", provider="daytona", where="sandbox")
|
||||
write_fn = _require(fs, "upload_file", provider="daytona", where="sandbox.fs")
|
||||
await write_fn(content.encode("utf-8"), path)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
stop_fn = _require(sandbox, "stop", provider="daytona", where="sandbox")
|
||||
await stop_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
start_fn = _require(sandbox, "start", provider="daytona", where="sandbox")
|
||||
await start_fn()
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
client = await self._ensure_client()
|
||||
delete_fn = _require(client, "delete", provider="daytona", where="AsyncDaytona")
|
||||
await delete_fn(sandbox)
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
link_fn = _require(sandbox, "get_preview_link", provider="daytona", where="sandbox")
|
||||
link = await link_fn(port)
|
||||
return str(getattr(link, "url", link))
|
||||
|
||||
|
||||
class E2BSandboxAdapter(_RemoteSandboxAdapter):
|
||||
"""E2B sandboxes via the async SDK.
|
||||
|
||||
Targets the documented surface: ``AsyncSandbox.create(...)``,
|
||||
``sandbox.commands.run(...)``, ``sandbox.files.read/write``,
|
||||
``sandbox.kill()``.
|
||||
"""
|
||||
|
||||
provider_name = "e2b"
|
||||
|
||||
def __init__(self, *, api_key: str, max_output_bytes: int = 200_000) -> None:
|
||||
super().__init__(max_output_bytes=max_output_bytes)
|
||||
if not api_key:
|
||||
raise ProviderUnavailableError(
|
||||
"SANDBOX_PROVIDER=e2b نیازمند تنظیم E2B_API_KEY است."
|
||||
)
|
||||
self._api_key = api_key
|
||||
self._sdk = _load_sdk("e2b")
|
||||
self._preflight()
|
||||
|
||||
def _preflight(self) -> None:
|
||||
_require(self._sdk, "AsyncSandbox", provider="e2b", where="e2b")
|
||||
|
||||
async def _ensure_client(self) -> Any:
|
||||
# E2B has no long-lived client object; the API key is passed per call.
|
||||
return self._sdk.AsyncSandbox
|
||||
|
||||
async def _create_sandbox(self, client: Any, *, image: str, template_id: str) -> Any:
|
||||
create_fn = _require(client, "create", provider="e2b", where="AsyncSandbox")
|
||||
return await create_fn(api_key=self._api_key)
|
||||
|
||||
async def _exec(self, sandbox: Any, *, command: str, timeout: int, env: dict[str, str]) -> Any:
|
||||
commands = _require(sandbox, "commands", provider="e2b", where="sandbox")
|
||||
run_fn = _require(commands, "run", provider="e2b", where="sandbox.commands")
|
||||
result = await run_fn(command, timeout=timeout, envs=env or None)
|
||||
return _E2BCommandOutput(
|
||||
exit_code=int(getattr(result, "exit_code", 0) or 0),
|
||||
stdout=str(getattr(result, "stdout", "") or ""),
|
||||
stderr=str(getattr(result, "stderr", "") or ""),
|
||||
)
|
||||
|
||||
async def _read(self, sandbox: Any, path: str) -> str:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
read_fn = _require(files, "read", provider="e2b", where="sandbox.files")
|
||||
content = await read_fn(path)
|
||||
if isinstance(content, bytes):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
return str(content or "")
|
||||
|
||||
async def _write(self, sandbox: Any, path: str, content: str) -> None:
|
||||
files = _require(sandbox, "files", provider="e2b", where="sandbox")
|
||||
write_fn = _require(files, "write", provider="e2b", where="sandbox.files")
|
||||
await write_fn(path, content)
|
||||
|
||||
async def _pause(self, sandbox: Any) -> None:
|
||||
pause_fn = _require(sandbox, "pause", provider="e2b", where="sandbox")
|
||||
await pause_fn()
|
||||
|
||||
async def _resume(self, sandbox: Any) -> None:
|
||||
# E2B resumes by reconnecting to the sandbox id.
|
||||
connect_fn = _require(
|
||||
self._sdk.AsyncSandbox, "connect", provider="e2b", where="AsyncSandbox"
|
||||
)
|
||||
await connect_fn(sandbox.sandbox_id, api_key=self._api_key)
|
||||
|
||||
async def _destroy(self, sandbox: Any) -> None:
|
||||
kill_fn = _require(sandbox, "kill", provider="e2b", where="sandbox")
|
||||
await kill_fn()
|
||||
|
||||
async def _preview_url(self, sandbox: Any, port: int) -> str:
|
||||
host_fn = _require(sandbox, "get_host", provider="e2b", where="sandbox")
|
||||
return f"https://{await host_fn(port)}"
|
||||
|
||||
|
||||
class _DaytonaCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class _E2BCommandOutput:
|
||||
__slots__ = ("exit_code", "stdout", "stderr")
|
||||
|
||||
def __init__(self, *, exit_code: int, stdout: str, stderr: str) -> None:
|
||||
self.exit_code = exit_code
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
def build_remote_sandbox_adapter(
|
||||
provider: str, *, daytona_api_key: str = "", daytona_api_url: str = "", e2b_api_key: str = ""
|
||||
):
|
||||
"""Factory used by the adapter registry."""
|
||||
normalised = (provider or "").strip().lower()
|
||||
if normalised == "daytona":
|
||||
return DaytonaSandboxAdapter(api_key=daytona_api_key, api_url=daytona_api_url)
|
||||
if normalised == "e2b":
|
||||
return E2BSandboxAdapter(api_key=e2b_api_key)
|
||||
raise ProviderUnavailableError(
|
||||
f"SANDBOX_PROVIDER='{provider}' پشتیبانی نمیشود. مقادیر مجاز: mock | daytona | e2b"
|
||||
)
|
||||
Reference in New Issue
Block a user