fix: sync SDK with v0.2 API, add wait kwargs to lifecycle ops

- Drop AuthResponse from models __init__ (renamed SessionResponse server-side; SDK auths via API key, doesn't need either)
- Regenerate models from updated 0.2 openapi spec
- Add wait: bool = False kwarg to Capsule/AsyncCapsule destroy/pause/resume (instance + _static_*); 500ms poll for resume/destroy, 2s for pause
- Unify polling into _poll_until / _apoll_until + _wait_for_status helper; remove duplicated _POLL_INTERVALS tables
- wait_ready: drop implicit paused->resume side effect; treat missing as fail
- Capsule.connect: handle transient pausing (wait for paused first) before resuming, fixes hang when caller pauses then connects immediately
- Drop dead "if self._id is None" branch in Capsule.__init__ after assigning from already-truthy _capsule_id
- files.make_dir: detect already_exists across 409/wrapped error messages via shared _is_already_exists helper
- tests/test_integration.py: assertions on final lifecycle state use wait=True
This commit is contained in:
2026-05-19 15:06:49 +06:00
parent e5e4e1a85b
commit 51c6987515
7 changed files with 1551 additions and 247 deletions

File diff suppressed because it is too large Load Diff

View File

@ -10,15 +10,54 @@ from contextlib import asynccontextmanager
import httpx_ws
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.commands import AsyncCommands
from wrenn.exceptions import WrennNotFoundError
from wrenn.files import AsyncFiles
from wrenn.models import Capsule as CapsuleModel
from wrenn.models import Status, Template
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:
"""Async Wrenn capsule with e2b-compatible interface.
@ -139,15 +178,16 @@ class AsyncCapsule:
client = AsyncWrennClient(api_key=api_key, base_url=base_url)
info = await client.capsules.get(capsule_id)
if info.status == Status.paused:
await client.capsules.resume(capsule_id)
capsule = cls(
_capsule_id=capsule_id,
_client=client,
_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:
await capsule.wait_ready()
@ -160,22 +200,35 @@ class AsyncCapsule:
resume = _DualMethod("_instance_resume", "_static_resume")
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)
if wait:
await self._wait_for_status(
{Status.stopped, Status.missing}, _DESTROY_INTERVAL
)
@classmethod
async def _static_destroy(
cls,
capsule_id: str,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> None:
async with AsyncWrennClient(api_key=api_key, base_url=base_url) as client:
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)
if wait:
self._info = await self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
return self._info
@classmethod
@ -183,14 +236,24 @@ class AsyncCapsule:
cls,
capsule_id: str,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> CapsuleModel:
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)
if wait:
self._info = await self._wait_for_status({Status.running}, _RESUME_INTERVAL)
return self._info
@classmethod
@ -198,11 +261,19 @@ class AsyncCapsule:
cls,
capsule_id: str,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> CapsuleModel:
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:
self._info = await self._client.capsules.get(self._id)
@ -229,43 +300,30 @@ class AsyncCapsule:
"""
await self._client.capsules.ping(self._id)
_POLL_INTERVALS: dict[Status, float] = {
Status.starting: 0.5,
Status.resuming: 0.5,
Status.pausing: 2.0,
Status.stopping: 1.0,
}
async def _wait_for_status(
self,
targets: set[Status],
interval: float,
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:
"""Await until the 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``.
async def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
"""Await until capsule status is ``running``.
Raises:
TimeoutError: If the capsule does not reach ``running`` state
within ``timeout`` seconds.
RuntimeError: If the capsule enters an error, stopped, or paused
state while waiting.
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
RuntimeError: If capsule enters error/stopped/missing while waiting.
"""
deadline = time.monotonic() + 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")
await self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
async def is_running(self) -> bool:
"""Check whether the capsule is currently running.

View File

@ -13,6 +13,7 @@ import httpx_ws
from wrenn._git import Git
from wrenn.client import WrennClient
from wrenn.commands import Commands
from wrenn.exceptions import WrennNotFoundError
from wrenn.files import Files
from wrenn.models import Capsule as CapsuleModel
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}"
_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:
"""Descriptor that dispatches to instance method or classmethod depending on call site."""
@ -100,9 +139,6 @@ class Capsule:
self._id: str = _capsule_id
self._client = _client
self._info = _info
if self._id is None:
self._client.close()
raise RuntimeError("API returned a capsule without an ID")
else:
self._client = WrennClient(api_key=api_key, base_url=base_url)
try:
@ -213,15 +249,16 @@ class Capsule:
client = WrennClient(api_key=api_key, base_url=base_url)
info = client.capsules.get(capsule_id)
if info.status == Status.paused:
client.capsules.resume(capsule_id)
capsule = cls(
_capsule_id=capsule_id,
_client=client,
_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:
capsule.wait_ready()
@ -234,25 +271,36 @@ class Capsule:
resume = _DualMethod("_instance_resume", "_static_resume")
get_info = _DualMethod("_instance_get_info", "_static_get_info")
def _instance_destroy(self) -> None:
"""Destroy this capsule."""
def _instance_destroy(self, wait: bool = False) -> None:
"""Destroy this capsule. If ``wait``, poll until stopped/missing."""
self._client.capsules.destroy(self._id)
if wait:
self._wait_for_status({Status.stopped, Status.missing}, _DESTROY_INTERVAL)
@classmethod
def _static_destroy(
cls,
capsule_id: str,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> None:
"""Destroy a capsule by ID."""
with WrennClient(api_key=api_key, base_url=base_url) as client:
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:
"""Pause this capsule."""
def _instance_pause(self, wait: bool = False) -> CapsuleModel:
"""Pause this capsule. If ``wait``, poll until ``paused``."""
self._info = self._client.capsules.pause(self._id)
if wait:
self._info = self._wait_for_status({Status.paused}, _PAUSE_INTERVAL)
return self._info
@classmethod
@ -260,16 +308,26 @@ class Capsule:
cls,
capsule_id: str,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> CapsuleModel:
"""Pause a capsule by ID."""
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:
"""Resume this capsule."""
def _instance_resume(self, wait: bool = False) -> CapsuleModel:
"""Resume this capsule. If ``wait``, poll until ``running``."""
self._info = self._client.capsules.resume(self._id)
if wait:
self._info = self._wait_for_status({Status.running}, _RESUME_INTERVAL)
return self._info
@classmethod
@ -277,12 +335,20 @@ class Capsule:
cls,
capsule_id: str,
*,
wait: bool = False,
api_key: str | None = None,
base_url: str | None = None,
) -> CapsuleModel:
"""Resume a capsule by ID."""
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:
"""Get current info for this capsule."""
@ -311,43 +377,30 @@ class Capsule:
"""
self._client.capsules.ping(self._id)
_POLL_INTERVALS: dict[Status, float] = {
Status.starting: 0.5,
Status.resuming: 0.5,
Status.pausing: 2.0,
Status.stopping: 1.0,
}
def _wait_for_status(
self,
targets: set[Status],
interval: float,
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:
"""Block until the 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``.
def wait_ready(self, timeout: float = _DEFAULT_WAIT_TIMEOUT) -> None:
"""Block until capsule status is ``running``.
Raises:
TimeoutError: If the capsule does not reach ``running`` state
within ``timeout`` seconds.
RuntimeError: If the capsule enters an error, stopped, or paused
state while waiting.
TimeoutError: If capsule does not reach ``running`` within ``timeout``.
RuntimeError: If capsule enters error/stopped/missing while waiting.
"""
deadline = time.monotonic() + 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")
self._wait_for_status({Status.running}, _START_INTERVAL, timeout)
def is_running(self) -> bool:
"""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
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:
"""Sync filesystem interface. Accessed via ``capsule.files``."""
@ -118,17 +148,10 @@ class Files:
f"/v1/capsules/{self._capsule_id}/files/mkdir",
json={"path": path},
)
if resp.status_code == 409:
try:
body = resp.json()
if body.get("error", {}).get("code") == "conflict":
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
if _is_already_exists(resp):
existing = _find_entry(self.list, path)
if existing is not None:
return existing
parsed = MakeDirResponse.model_validate(handle_response(resp))
if parsed.entry is None:
raise RuntimeError("mkdir response missing entry")
@ -315,17 +338,12 @@ class AsyncFiles:
f"/v1/capsules/{self._capsule_id}/files/mkdir",
json={"path": path},
)
if resp.status_code == 409:
try:
body = resp.json()
if body.get("error", {}).get("code") == "conflict":
parent = os.path.dirname(path)
name = os.path.basename(path)
for entry in await self.list(parent, depth=1):
if entry.name == name:
return entry
except Exception:
pass
if _is_already_exists(resp):
parent = os.path.dirname(path)
name = os.path.basename(path)
for entry in await self.list(parent, depth=1):
if entry.name == name:
return entry
parsed = MakeDirResponse.model_validate(handle_response(resp))
if parsed.entry is None:
raise RuntimeError("mkdir response missing entry")

View File

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

View File

@ -1,10 +1,10 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-05-15T07:57:28+00:00
# timestamp: 2026-05-19T08:54:50+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from typing import Annotated
from typing import Annotated, Any
from datetime import date as date_aliased
from enum import StrEnum
@ -27,14 +27,20 @@ class SignupResponse(BaseModel):
] = None
class AuthResponse(BaseModel):
token: Annotated[str | None, Field(description="JWT token (valid for 6 hours)")] = (
None
)
class SessionResponse(BaseModel):
"""
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
team_id: str | None = None
email: str | None = None
name: str | None = None
role: str | None = None
is_admin: bool | None = None
class CreateAPIKeyRequest(BaseModel):
@ -62,10 +68,17 @@ class CreateCapsuleRequest(BaseModel):
template: str | None = "minimal"
vcpus: int | None = 1
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[
int | None,
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
@ -156,6 +169,13 @@ class Capsule(BaseModel):
started_at: AwareDatetime | None = None
last_active_at: 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):
@ -180,6 +200,13 @@ class Template(BaseModel):
memory_mb: int | None = None
size_bytes: int | 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):
@ -402,7 +429,7 @@ class HostDeletePreview(BaseModel):
host: Host | None = None
sandbox_ids: Annotated[
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
@ -410,8 +437,7 @@ class Error(BaseModel):
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
message: str | None = None
sandbox_ids: Annotated[
list[str] | None,
Field(description="IDs of active capsulees blocking deletion."),
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
] = None
@ -479,7 +505,9 @@ class MetricPoint(BaseModel):
] = None
mem_bytes: Annotated[
int | None,
Field(description="Resident memory in bytes (VmRSS of Firecracker process)"),
Field(
description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)"
),
] = None
disk_bytes: Annotated[
int | None, Field(description="Allocated disk bytes for the CoW sparse file")
@ -497,12 +525,12 @@ class Provider(StrEnum):
class Event(StrEnum):
capsule_created = "capsule.created"
capsule_running = "capsule.running"
capsule_paused = "capsule.paused"
capsule_destroyed = "capsule.destroyed"
template_snapshot_created = "template.snapshot.created"
template_snapshot_deleted = "template.snapshot.deleted"
capsule_create = "capsule.create"
capsule_pause = "capsule.pause"
capsule_resume = "capsule.resume"
capsule_destroy = "capsule.destroy"
template_snapshot_create = "template.snapshot.create"
template_snapshot_delete = "template.snapshot.delete"
host_up = "host.up"
host_down = "host.down"
@ -594,6 +622,106 @@ class Error1(BaseModel):
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):
entries: list[FileEntry] | None = None

View File

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