fix: update SDK for v0.2.0 API compatibility #10

Merged
pptx704 merged 4 commits from fix/0.2-compatibility into dev 2026-05-19 11:16:21 +00:00
16 changed files with 1732 additions and 349 deletions
Showing only changes of commit 51c6987515 - Show all commits

File diff suppressed because it is too large Load Diff

View File

@ -10,15 +10,54 @@ from contextlib import asynccontextmanager
import httpx_ws import httpx_ws
from wrenn._git import AsyncGit from wrenn._git import AsyncGit
from wrenn.capsule import _DualMethod, _build_proxy_url from wrenn.capsule import (
_DEFAULT_WAIT_TIMEOUT,
_DESTROY_INTERVAL,
_FAIL_STATUSES,
_PAUSE_INTERVAL,
_RESUME_INTERVAL,
_START_INTERVAL,
_DualMethod,
_build_proxy_url,
)
from wrenn.client import AsyncWrennClient from wrenn.client import AsyncWrennClient
from wrenn.commands import AsyncCommands from wrenn.commands import AsyncCommands
from wrenn.exceptions import WrennNotFoundError
from wrenn.files import AsyncFiles from wrenn.files import AsyncFiles
from wrenn.models import Capsule as CapsuleModel from wrenn.models import Capsule as CapsuleModel
from wrenn.models import Status, Template from wrenn.models import Status, Template
from wrenn.pty import AsyncPtySession from wrenn.pty import AsyncPtySession
async def _apoll_until(
fetch,
targets: set[Status],
interval: float,
timeout: float = _DEFAULT_WAIT_TIMEOUT,
fail_on: set[Status] | None = None,
) -> CapsuleModel:
fail = fail_on if fail_on is not None else _FAIL_STATUSES
treat_missing_as_target = Status.missing in targets
deadline = time.monotonic() + timeout
last: CapsuleModel | None = None
while time.monotonic() < deadline:
try:
last = await fetch()
except WrennNotFoundError:
if treat_missing_as_target:
return CapsuleModel(status=Status.missing)
raise
if last.status in targets:
return last
if last.status is not None and last.status in fail:
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
await asyncio.sleep(interval)
raise TimeoutError(
f"Capsule did not reach {targets} within {timeout}s "
f"(last status: {last.status if last else 'unknown'})"
)
class AsyncCapsule: class AsyncCapsule:
"""Async Wrenn capsule with e2b-compatible interface. """Async Wrenn capsule with e2b-compatible interface.
@ -139,15 +178,16 @@ class AsyncCapsule:
client = AsyncWrennClient(api_key=api_key, base_url=base_url) client = AsyncWrennClient(api_key=api_key, base_url=base_url)
info = await client.capsules.get(capsule_id) info = await client.capsules.get(capsule_id)
if info.status == Status.paused:
await client.capsules.resume(capsule_id)
capsule = cls( capsule = cls(
_capsule_id=capsule_id, _capsule_id=capsule_id,
_client=client, _client=client,
_info=info, _info=info,
) )
if info.status == Status.pausing:
info = await capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
if info.status == Status.paused:
await client.capsules.resume(capsule_id)
if info.status != Status.running: if info.status != Status.running:
await capsule.wait_ready() await capsule.wait_ready()
@ -160,22 +200,35 @@ class AsyncCapsule:
resume = _DualMethod("_instance_resume", "_static_resume") resume = _DualMethod("_instance_resume", "_static_resume")
get_info = _DualMethod("_instance_get_info", "_static_get_info") get_info = _DualMethod("_instance_get_info", "_static_get_info")
async def _instance_destroy(self) -> None: async def _instance_destroy(self, wait: bool = False) -> None:
await self._client.capsules.destroy(self._id) await self._client.capsules.destroy(self._id)
if wait:
await self._wait_for_status(
{Status.stopped, Status.missing}, _DESTROY_INTERVAL
)
@classmethod @classmethod
async def _static_destroy( async def _static_destroy(
cls, cls,
capsule_id: str, capsule_id: str,
*, *,
wait: bool = False,
api_key: str | None = None, api_key: str | None = None,
base_url: str | None = None, base_url: str | None = None,
) -> None: ) -> None:
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
await client.capsules.destroy(capsule_id) await client.capsules.destroy(capsule_id)
if wait:
await _apoll_until(
lambda: client.capsules.get(capsule_id),
{Status.stopped, Status.missing},
_DESTROY_INTERVAL,
)
async def _instance_pause(self) -> CapsuleModel: async def _instance_pause(self, wait: bool = False) -> CapsuleModel:
self._info = await self._client.capsules.pause(self._id) self._info = await self._client.capsules.pause(self._id)
if wait:
self._info = await self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
return self._info return self._info
@classmethod @classmethod
@ -183,14 +236,24 @@ class AsyncCapsule:
cls, cls,
capsule_id: str, capsule_id: str,
*, *,
wait: bool = False,
api_key: str | None = None, api_key: str | None = None,
base_url: str | None = None, base_url: str | None = None,
) -> CapsuleModel: ) -> CapsuleModel:
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
return await client.capsules.pause(capsule_id) info = await client.capsules.pause(capsule_id)
if wait:
info = await _apoll_until(
lambda: client.capsules.get(capsule_id),
{Status.paused},
_PAUSE_INTERVAL,
)
return info
async def _instance_resume(self) -> CapsuleModel: async def _instance_resume(self, wait: bool = False) -> CapsuleModel:
self._info = await self._client.capsules.resume(self._id) self._info = await self._client.capsules.resume(self._id)
if wait:
self._info = await self._wait_for_status({Status.running}, _RESUME_INTERVAL)
return self._info return self._info
@classmethod @classmethod
@ -198,11 +261,19 @@ class AsyncCapsule:
cls, cls,
capsule_id: str, capsule_id: str,
*, *,
wait: bool = False,
api_key: str | None = None, api_key: str | None = None,
base_url: str | None = None, base_url: str | None = None,
) -> CapsuleModel: ) -> CapsuleModel:
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client: async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
return await client.capsules.resume(capsule_id) info = await client.capsules.resume(capsule_id)
if wait:
info = await _apoll_until(
lambda: client.capsules.get(capsule_id),
{Status.running},
_RESUME_INTERVAL,
)
return info
async def _instance_get_info(self) -> CapsuleModel: async def _instance_get_info(self) -> CapsuleModel:
self._info = await self._client.capsules.get(self._id) self._info = await self._client.capsules.get(self._id)
@ -229,43 +300,30 @@ class AsyncCapsule:
""" """
await self._client.capsules.ping(self._id) await self._client.capsules.ping(self._id)
_POLL_INTERVALS: dict[Status, float] = { async def _wait_for_status(
Status.starting: 0.5, self,
Status.resuming: 0.5, targets: set[Status],
Status.pausing: 2.0, interval: float,
Status.stopping: 1.0, timeout: float = _DEFAULT_WAIT_TIMEOUT,
} ) -> CapsuleModel:
info = await _apoll_until(
lambda: self._client.capsules.get(self._id),
targets,
interval,
timeout,
fail_on={Status.error, Status.stopped, Status.missing} - targets,
)
self._info = info
return info
async def wait_ready(self, timeout: float = 30) -> None: async def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
"""Await until the capsule status is ``running``. """Await until capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``.
Raises: Raises:
TimeoutError: If the capsule does not reach ``running`` state TimeoutError: If capsule does not reach ``running`` within ``timeout``.
within ``timeout`` seconds. RuntimeError: If capsule enters error/stopped/missing while waiting.
RuntimeError: If the capsule enters an error, stopped, or paused
state while waiting.
""" """
deadline = time.monotonic() + timeout await self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
while time.monotonic() < deadline:
info = await self._client.capsules.get(self._id)
if info.status == Status.running:
self._info = info
return
if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
await self._client.capsules.resume(self._id)
interval = (
self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
)
await asyncio.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
async def is_running(self) -> bool: async def is_running(self) -> bool:
"""Check whether the capsule is currently running. """Check whether the capsule is currently running.

