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:
1273
api/openapi.yaml
1273
api/openapi.yaml
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user