View File

@ -13,6 +13,7 @@ import httpx_ws
from wrenn._git import Git from wrenn._git import Git
from wrenn.client import WrennClient from wrenn.client import WrennClient
from wrenn.commands import Commands from wrenn.commands import Commands
from wrenn.exceptions import WrennNotFoundError
from wrenn.files import Files from wrenn.files import Files
from wrenn.models import Capsule as CapsuleModel from wrenn.models import Capsule as CapsuleModel
from wrenn.models import Status, Template from wrenn.models import Status, Template
@ -28,6 +29,44 @@ def _build_proxy_url(base_url: str, capsule_id: str | None, port: int) -> str:
return f"{scheme}://{port}-{capsule_id}.{host}" return f"{scheme}://{port}-{capsule_id}.{host}"
_RESUME_INTERVAL = 0.5
_DESTROY_INTERVAL = 0.5
_PAUSE_INTERVAL = 2.0
_START_INTERVAL = 0.5
_DEFAULT_WAIT_TIMEOUT = 30.0
_FAIL_STATUSES = {Status.error}
def _poll_until(
fetch,
targets: set[Status],
interval: float,
timeout: float = _DEFAULT_WAIT_TIMEOUT,
fail_on: set[Status] | None = None,
) -> CapsuleModel:
"""Poll ``fetch()`` until status ∈ ``targets``. Raise on ``fail_on``/timeout."""
fail = fail_on if fail_on is not None else _FAIL_STATUSES
treat_missing_as_target = Status.missing in targets
deadline = time.monotonic() + timeout
last: CapsuleModel | None = None
while time.monotonic() < deadline:
try:
last = fetch()
except WrennNotFoundError:
if treat_missing_as_target:
return CapsuleModel(status=Status.missing)
raise
if last.status in targets:
return last
if last.status is not None and last.status in fail:
raise RuntimeError(f"Capsule entered {last.status} state while waiting")
time.sleep(interval)
raise TimeoutError(
f"Capsule did not reach {targets} within {timeout}s "
f"(last status: {last.status if last else 'unknown'})"
)
class _DualMethod: class _DualMethod:
"""Descriptor that dispatches to instance method or classmethod depending on call site.""" """Descriptor that dispatches to instance method or classmethod depending on call site."""
@ -100,9 +139,6 @@ class Capsule:
self._id: str = _capsule_id self._id: str = _capsule_id
self._client = _client self._client = _client
self._info = _info self._info = _info
if self._id is None:
self._client.close()
raise RuntimeError("API returned a capsule without an ID")
else: else:
self._client = WrennClient(api_key=api_key, base_url=base_url) self._client = WrennClient(api_key=api_key, base_url=base_url)
try: try:
@ -213,15 +249,16 @@ class Capsule:
client = WrennClient(api_key=api_key, base_url=base_url) client = WrennClient(api_key=api_key, base_url=base_url)
info = client.capsules.get(capsule_id) info = client.capsules.get(capsule_id)
if info.status == Status.paused:
client.capsules.resume(capsule_id)
capsule = cls( capsule = cls(
_capsule_id=capsule_id, _capsule_id=capsule_id,
_client=client, _client=client,
_info=info, _info=info,
) )
if info.status == Status.pausing:
info = capsule._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
if info.status == Status.paused:
client.capsules.resume(capsule_id)
if info.status != Status.running: if info.status != Status.running:
capsule.wait_ready() capsule.wait_ready()
@ -234,25 +271,36 @@ class Capsule:
resume = _DualMethod("_instance_resume", "_static_resume") resume = _DualMethod("_instance_resume", "_static_resume")
get_info = _DualMethod("_instance_get_info", "_static_get_info") get_info = _DualMethod("_instance_get_info", "_static_get_info")
def _instance_destroy(self) -> None: def _instance_destroy(self, wait: bool = False) -> None:
"""Destroy this capsule.""" """Destroy this capsule. If ``wait``, poll until stopped/missing."""
self._client.capsules.destroy(self._id) self._client.capsules.destroy(self._id)
if wait:
self._wait_for_status({Status.stopped, Status.missing}, _DESTROY_INTERVAL)
@classmethod @classmethod
def _static_destroy( def _static_destroy(
cls, cls,
capsule_id: str, capsule_id: str,
*, *,
wait: bool = False,
api_key: str | None = None, api_key: str | None = None,
base_url: str | None = None, base_url: str | None = None,
) -> None: ) -> None:
"""Destroy a capsule by ID.""" """Destroy a capsule by ID."""
with WrennClient(api_key=api_key, base_url=base_url) as client: with WrennClient(api_key=api_key, base_url=base_url) as client:
client.capsules.destroy(capsule_id) client.capsules.destroy(capsule_id)
if wait:
_poll_until(
lambda: client.capsules.get(capsule_id),
{Status.stopped, Status.missing},
_DESTROY_INTERVAL,
)
def _instance_pause(self) -> CapsuleModel: def _instance_pause(self, wait: bool = False) -> CapsuleModel:
"""Pause this capsule.""" """Pause this capsule. If ``wait``, poll until ``paused``."""
self._info = self._client.capsules.pause(self._id) self._info = self._client.capsules.pause(self._id)
if wait:
self._info = self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
return self._info return self._info
@classmethod @classmethod
@ -260,16 +308,26 @@ class Capsule:
cls, cls,
capsule_id: str, capsule_id: str,
*, *,
wait: bool = False,
api_key: str | None = None, api_key: str | None = None,
base_url: str | None = None, base_url: str | None = None,
) -> CapsuleModel: ) -> CapsuleModel:
"""Pause a capsule by ID.""" """Pause a capsule by ID."""
with WrennClient(api_key=api_key, base_url=base_url) as client: with WrennClient(api_key=api_key, base_url=base_url) as client:
return client.capsules.pause(capsule_id) info = client.capsules.pause(capsule_id)
if wait:
info = _poll_until(
lambda: client.capsules.get(capsule_id),
{Status.paused},
_PAUSE_INTERVAL,
)
return info
def _instance_resume(self) -> CapsuleModel: def _instance_resume(self, wait: bool = False) -> CapsuleModel:
"""Resume this capsule.""" """Resume this capsule. If ``wait``, poll until ``running``."""
self._info = self._client.capsules.resume(self._id) self._info = self._client.capsules.resume(self._id)
if wait:
self._info = self._wait_for_status({Status.running}, _RESUME_INTERVAL)
return self._info return self._info
@classmethod @classmethod
@ -277,12 +335,20 @@ class Capsule:
cls, cls,
capsule_id: str, capsule_id: str,
*, *,
wait: bool = False,
api_key: str | None = None, api_key: str | None = None,
base_url: str | None = None, base_url: str | None = None,
) -> CapsuleModel: ) -> CapsuleModel:
"""Resume a capsule by ID.""" """Resume a capsule by ID."""
with WrennClient(api_key=api_key, base_url=base_url) as client: with WrennClient(api_key=api_key, base_url=base_url) as client:
return client.capsules.resume(capsule_id) info = client.capsules.resume(capsule_id)
if wait:
info = _poll_until(
lambda: client.capsules.get(capsule_id),
{Status.running},
_RESUME_INTERVAL,
)
return info
def _instance_get_info(self) -> CapsuleModel: def _instance_get_info(self) -> CapsuleModel:
"""Get current info for this capsule.""" """Get current info for this capsule."""
@ -311,43 +377,30 @@ class Capsule:
""" """
self._client.capsules.ping(self._id) self._client.capsules.ping(self._id)
_POLL_INTERVALS: dict[Status, float] = { def _wait_for_status(
Status.starting: 0.5, self,
Status.resuming: 0.5, targets: set[Status],
Status.pausing: 2.0, interval: float,
Status.stopping: 1.0, timeout: float = _DEFAULT_WAIT_TIMEOUT,
} ) -> CapsuleModel:
info = _poll_until(
lambda: self._client.capsules.get(self._id),
targets,
interval,
timeout,
fail_on={Status.error, Status.stopped, Status.missing} - targets,
)
self._info = info
return info
def wait_ready(self, timeout: float = 30) -> None: def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
"""Block until the capsule status is ``running``. """Block until capsule status is ``running``.
Polling interval adapts to the current transient status:
0.5 s for starting/resuming, 2 s for pausing, 1 s for stopping.
Args:
timeout (float): Maximum seconds to wait. Defaults to ``30``.
Raises: Raises:
TimeoutError: If the capsule does not reach ``running`` state TimeoutError: If capsule does not reach ``running`` within ``timeout``.
within ``timeout`` seconds. RuntimeError: If capsule enters error/stopped/missing while waiting.
RuntimeError: If the capsule enters an error, stopped, or paused
state while waiting.
""" """
deadline = time.monotonic() + timeout self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
while time.monotonic() < deadline:
info = self._client.capsules.get(self._id)
if info.status == Status.running:
self._info = info
return
if info.status in (Status.error, Status.stopped):
raise RuntimeError(f"Capsule entered {info.status} state while waiting")
if info.status == Status.paused:
self._client.capsules.resume(self._id)
interval = (
self._POLL_INTERVALS.get(info.status, 0.5) if info.status else 0.5
)
time.sleep(interval)
raise TimeoutError(f"Capsule {self._id} did not become ready within {timeout}s")
def is_running(self) -> bool: def is_running(self) -> bool:
"""Check whether the capsule is currently running. """Check whether the capsule is currently running.

View File

@ -9,6 +9,36 @@ from wrenn.exceptions import WrennNotFoundError, _raise_for_status, handle_respo
from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse from wrenn.models import FileEntry, ListDirResponse, MakeDirResponse
def _is_already_exists(resp: httpx.Response) -> bool:
"""Detect server's already-exists reply across status codes / code strings.
Server may return 409 with code "conflict"/"already_exists" or wrap
"already_exists" inside an "internal" 500 message.
"""
if resp.status_code < 400:
return False
try:
body = resp.json()
except Exception:
return False
err = body.get("error", {}) if isinstance(body, dict) else {}
code = err.get("code", "")
msg = err.get("message", "") or ""
return code in {"conflict", "already_exists"} or "already_exists" in msg
def _find_entry(list_fn, path: str) -> FileEntry | None:
parent = os.path.dirname(path)
name = os.path.basename(path)
try:
for entry in list_fn(parent, depth=1):
if entry.name == name:
return entry
except WrennNotFoundError:
return None
return None
class Files: class Files:
"""Sync filesystem interface. Accessed via ``capsule.files``.""" """Sync filesystem interface. Accessed via ``capsule.files``."""
@ -118,17 +148,10 @@ class Files:
f"/v1/capsules/{self._capsule_id}/files/mkdir", f"/v1/capsules/{self._capsule_id}/files/mkdir",
json={"path": path}, json={"path": path},
) )
if resp.status_code == 409: if _is_already_exists(resp):
try: existing = _find_entry(self.list, path)
body = resp.json() if existing is not None:
if body.get("error", {}).get("code") == "conflict": return existing
parent = os.path.dirname(path)
name = os.path.basename(path)
for entry in self.list(parent, depth=1):
if entry.name == name:
return entry
except Exception:
pass
parsed = MakeDirResponse.model_validate(handle_response(resp)) parsed = MakeDirResponse.model_validate(handle_response(resp))
if parsed.entry is None: if parsed.entry is None:
raise RuntimeError("mkdir response missing entry") raise RuntimeError("mkdir response missing entry")
@ -315,17 +338,12 @@ class AsyncFiles:
f"/v1/capsules/{self._capsule_id}/files/mkdir", f"/v1/capsules/{self._capsule_id}/files/mkdir",
json={"path": path}, json={"path": path},
) )
if resp.status_code == 409: if _is_already_exists(resp):
try: parent = os.path.dirname(path)
body = resp.json() name = os.path.basename(path)
if body.get("error", {}).get("code") == "conflict": for entry in await self.list(parent, depth=1):
parent = os.path.dirname(path) if entry.name == name:
name = os.path.basename(path) return entry
for entry in await self.list(parent, depth=1):
if entry.name == name:
return entry
except Exception:
pass
parsed = MakeDirResponse.model_validate(handle_response(resp)) parsed = MakeDirResponse.model_validate(handle_response(resp))
if parsed.entry is None: if parsed.entry is None:
raise RuntimeError("mkdir response missing entry") raise RuntimeError("mkdir response missing entry")

View File

@ -1,6 +1,5 @@
from wrenn.models._generated import ( from wrenn.models._generated import (
APIKeyResponse, APIKeyResponse,
AuthResponse,
Capsule, Capsule,
CreateAPIKeyRequest, CreateAPIKeyRequest,
CreateCapsuleRequest, CreateCapsuleRequest,
@ -34,7 +33,6 @@ from wrenn.models._generated import (
__all__ = [ __all__ = [
"APIKeyResponse", "APIKeyResponse",
"AuthResponse",
"CreateAPIKeyRequest", "CreateAPIKeyRequest",
"CreateHostRequest", "CreateHostRequest",
"CreateHostResponse", "CreateHostResponse",

View File

@ -1,10 +1,10 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-05-15T07:57:28+00:00 # timestamp: 2026-05-19T08:54:50+00:00
from __future__ import annotations from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from typing import Annotated from typing import Annotated, Any
from datetime import date as date_aliased from datetime import date as date_aliased
from enum import StrEnum from enum import StrEnum
@ -27,14 +27,20 @@ class SignupResponse(BaseModel):
] = None ] = None
class AuthResponse(BaseModel): class SessionResponse(BaseModel):
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = ( """
None Returned by login, activate, and switch-team. The actual auth credential
) is the wrenn_sid cookie set on the response. The body carries identity
data the SPA needs to bootstrap.
"""
user_id: str | None = None user_id: str | None = None
team_id: str | None = None team_id: str | None = None
email: str | None = None email: str | None = None
name: str | None = None name: str | None = None
role: str | None = None
is_admin: bool | None = None
class CreateAPIKeyRequest(BaseModel): class CreateAPIKeyRequest(BaseModel):
@ -62,10 +68,17 @@ class CreateCapsuleRequest(BaseModel):
template: str | None = "minimal" template: str | None = "minimal"
vcpus: int | None = 1 vcpus: int | None = 1
memory_mb: int | None = 512 memory_mb: int | None = 512
disk_size_mb: Annotated[
int | None,
Field(
description="Maximum size of the per-capsule copy-on-write disk in MB. Capped at 5 GB by default; the actual size is max(disk_size_mb, origin rootfs size).\n"
),
] = 5120
timeout_sec: Annotated[ timeout_sec: Annotated[
int | None, int | None,
Field( Field(
description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause.\n" description="Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n",
ge=0,
), ),
] = 0 ] = 0
@ -156,6 +169,13 @@ class Capsule(BaseModel):
started_at: AwareDatetime | None = None started_at: AwareDatetime | None = None
last_active_at: AwareDatetime | None = None last_active_at: AwareDatetime | None = None
last_updated: AwareDatetime | None = None last_updated: AwareDatetime | None = None
metadata: Annotated[
dict[str, str] | None,
Field(
description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n"
),
] = None
disk_size_mb: int | None = None
class CreateSnapshotRequest(BaseModel): class CreateSnapshotRequest(BaseModel):
@ -180,6 +200,13 @@ class Template(BaseModel):
memory_mb: int | None = None memory_mb: int | None = None
size_bytes: int | None = None size_bytes: int | None = None
created_at: AwareDatetime | None = None created_at: AwareDatetime | None = None
platform: Annotated[
bool | None,
Field(
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n"
),
] = None
metadata: dict[str, str] | None = None
class ExecRequest(BaseModel): class ExecRequest(BaseModel):
@ -402,7 +429,7 @@ class HostDeletePreview(BaseModel):
host: Host | None = None host: Host | None = None
sandbox_ids: Annotated[ sandbox_ids: Annotated[
list[str] | None, list[str] | None,
Field(description="IDs of capsulees that would be destroyed on force-delete."), Field(description="IDs of capsules that would be destroyed on force-delete."),
] = None ] = None
@ -410,8 +437,7 @@ class Error(BaseModel):
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
message: str | None = None message: str | None = None
sandbox_ids: Annotated[ sandbox_ids: Annotated[
list[str] | None, list[str] | None, Field(description="IDs of active capsules blocking deletion.")
Field(description="IDs of active capsulees blocking deletion."),
] = None ] = None
@ -479,7 +505,9 @@ class MetricPoint(BaseModel):
] = None ] = None
mem_bytes: Annotated[ mem_bytes: Annotated[
int | None, int | None,
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"), Field(
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
),
] = None ] = None
disk_bytes: Annotated[ disk_bytes: Annotated[
int | None, Field(description="Allocated disk bytes for the CoW sparse file") int | None, Field(description="Allocated disk bytes for the CoW sparse file")
@ -497,12 +525,12 @@ class Provider(StrEnum):
class Event(StrEnum): class Event(StrEnum):
capsule_created = "capsule.created" capsule_create = "capsule.create"
capsule_running = "capsule.running" capsule_pause = "capsule.pause"
capsule_paused = "capsule.paused" capsule_resume = "capsule.resume"
capsule_destroyed = "capsule.destroyed" capsule_destroy = "capsule.destroy"
template_snapshot_created = "template.snapshot.created" template_snapshot_create = "template.snapshot.create"
template_snapshot_deleted = "template.snapshot.deleted" template_snapshot_delete = "template.snapshot.delete"
host_up = "host.up" host_up = "host.up"
host_down = "host.down" host_down = "host.down"
@ -594,6 +622,106 @@ class Error1(BaseModel):
error: Error2 | None = None error: Error2 | None = None
class ActorType(StrEnum):
user = "user"
api_key = "api_key"
host = "host"
system = "system"
class Status2(StrEnum):
success = "success"
failure = "failure"
class AuditLogEntry(BaseModel):
id: str | None = None
actor_type: ActorType | None = None
actor_id: str | None = None
actor_name: str | None = None
resource_type: str | None = None
resource_id: str | None = None
action: str | None = None
scope: str | None = None
status: Status2 | None = None
metadata: dict[str, Any] | None = None
created_at: AwareDatetime | None = None
class Event2(StrEnum):
connected = "connected"
capsule_create = "capsule.create"
capsule_pause = "capsule.pause"
capsule_resume = "capsule.resume"
capsule_destroy = "capsule.destroy"
capsule_state_changed = "capsule.state.changed"
template_snapshot_create = "template.snapshot.create"
template_snapshot_delete = "template.snapshot.delete"
host_up = "host.up"
host_down = "host.down"
class Outcome(StrEnum):
"""
Present for action events (capsule.* except state.changed,
template.snapshot.*). Absent for host.up/down, capsule.state.changed,
and the connected sentinel.
"""
success = "success"
error = "error"
class Resource(BaseModel):
id: str | None = None
type: str | None = None
class Type4(StrEnum):
user = "user"
api_key = "api_key"
system = "system"
class Actor(BaseModel):
type: Type4 | None = None
id: str | None = None
name: str | None = None
class SSEEvent(BaseModel):
"""
Wire format of one SSE message body. The event name (`event:` line) is
the `kind` and the JSON below is the `data:` line.
"""
event: Event2 | None = None
outcome: Annotated[
Outcome | None,
Field(
description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n"
),
] = None
resource: Resource | None = None
actor: Actor | None = None
metadata: Annotated[
dict[str, str] | None,
Field(
description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n"
),
] = None
error: Annotated[
str | None, Field(description="Failure reason; only set when outcome=error.")
] = None
sandbox: Annotated[
Capsule | None,
Field(description="Populated for capsule.* events; null if DB lookup failed."),
] = None
timestamp: AwareDatetime | None = None
class ListDirResponse(BaseModel): class ListDirResponse(BaseModel):
entries: list[FileEntry] | None = None entries: list[FileEntry] | None = None

View File

@ -46,7 +46,7 @@ class TestCapsuleLifecycle:
assert capsule_id assert capsule_id
assert capsule.info is not None assert capsule.info is not None
finally: finally:
capsule.destroy() capsule.destroy(wait=True)
info = Capsule.get_info(capsule_id) info = Capsule.get_info(capsule_id)
assert info.status in (Status.stopped, Status.missing) assert info.status in (Status.stopped, Status.missing)
@ -65,7 +65,7 @@ class TestCapsuleLifecycle:
assert capsule.is_running() assert capsule.is_running()
info = Capsule.get_info(capsule_id) info = Capsule.get_info(capsule_id)
assert info.status in (Status.stopped, Status.missing) assert info.status in (Status.stopping, Status.stopped, Status.missing)
def test_get_info(self): def test_get_info(self):
capsule = Capsule(wait=True) capsule = Capsule(wait=True)
@ -80,11 +80,11 @@ class TestCapsuleLifecycle:
def test_pause_and_resume(self): def test_pause_and_resume(self):
capsule = Capsule(wait=True) capsule = Capsule(wait=True)
try: try:
paused = capsule.pause() paused = capsule.pause(wait=True)
assert paused.status == Status.paused assert paused.status == Status.paused
assert not capsule.is_running() assert not capsule.is_running()
resumed = capsule.resume() resumed = capsule.resume(wait=True)
assert resumed.status == Status.running assert resumed.status == Status.running
finally: finally:
capsule.destroy() capsule.destroy()
@ -93,7 +93,7 @@ class TestCapsuleLifecycle:
capsule = Capsule(wait=True) capsule = Capsule(wait=True)
capsule_id = capsule.capsule_id capsule_id = capsule.capsule_id
try: try:
Capsule.destroy(capsule_id) Capsule.destroy(capsule_id, wait=True)
except Exception: except Exception:
capsule.destroy() capsule.destroy()
raise raise