diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8dc6f53..a72592d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - pydantic>=2.12.5 - httpx>=0.28.1 - httpx-ws>=0.9.0 - - email-validator>=2.3.0 + - types-PyYAML - repo: local hooks: diff --git a/Makefile b/Makefile index 047d79e..23f2acc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Makefile -.PHONY: generate lint test check test-integration test-code-runner +.PHONY: generate gen-docs lint test check test-integration test-code-runner prune-spec # Variables SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml" @@ -12,6 +12,8 @@ generate: curl -fsSL $(SPEC_URL) -o $(SPEC_PATH) + $(MAKE) prune-spec + uv run datamodel-codegen \ --input $(SPEC_PATH) \ --output src/wrenn/models/_generated.py \ @@ -40,6 +42,10 @@ test-code-runner: check: lint test +prune-spec: + @echo "Pruning spec down to the SDK's API-key surface..." + uv run python scripts/prune_openapi.py $(SPEC_PATH) + gen-docs: mkdir -p docs uv run pydoc-markdown > docs/reference.md diff --git a/api/openapi.yaml b/api/openapi.yaml index 0a4b218..00733ac 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,958 +1,21 @@ -openapi: "3.1.0" +openapi: 3.1.0 info: title: Wrenn API - description: AI agent execution platform API. - version: "0.2.0" - + description: API for Wrenn — the runtime where AI coding agents live, work, and ship. + version: 0.2.2 servers: - url: http://localhost:8080 description: Local development - security: [] - paths: - /v1/auth/signup: - post: - summary: Create a new account - operationId: signup - tags: [auth] - description: | - Creates an inactive user account and sends an activation email. - The user must activate their account within 30 minutes. - Does not return a JWT — the user must activate first, then sign in. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SignupRequest" - responses: - "201": - description: Account created, activation email sent - content: - application/json: - schema: - $ref: "#/components/schemas/SignupResponse" - "400": - description: Invalid request (bad email, short password) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: Email already registered or signup cooldown active - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/auth/activate: - post: - summary: Activate account via email token - operationId: activate - tags: [auth] - description: | - Consumes the activation token sent via email and activates the user account. - Creates a default team and sets a session cookie to log the user in. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token] - properties: - token: - type: string - responses: - "200": - description: Account activated, session cookie set - content: - application/json: - schema: - $ref: "#/components/schemas/SessionResponse" - "400": - description: Invalid or expired token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/auth/logout: - post: - summary: Revoke the current session - operationId: logout - tags: [auth] - security: - - sessionAuth: [] - responses: - "204": - description: Session revoked; cookies cleared - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/auth/logout-all: - post: - summary: Revoke every session for the current user - operationId: logoutAll - tags: [auth] - description: | - Revokes every active session for the calling user across all devices, - including the caller's own. Returns 204 and clears cookies on the - response. Triggered automatically by password change, password add, - and password reset. - security: - - sessionAuth: [] - responses: - "204": - description: All sessions revoked - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/me/sessions: - get: - summary: List the caller's active sessions - operationId: listSessions - tags: [me] - security: - - sessionAuth: [] - responses: - "200": - description: Sessions list - content: - application/json: - schema: - type: object - properties: - sessions: - type: array - items: - type: object - properties: - id: - type: string - user_agent: - type: string - ip_address: - type: string - created_at: - type: string - format: date-time - last_seen_at: - type: string - format: date-time - expires_at: - type: string - format: date-time - current: - type: boolean - "401": - $ref: "#/components/responses/Unauthorized" - - /v1/me/sessions/{id}: - delete: - summary: Revoke a single session - operationId: revokeSession - tags: [me] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "204": - description: Session revoked - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/auth/switch-team: - post: - summary: Switch active team - operationId: switchTeam - tags: [auth] - security: - - sessionAuth: [] - description: | - Rotates the session SID and updates its team scope. The user must be a - member of the target team (verified from DB). The new wrenn_sid and - wrenn_csrf cookies are set on the response. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [team_id] - properties: - team_id: - type: string - responses: - "200": - description: New session issued for the target team; cookies refreshed - content: - application/json: - schema: - $ref: "#/components/schemas/SessionResponse" - "403": - description: Not a member of this team - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Team not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/auth/login: - post: - summary: Log in with email and password - operationId: login - tags: [auth] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/LoginRequest" - responses: - "200": - description: Login successful - content: - application/json: - schema: - $ref: "#/components/schemas/SessionResponse" - "401": - description: Invalid credentials - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /auth/oauth/{provider}: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - get: - summary: Start OAuth login flow - operationId: oauthRedirect - tags: [auth] - description: | - Redirects the user to the OAuth provider's authorization page. - Sets a short-lived CSRF state cookie for validation on callback. - responses: - "302": - description: Redirect to provider authorization URL - "404": - description: Provider not found or not configured - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /auth/oauth/{provider}/callback: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - get: - summary: OAuth callback - operationId: oauthCallback - tags: [auth] - description: | - Handles the OAuth provider's callback after user authorization. - Exchanges the authorization code for a user profile, creates or - logs in the user, sets the wrenn_sid + wrenn_csrf cookies, and - redirects to the SPA callback page. - - **On success:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback` (no tokens in URL). - - **On error:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?error=...` - - Possible error codes: `access_denied`, `invalid_state`, `missing_code`, - `exchange_failed`, `email_taken`, `internal_error`. - parameters: - - name: code - in: query - schema: - type: string - description: Authorization code from the OAuth provider - - name: state - in: query - schema: - type: string - description: CSRF state parameter (must match the cookie) - responses: - "302": - description: Redirect to frontend with token or error - - /v1/me: - get: - summary: Get current user profile - operationId: getMe - tags: [account] - security: - - sessionAuth: [] - responses: - "200": - description: User profile - content: - application/json: - schema: - $ref: "#/components/schemas/MeResponse" - - patch: - summary: Update display name - operationId: updateName - tags: [account] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name] - properties: - name: - type: string - minLength: 1 - maxLength: 100 - responses: - "204": - description: Name updated; session caches refreshed - "400": - description: Invalid name - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Delete current account - operationId: deleteAccount - tags: [account] - security: - - sessionAuth: [] - description: | - Soft-deletes the account (sets status=deleted, deleted_at=now). - The account is permanently removed after 15 days. Blocked if the user - owns any team that has other members. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [confirmation] - properties: - confirmation: - type: string - description: Must match the user's email address (case-insensitive) - responses: - "204": - description: Account scheduled for deletion - "400": - description: Confirmation does not match email - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: User owns teams with other members - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/password: - post: - summary: Change or add password - operationId: changePassword - tags: [account] - security: - - sessionAuth: [] - description: | - For users with an existing password: requires `current_password` and `new_password`. - For OAuth-only users adding a password: requires `new_password` and `confirm_password`. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ChangePasswordRequest" - responses: - "204": - description: Password updated - "400": - description: Invalid request (short password, mismatch, etc.) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "401": - description: Current password is incorrect - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/password/reset: - post: - summary: Request a password reset email - operationId: requestPasswordReset - tags: [account] - description: | - Sends a password reset link to the given email. Always returns 200 - regardless of whether the email exists, to prevent account enumeration. - The reset token expires in 15 minutes. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [email] - properties: - email: - type: string - format: email - responses: - "204": - description: Request accepted (email sent if account exists) - - /v1/me/password/reset/confirm: - post: - summary: Confirm password reset - operationId: confirmPasswordReset - tags: [account] - description: | - Consumes a password reset token and sets a new password. The token is - single-use and expires after 15 minutes. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token, new_password] - properties: - token: - type: string - description: Raw reset token from the email link - new_password: - type: string - minLength: 8 - responses: - "204": - description: Password reset successful - "400": - description: Invalid or expired token, or password too short - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/providers/{provider}/connect: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - get: - summary: Initiate OAuth provider link - operationId: connectProvider - tags: [account] - security: - - sessionAuth: [] - description: | - Sets OAuth state and link cookies, then returns the provider's - authorization URL. The frontend navigates to this URL to start the - OAuth flow. On callback, the provider is linked to the current account - (not a new registration). - responses: - "200": - description: Authorization URL - content: - application/json: - schema: - type: object - properties: - auth_url: - type: string - format: uri - "404": - description: Provider not found or not configured - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/providers/{provider}: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - delete: - summary: Disconnect an OAuth provider - operationId: disconnectProvider - tags: [account] - security: - - sessionAuth: [] - description: | - Unlinks the OAuth provider from the current account. Blocked if this - is the user's only login method (no password and no other providers). - responses: - "204": - description: Provider disconnected - "400": - description: Cannot disconnect last login method - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Provider not connected - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/api-keys: - post: - summary: Create an API key - operationId: createAPIKey - tags: [api-keys] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateAPIKeyRequest" - responses: - "201": - description: API key created (plaintext key only shown once) - content: - application/json: - schema: - $ref: "#/components/schemas/APIKeyResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - get: - summary: List API keys for your team - operationId: listAPIKeys - tags: [api-keys] - security: - - sessionAuth: [] - responses: - "200": - description: List of API keys (plaintext keys are never returned) - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/APIKeyResponse" - - /v1/api-keys/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - delete: - summary: Delete an API key - operationId: deleteAPIKey - tags: [api-keys] - security: - - sessionAuth: [] - responses: - "204": - description: API key deleted - - /v1/users/search: - get: - summary: Search users by email prefix - operationId: searchUsers - tags: [users] - security: - - sessionAuth: [] - description: | - Returns up to 10 users whose email starts with the given prefix. - The prefix must contain "@". Intended for the add-member UI autocomplete. - parameters: - - name: email - in: query - required: true - schema: - type: string - description: Email prefix (must contain "@", e.g. "alice@") - responses: - "200": - description: Matching users - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/UserSearchResult" - "400": - description: Prefix does not contain "@" - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams: - get: - summary: List teams for the authenticated user - operationId: listTeams - tags: [teams] - security: - - sessionAuth: [] - responses: - "200": - description: Teams the user belongs to, each with their role - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/TeamWithRole" - - post: - summary: Create a new team - operationId: createTeam - tags: [teams] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name] - properties: - name: - type: string - description: 1-128 chars; A-Z a-z 0-9 space _ - responses: - "201": - description: Team created (caller is owner) - content: - application/json: - schema: - $ref: "#/components/schemas/TeamWithRole" - "400": - description: Invalid team name - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Team ID (must match the JWT's team_id) - - get: - summary: Get team info and member list - operationId: getTeam - tags: [teams] - security: - - sessionAuth: [] - responses: - "200": - description: Team details with members - content: - application/json: - schema: - $ref: "#/components/schemas/TeamDetail" - "403": - description: JWT team does not match requested team - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Team not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - patch: - summary: Rename the team - operationId: renameTeam - tags: [teams] - security: - - sessionAuth: [] - description: Admin or owner role required (verified from DB). - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name] - properties: - name: - type: string - responses: - "204": - description: Renamed - "400": - description: Invalid team name - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Insufficient role - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Delete the team - operationId: deleteTeam - tags: [teams] - security: - - sessionAuth: [] - description: | - Owner only. Soft-deletes the team and destroys all running/paused/starting - capsules. All DB records are preserved. The team slug is permanently reserved. - responses: - "204": - description: Team deleted - "403": - description: Caller is not the owner - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}/members: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: List team members - operationId: listTeamMembers - tags: [teams] - security: - - sessionAuth: [] - responses: - "200": - description: Members with roles - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/TeamMember" - - post: - summary: Add a member by email - operationId: addTeamMember - tags: [teams] - security: - - sessionAuth: [] - description: Admin or owner role required. User is added instantly as a member. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [email] - properties: - email: - type: string - format: email - responses: - "201": - description: Member added - content: - application/json: - schema: - $ref: "#/components/schemas/TeamMember" - "403": - description: Insufficient role - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: No account with that email - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "400": - description: User is already a member - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}/members/{uid}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: uid - in: path - required: true - schema: - type: string - description: Target user ID - - patch: - summary: Update member role - operationId: updateMemberRole - tags: [teams] - security: - - sessionAuth: [] - description: | - Admin or owner required. Valid target roles: admin, member. - The owner's role cannot be changed. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [role] - properties: - role: - type: string - enum: [admin, member] - responses: - "204": - description: Role updated - "403": - description: Insufficient role or attempt to modify owner - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: User is not a member - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Remove a member - operationId: removeTeamMember - tags: [teams] - security: - - sessionAuth: [] - description: Admin or owner required. Owner cannot be removed. - responses: - "204": - description: Member removed - "403": - description: Insufficient role or attempt to remove owner - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: User is not a member - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}/leave: - parameters: - - name: id - in: path - required: true - schema: - type: string - - post: - summary: Leave the team - operationId: leaveTeam - tags: [teams] - security: - - sessionAuth: [] - description: The owner cannot leave; they must delete the team instead. - responses: - "204": - description: Left the team - "403": - description: Owner cannot leave - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /v1/capsules: post: summary: Create a capsule operationId: createCapsule - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] requestBody: required: true content: @@ -966,20 +29,27 @@ paths: application/json: schema: $ref: "#/components/schemas/Capsule" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/FailedPrecondition" "502": description: Host agent error content: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" get: summary: List capsules for your team operationId: listCapsules - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] responses: "200": description: List of capsules @@ -989,22 +59,26 @@ paths: type: array items: $ref: "#/components/schemas/Capsule" - /v1/capsules/stats: get: summary: Get capsule usage stats for your team operationId: getCapsuleStats - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] parameters: - name: range in: query required: false schema: type: string - enum: [5m, 1h, 6h, 24h, 30d] + enum: + - 5m + - 1h + - 6h + - 24h + - 30d default: 1h description: Time window for the time-series data. responses: @@ -1016,15 +90,14 @@ paths: $ref: "#/components/schemas/CapsuleStats" "400": $ref: "#/components/responses/BadRequest" - /v1/capsules/usage: get: summary: Get daily CPU and RAM usage for your team operationId: getCapsuleUsage - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] parameters: - name: from in: query @@ -1049,7 +122,6 @@ paths: $ref: "#/components/schemas/UsageResponse" "400": $ref: "#/components/responses/BadRequest" - /v1/capsules/{id}: parameters: - name: id @@ -1057,14 +129,13 @@ paths: required: true schema: type: string - get: summary: Get capsule details operationId: getCapsule - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] responses: "200": description: Capsule details @@ -1072,24 +143,32 @@ paths: application/json: schema: $ref: "#/components/schemas/Capsule" + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" - delete: summary: Destroy a capsule operationId: destroyCapsule - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] responses: "202": description: Capsule destruction initiated - + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/FailedPrecondition" + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/exec: parameters: - name: id @@ -1097,14 +176,13 @@ paths: required: true schema: type: string - post: summary: Execute a command operationId: execCommand - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] requestBody: required: true content: @@ -1124,6 +202,8 @@ paths: application/json: schema: $ref: "#/components/schemas/BackgroundExecResponse" + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1136,7 +216,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/processes: parameters: - name: id @@ -1144,17 +225,19 @@ paths: required: true schema: type: string - get: summary: List running processes operationId: listProcesses - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Returns all running processes inside the capsule, including background + description: + "Returns all running processes inside the capsule, including background + processes and any processes started by templates or init scripts. + + " responses: "200": description: Process list @@ -1162,6 +245,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ProcessListResponse" + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1174,7 +259,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/processes/{selector}: parameters: - name: id @@ -1188,14 +274,13 @@ paths: description: Process PID (numeric) or tag (string) schema: type: string - delete: summary: Kill a process operationId: killProcess - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] parameters: - name: signal in: query @@ -1203,11 +288,15 @@ paths: description: Signal to send (SIGKILL or SIGTERM, default SIGKILL) schema: type: string - enum: [SIGKILL, SIGTERM] + enum: + - SIGKILL + - SIGTERM default: SIGKILL responses: "204": description: Process killed + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule or process not found content: @@ -1220,7 +309,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/processes/{selector}/stream: parameters: - name: id @@ -1234,28 +324,35 @@ paths: description: Process PID (numeric) or tag (string) schema: type: string - get: summary: Stream process output via WebSocket operationId: connectProcess - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Opens a WebSocket connection to stream stdout/stderr from a running + description: + 'Opens a WebSocket connection to stream stdout/stderr from a running + background process. The selector can be a numeric PID or a string tag. + Server sends JSON messages: + - `{"type": "start", "pid": 42}` — connected to process + - `{"type": "stdout", "data": "..."}` — stdout output + - `{"type": "stderr", "data": "..."}` — stderr output + - `{"type": "exit", "exit_code": 0}` — process exited + - `{"type": "error", "data": "..."}` — error message + + ' responses: "101": description: WebSocket upgrade - /v1/capsules/{id}/ping: parameters: - name: id @@ -1263,21 +360,26 @@ paths: required: true schema: type: string - post: summary: Reset capsule inactivity timer operationId: pingCapsule - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Resets the last_active_at timestamp for a running capsule, preventing + description: + "Resets the last_active_at timestamp for a running capsule, preventing + the auto-pause TTL from expiring. Use this as a keepalive for capsules + that are idle but should remain running. + + " responses: "204": description: Ping acknowledged, inactivity timer reset + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1290,7 +392,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /v1/capsules/{id}/metrics: parameters: - name: id @@ -1298,32 +399,47 @@ paths: required: true schema: type: string - get: summary: Get per-capsule resource metrics operationId: getCapsuleMetrics - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Returns time-series CPU, memory, and disk metrics for a capsule. + description: + "Returns time-series CPU, memory, and disk metrics for a capsule. + Three tiers are available with different granularity and retention: + - `10m`: 500ms samples, last 10 minutes + - `2h`: 30-second averages, last 2 hours + - `24h`: 5-minute averages, last 24 hours + For running capsules, data comes from the host agent's in-memory + ring buffer. For paused capsules, data is read from persisted + snapshots in the database. Stopped/destroyed capsules return 404. + + " parameters: - name: range in: query required: false schema: type: string - enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] - default: "10m" + enum: + - 5m + - 10m + - 1h + - 2h + - 6h + - 12h + - 24h + default: 10m description: Time range filter to query responses: "200": @@ -1344,7 +460,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/pause: parameters: - name: id @@ -1352,18 +469,21 @@ paths: required: true schema: type: string - post: summary: Pause a running capsule operationId: pauseCapsule - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Takes a snapshot of the capsule (VM state + memory + rootfs), then + description: + "Takes a snapshot of the capsule (VM state + memory + rootfs), then + destroys all running resources. The capsule exists only as files on + disk and can be resumed later. + + " responses: "202": description: Capsule pause initiated (status will be "pausing") @@ -1371,13 +491,18 @@ paths: application/json: schema: $ref: "#/components/schemas/Capsule" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/resume: parameters: - name: id @@ -1385,19 +510,23 @@ paths: required: true schema: type: string - post: summary: Resume a paused capsule operationId: resumeCapsule - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Restores a paused capsule from its snapshot. Cloud Hypervisor is + description: + "Restores a paused capsule from its snapshot. Cloud Hypervisor is + relaunched in --restore mode with memory_restore_mode=ondemand so + guest pages fault in lazily via userfaultfd. The original network + slot (and host-reachable IP) is preserved across pause/resume. + + " responses: "202": description: Capsule resume initiated (status will be "resuming") @@ -1405,38 +534,57 @@ paths: application/json: schema: $ref: "#/components/schemas/Capsule" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" "409": description: Capsule not paused content: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/snapshots: post: summary: Create a snapshot template operationId: createSnapshot - tags: [snapshots] + tags: + - snapshots security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Snapshot a capsule, processed asynchronously. The call returns + description: + "Snapshot a capsule, processed asynchronously. The call returns + immediately with the capsule in the `snapshotting` state, then it + returns to its original state on completion. The capsule must be + `running` or `paused`. + A `running` capsule is snapshotted live: it briefly pauses while its VM + state + memory + flattened rootfs are written to a new template, then + resumes to `running`. A `paused` capsule is snapshotted directly from + its on-disk state without reviving the VM, and stays `paused`. + Because it is async, the response does NOT contain the template. Watch + for the `template.snapshot.create` SSE event (its `outcome` reports + success or failure) or poll `GET /v1/snapshots` to observe completion. + Snapshots are immutable: each call must use a fresh name. Re-using + an existing name returns 409 Conflict. + + " requestBody: required: true content: @@ -1450,27 +598,32 @@ paths: application/json: schema: $ref: "#/components/schemas/Capsule" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" "409": description: Name already exists, or capsule is not running or paused content: application/json: schema: $ref: "#/components/schemas/Error" - get: summary: List templates for your team operationId: listSnapshots - tags: [snapshots] + tags: + - snapshots security: - apiKeyAuth: [] - - sessionAuth: [] parameters: - name: type in: query required: false schema: type: string - enum: [base, snapshot] + enum: + - base + - snapshot description: Filter by template type. responses: "200": @@ -1481,7 +634,6 @@ paths: type: array items: $ref: "#/components/schemas/Template" - /v1/snapshots/{name}: parameters: - name: name @@ -1489,25 +641,37 @@ paths: required: true schema: type: string - delete: summary: Delete a snapshot template operationId: deleteSnapshot - tags: [snapshots] + tags: + - snapshots security: - apiKeyAuth: [] - - sessionAuth: [] description: Removes the snapshot files from disk and deletes the database record. responses: "204": description: Snapshot deleted + "400": + $ref: "#/components/responses/BadRequest" + "403": + description: Cannot delete a platform-owned / system base template + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "404": description: Template not found content: application/json: schema: $ref: "#/components/schemas/Error" - + "409": + description: Delete failed (e.g. owning host offline) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/write: parameters: - name: id @@ -1515,21 +679,22 @@ paths: required: true schema: type: string - post: summary: Upload a file operationId: uploadFile - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] requestBody: required: true content: multipart/form-data: schema: type: object - required: [path, file] + required: + - path + - file properties: path: type: string @@ -1541,6 +706,10 @@ paths: responses: "204": description: File uploaded + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" "409": description: Capsule not running content: @@ -1553,7 +722,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/files/read: parameters: - name: id @@ -1561,14 +731,13 @@ paths: required: true schema: type: string - post: summary: Download a file operationId: downloadFile - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] requestBody: required: true content: @@ -1583,13 +752,22 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule or file not found content: application/json: schema: $ref: "#/components/schemas/Error" - + "409": + description: Capsule not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/files/list: parameters: - name: id @@ -1597,14 +775,13 @@ paths: required: true schema: type: string - post: summary: List directory contents operationId: listDir - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] requestBody: required: true content: @@ -1618,6 +795,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ListDirResponse" + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1630,7 +809,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/files/mkdir: parameters: - name: id @@ -1638,14 +818,13 @@ paths: required: true schema: type: string - post: summary: Create a directory operationId: makeDir - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] requestBody: required: true content: @@ -1659,6 +838,8 @@ paths: application/json: schema: $ref: "#/components/schemas/MakeDirResponse" + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1666,14 +847,16 @@ paths: schema: $ref: "#/components/schemas/Error" "409": - description: > - Capsule not running, or a directory already exists at the - target path (error code `already_exists`). + description: + "Capsule not running, or a directory already exists at the target path (error code `already_exists`). + + " content: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/files/remove: parameters: - name: id @@ -1681,14 +864,13 @@ paths: required: true schema: type: string - post: summary: Remove a file or directory operationId: removePath - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] requestBody: required: true content: @@ -1698,6 +880,8 @@ paths: responses: "204": description: File or directory removed + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1710,7 +894,8 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/exec/stream: parameters: - name: id @@ -1718,40 +903,67 @@ paths: required: true schema: type: string - get: summary: Stream command execution via WebSocket operationId: execStream - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Opens a WebSocket connection for streaming command execution. + description: + 'Opens a WebSocket connection for streaming command execution. + **Client sends** (first message to start the process): + ```json + {"type": "start", "cmd": "tail", "args": ["-f", "/var/log/syslog"]} + ``` + **Client sends** (to stop the process): + ```json + {"type": "stop"} + ``` + **Server sends** (process events as they arrive): + ```json + {"type": "start", "pid": 1234} + {"type": "stdout", "data": "line of output\n"} + {"type": "stderr", "data": "warning message\n"} + {"type": "exit", "exit_code": 0} + {"type": "error", "data": "description of error"} + ``` + The connection closes automatically after the process exits. + + + Note: capsule-not-found and not-running conditions encountered after the + + WebSocket upgrade are delivered as in-band `{"type": "error"}` frames + + rather than HTTP status codes. + + ' responses: "101": description: WebSocket upgrade + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1764,7 +976,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /v1/capsules/{id}/pty: parameters: - name: id @@ -1772,64 +983,36 @@ paths: required: true schema: type: string - get: summary: Interactive PTY session via WebSocket operationId: ptySession - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Opens a WebSocket connection for an interactive PTY (terminal) session. - Supports creating new sessions, sending input, resizing, killing, and - reconnecting to existing sessions. - - **Client sends** (first message — start a new PTY): - ```json - { - "type": "start", - "cmd": "/bin/bash", - "args": [], - "cols": 80, - "rows": 24, - "envs": {"TERM": "xterm-256color"}, - "cwd": "/home/user", - "user": "user" - } - ``` - All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24. - - **Client sends** (first message — reconnect to existing PTY): - ```json - {"type": "connect", "tag": "pty-abc123de"} - ``` - - **Client sends** (after session is established): - ```json - {"type": "input", "data": ""} - {"type": "resize", "cols": 120, "rows": 40} - {"type": "kill"} - ``` - - **Server sends**: - ```json - {"type": "started", "tag": "pty-abc123de", "pid": 42} - {"type": "output", "data": ""} - {"type": "exit", "exit_code": 0} - {"type": "error", "data": "description", "fatal": true} - {"type": "ping"} - ``` - - PTY data (input and output) is base64-encoded because it contains raw - terminal bytes (escape sequences, control codes) that are not valid UTF-8. - - Sessions persist across WebSocket disconnections — the process keeps - running in the capsule. Use the `tag` from the "started" response to - reconnect later. + description: + "Opens a WebSocket connection for an interactive PTY (terminal) session.\nSupports creating new sessions,\ + \ sending input, resizing, killing, and\nreconnecting to existing sessions.\n\n**Client sends** (first message — start\ + \ a new PTY):\n```json\n{\n \"type\": \"start\",\n \"cmd\": \"/bin/bash\",\n \"args\": [],\n \"cols\": 80,\n \ + \ \"rows\": 24,\n \"envs\": {\"TERM\": \"xterm-256color\"},\n \"cwd\": \"/home/user\",\n \"user\": \"user\"\n}\n\ + ```\nAll fields except `type` are optional. Defaults: cmd=\"/bin/bash\", cols=80, rows=24.\n\n**Client sends** (first\ + \ message — reconnect to existing PTY):\n```json\n{\"type\": \"connect\", \"tag\": \"pty-abc123de\"}\n```\n\n**Client\ + \ sends** (control messages, after session is established — JSON text frames):\n```json\n{\"type\": \"resize\", \"\ + cols\": 120, \"rows\": 40}\n{\"type\": \"kill\"}\n```\n\nKeystroke input is sent as **raw binary WebSocket frames**\ + \ containing the\nterminal bytes verbatim — not JSON, not base64. Every binary frame from\nthe client is treated as\ + \ PTY input.\n\n**Server sends** (control messages — JSON text frames):\n```json\n{\"type\": \"started\", \"tag\"\ + : \"pty-abc123de\", \"pid\": 42}\n{\"type\": \"exit\", \"exit_code\": 0}\n{\"type\": \"error\", \"data\": \"description\"\ + , \"fatal\": true}\n{\"type\": \"ping\"}\n```\n\nPTY output is sent as **raw binary WebSocket frames** containing\ + \ the\nterminal bytes verbatim — not JSON, not base64. Sending input and output\nas binary avoids the ~33% inflation\ + \ of base64 over the JSON path.\n\nThe connection negotiates permessage-deflate (RFC 7692) compression\nautomatically;\ + \ rapid output (e.g. full-screen TUI repaints) is coalesced\ninto fewer, larger frames before compression.\n\nSessions\ + \ persist across WebSocket disconnections — the process keeps\nrunning in the capsule. Use the `tag` from the \"started\"\ + \ response to\nreconnect later.\n" responses: "101": description: WebSocket upgrade + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1842,7 +1025,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - /v1/capsules/{id}/files/stream/write: parameters: - name: id @@ -1850,25 +1032,30 @@ paths: required: true schema: type: string - post: summary: Upload a file (streaming) operationId: streamUploadFile - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Streams file content to the capsule without buffering in memory. + description: + "Streams file content to the capsule without buffering in memory. + Suitable for large files. Uses the same multipart/form-data format + as the non-streaming upload endpoint. + + " requestBody: required: true content: multipart/form-data: schema: type: object - required: [path, file] + required: + - path + - file properties: path: type: string @@ -1880,6 +1067,8 @@ paths: responses: "204": description: File uploaded + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule not found content: @@ -1892,7 +1081,14 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - + "502": + description: Host agent error while streaming the upload + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/capsules/{id}/files/stream/read: parameters: - name: id @@ -1900,17 +1096,19 @@ paths: required: true schema: type: string - post: summary: Download a file (streaming) operationId: streamDownloadFile - tags: [capsules] + tags: + - capsules security: - apiKeyAuth: [] - - sessionAuth: [] - description: | - Streams file content from the capsule without buffering in memory. + description: + "Streams file content from the capsule without buffering in memory. + Suitable for large files. Returns raw bytes with chunked transfer encoding. + + " requestBody: required: true content: @@ -1925,6 +1123,8 @@ paths: schema: type: string format: binary + "400": + $ref: "#/components/responses/BadRequest" "404": description: Capsule or file not found content: @@ -1937,662 +1137,42 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - - /v1/hosts: - post: - summary: Create a host - operationId: createHost - tags: [hosts] - security: - - sessionAuth: [] - description: | - Creates a new host record and returns a one-time registration token. - Regular hosts can only be created by admins. BYOC hosts can be created - by admins or team owners. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHostRequest" - responses: - "201": - description: Host created with registration token - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHostResponse" - "400": - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - get: - summary: List hosts - operationId: listHosts - tags: [hosts] - security: - - sessionAuth: [] - description: | - Admins see all hosts. Non-admins see only BYOC hosts belonging to their team. - responses: - "200": - description: List of hosts - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Host" - - /v1/hosts/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: Get host details - operationId: getHost - tags: [hosts] - security: - - sessionAuth: [] - responses: - "200": - description: Host details - content: - application/json: - schema: - $ref: "#/components/schemas/Host" - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Delete a host - operationId: deleteHost - tags: [hosts] - security: - - sessionAuth: [] - description: | - Admins can delete any host. Team owners and admins can delete BYOC hosts - belonging to their team. Without `?force=true`, returns 409 if the host - has active capsules. With `?force=true`, destroys all capsules first. - parameters: - - name: force - in: query - required: false - schema: - type: boolean - description: If true, destroy all capsules on the host before deleting. - responses: - "204": - description: Host deleted - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: Host has active capsules (only when force is not set) - content: - application/json: - schema: - $ref: "#/components/schemas/HostHasCapsulesError" - - /v1/hosts/{id}/token: - parameters: - - name: id - in: path - required: true - schema: - type: string - - post: - summary: Regenerate registration token - operationId: regenerateHostToken - tags: [hosts] - security: - - sessionAuth: [] - description: | - Issues a new registration token for a host still in "pending" status. - Use this when a previous registration attempt failed after consuming - the original token. Same permission model as host creation. - responses: - "201": - description: New registration token issued - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHostResponse" - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: Host is not in pending status - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/register: - post: - summary: Register a host agent - operationId: registerHost - tags: [hosts] - description: | - Called by the host agent on first startup. Validates the one-time - registration token, records machine specs, sets the host status to - "online", and returns a long-lived JWT for subsequent API calls - (heartbeats). - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterHostRequest" - responses: - "201": - description: Host registered, JWT returned - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterHostResponse" - "400": - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "401": - description: Invalid or expired registration token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/heartbeat: - parameters: - - name: id - in: path - required: true - schema: - type: string - - post: - summary: Host agent heartbeat - operationId: hostHeartbeat - tags: [hosts] - security: - - hostTokenAuth: [] - description: | - Updates the host's last_heartbeat_at timestamp. The host ID in the URL - must match the host ID in the JWT. - responses: - "204": - description: Heartbeat recorded - "401": - description: Invalid or missing host token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Host ID mismatch - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/sandbox-events: - post: - summary: Sandbox lifecycle event callback - operationId: sandboxEventCallback - tags: [hosts] - security: - - hostTokenAuth: [] - description: | - Receives autonomous lifecycle events from host agents (e.g. auto-pause - from the TTL reaper). The event is published to an internal Redis stream - for the control plane's event consumer to process. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [event, sandbox_id, host_id] - properties: - event: - type: string - description: | - Lifecycle event type. Known values: - * `sandbox.auto_paused` — TTL reaper paused the capsule - * `sandbox.stopped` — autonomous destroy (crash/eviction) - * `sandbox.error` — VMM/crash watcher reported error - Unknown event names are accepted and forwarded to the - stream consumer as-is (future-compatible). - sandbox_id: - type: string - host_id: - type: string - timestamp: - type: integer - format: int64 - responses: - "204": - description: Event accepted - "400": - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Host ID mismatch - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/auth/refresh: - post: - summary: Refresh host JWT - operationId: refreshHostToken - tags: [hosts] - description: | - Exchanges a refresh token for a new JWT and rotated refresh token. - The old refresh token is immediately revoked. No authentication required — - the refresh token itself is the credential. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RefreshHostTokenRequest" - responses: - "200": - description: New JWT and rotated refresh token - content: - application/json: - schema: - $ref: "#/components/schemas/RefreshHostTokenResponse" - "401": - description: Invalid, expired, or revoked refresh token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/delete-preview: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: Preview host deletion - operationId: getHostDeletePreview - tags: [hosts] - security: - - sessionAuth: [] - description: | - Returns the list of capsule IDs that would be destroyed if the host - were deleted with `?force=true`. No state is modified. - responses: - "200": - description: Deletion preview - content: - application/json: - schema: - $ref: "#/components/schemas/HostDeletePreview" - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/tags: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: List host tags - operationId: listHostTags - tags: [hosts] - security: - - sessionAuth: [] - responses: - "200": - description: List of tags - content: - application/json: - schema: - type: array - items: - type: string - - post: - summary: Add a tag to a host - operationId: addHostTag - tags: [hosts] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AddTagRequest" - responses: - "204": - description: Tag added - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/tags/{tag}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: tag - in: path - required: true - schema: - type: string - - delete: - summary: Remove a tag from a host - operationId: removeHostTag - tags: [hosts] - security: - - sessionAuth: [] - responses: - "204": - description: Tag removed - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/channels: - post: - summary: Create a notification channel - operationId: createChannel - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateChannelRequest" - responses: - "201": - description: Channel created - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "400": - $ref: "#/components/responses/BadRequest" - get: - summary: List notification channels - operationId: listChannels - tags: [channels] - security: - - sessionAuth: [] - responses: - "200": - description: Channels list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/ChannelResponse" - - /v1/channels/test: - post: - summary: Test a channel configuration - description: > - Sends a test notification using the provided provider and config without - saving anything. Use this to verify credentials before creating a channel. - operationId: testChannel - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TestChannelRequest" - responses: - "200": - description: Test notification sent successfully - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: ok - "400": - $ref: "#/components/responses/BadRequest" - - /v1/channels/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - get: - summary: Get a notification channel - operationId: getChannel - tags: [channels] - security: - - sessionAuth: [] - responses: - "200": - description: Channel details - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "404": - description: Channel not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - patch: - summary: Update a notification channel - operationId: updateChannel - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateChannelRequest" - responses: - "200": - description: Channel updated - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "400": - $ref: "#/components/responses/BadRequest" - "404": - description: Channel not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete a notification channel - operationId: deleteChannel - tags: [channels] - security: - - sessionAuth: [] - responses: - "204": - description: Channel deleted - - /v1/channels/{id}/config: - parameters: - - name: id - in: path - required: true - schema: - type: string - put: - summary: Rotate channel secrets - description: > - Replaces the channel's provider configuration entirely with new secrets. - The previous config is discarded. Config fields must match the provider's - required fields. - operationId: rotateChannelConfig - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RotateConfigRequest" - responses: - "200": - description: Config rotated - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "400": - $ref: "#/components/responses/BadRequest" - "404": - description: Channel not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/admin/users/{id}/admin: - put: - summary: Grant or revoke platform admin - operationId: setUserAdmin - tags: [admin] - description: | - Sets the platform admin flag on a user. Cannot remove the last admin. - Requires platform admin access. Session caches for the target user - are invalidated immediately so the flag flip takes effect on the - user's next request. - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - example: "usr-a1b2c3d4" - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [admin] - properties: - admin: - type: boolean - description: true to grant admin, false to revoke. - responses: - "204": - description: Admin status updated - "400": - $ref: "#/components/responses/BadRequest" - "403": - description: Caller is not a platform admin - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: User not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - + "503": + $ref: "#/components/responses/ServiceUnavailable" /v1/events/stream: get: summary: Real-time lifecycle event stream operationId: streamEvents - tags: [events] - description: | - Server-Sent Events stream of capsule, template, and host lifecycle - events scoped to the caller's active team. Browsers send the + tags: + - events + description: + 'Server-Sent Events stream of capsule, template, and host lifecycle + + events scoped to the caller''s active team. Browsers send the + wrenn_sid cookie automatically on EventSource connections; SDKs + authenticate via X-API-Key. + Frame format follows the standard SSE protocol: + ``` + event: capsule.create + data: {"event":"capsule.create","outcome":"success","resource":{"id":"sb-..."},"sandbox":{...},"timestamp":"2026-05-19T02:00:00Z"} + : keepalive + ``` + A `: keepalive` comment is emitted every 30s. + + ' security: - apiKeyAuth: [] - - sessionAuth: [] responses: "200": description: SSE stream opened @@ -2606,829 +1186,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - - /v1/audit-logs: - get: - summary: List team audit log entries - operationId: listAuditLogs - tags: [audit] - description: Paginated cursor list of audit events for the caller's team. - security: - - sessionAuth: [] - parameters: - - name: before - in: query - required: false - schema: - type: string - format: date-time - - name: before_id - in: query - required: false - schema: - type: string - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 200 - default: 50 - responses: - "200": - description: Audit log page - content: - application/json: - schema: - type: object - properties: - entries: - type: array - items: - $ref: "#/components/schemas/AuditLogEntry" - next_cursor: - type: object - nullable: true - properties: - before: - type: string - format: date-time - before_id: - type: string - - /v1/admin/events/stream: - get: - summary: Admin SSE event stream (all teams) - operationId: adminStreamEvents - tags: [admin, events] - description: | - Admin variant of /v1/events/stream that emits events across all teams. - Requires an admin session cookie. - security: - - sessionAuth: [] - responses: - "200": - description: SSE stream opened - content: - text/event-stream: - schema: - $ref: "#/components/schemas/SSEEvent" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/audit-logs: - get: - summary: List audit log entries (all teams) - operationId: adminListAuditLogs - tags: [admin, audit] - security: - - sessionAuth: [] - parameters: - - name: before - in: query - schema: {type: string, format: date-time} - - name: before_id - in: query - schema: {type: string} - - name: limit - in: query - schema: {type: integer, minimum: 1, maximum: 200, default: 50} - responses: - "200": - description: Audit log page (all teams) - content: - application/json: - schema: - type: object - properties: - entries: - type: array - items: - $ref: "#/components/schemas/AuditLogEntry" - - /v1/admin/teams: - get: - summary: List all teams (admin) - operationId: adminListTeams - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: page - in: query - required: false - schema: - type: integer - minimum: 1 - default: 1 - description: Page number for pagination. - responses: - "200": - description: Paginated teams list - content: - application/json: - schema: - type: object - properties: - teams: - type: array - items: - $ref: "#/components/schemas/AdminTeam" - total: - type: integer - page: - type: integer - per_page: - type: integer - total_pages: - type: integer - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/teams/{id}/byoc: - put: - summary: Toggle BYOC for a team (admin) - operationId: adminSetTeamBYOC - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [enabled] - properties: - enabled: - type: boolean - description: true to enable BYOC, false to disable. - responses: - "204": - description: Updated - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/teams/{id}: - delete: - summary: Delete a team (admin) - operationId: adminDeleteTeam - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "204": - description: Deleted - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - - /v1/admin/hosts: - get: - summary: List all hosts (admin) - operationId: adminListHosts - tags: [admin] - security: - - sessionAuth: [] - description: | - Returns all hosts across all teams with per-host resource consumption. - Includes team name for hosts associated with a team. - responses: - "200": - description: Hosts list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Host" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/users: - get: - summary: List all users (admin) - operationId: adminListUsers - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Users list - content: - application/json: - schema: - type: array - items: {type: object} - - /v1/admin/users/{id}/active: - put: - summary: Activate or deactivate a user (admin) - operationId: adminSetUserActive - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [active] - properties: - active: {type: boolean} - responses: - "204": - description: Updated - - /v1/admin/templates: - get: - summary: List all templates (admin) - operationId: adminListTemplates - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Templates list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/AdminTemplate" - - /v1/admin/templates/{name}: - delete: - summary: Delete a template (admin) - operationId: adminDeleteTemplate - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: name - in: path - required: true - schema: {type: string} - responses: - "204": - description: Deleted - - /v1/admin/builds: - post: - summary: Submit a template build (admin) - operationId: adminCreateBuild - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: {type: object} - responses: - "202": - description: Build queued - content: - application/json: - schema: {type: object} - get: - summary: List builds (admin) - operationId: adminListBuilds - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Builds list - content: - application/json: - schema: - type: array - items: {type: object} - - /v1/admin/builds/{id}: - get: - summary: Get build detail (admin) - operationId: adminGetBuild - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "200": - description: Build detail - content: - application/json: - schema: {type: object} - - /v1/admin/builds/{id}/cancel: - post: - summary: Cancel a build (admin) - operationId: adminCancelBuild - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "204": - description: Cancelled - - /v1/admin/builds/{id}/stream: - get: - summary: Stream a build's live console (admin, WebSocket) - description: > - WebSocket endpoint. On connect, replays the completed-step history, - then live-tails JSON events (step-start, output, step-end, - build-status, ping) until the build finishes. - operationId: adminStreamBuild - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "101": - description: WebSocket upgrade — streams build console events - - /v1/admin/capsules: - post: - summary: Create a capsule on behalf of any team (admin) - operationId: adminCreateCapsule - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateCapsuleRequest" - responses: - "201": - description: Capsule created - content: - application/json: - schema: - $ref: "#/components/schemas/Capsule" - get: - summary: List capsules across all teams (admin) - operationId: adminListCapsules - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Capsules list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Capsule" - - /v1/admin/capsules/{id}: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Get capsule detail (admin) - operationId: adminGetCapsule - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Capsule detail - content: - application/json: - schema: - $ref: "#/components/schemas/Capsule" - delete: - summary: Destroy capsule (admin) - operationId: adminDestroyCapsule - tags: [admin] - security: - - sessionAuth: [] - responses: - "204": - description: Destroyed - - /v1/admin/capsules/{id}/snapshot: - post: - summary: Create snapshot from any capsule (admin) - operationId: adminCreateSnapshotFromCapsule - tags: [admin] - description: | - Snapshots a `running` or `paused` capsule into a platform template, - processed asynchronously (see `POST /v1/snapshots`). A running capsule - resumes to `running`; a paused capsule stays `paused`. - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: Optional; an auto-generated name is used when omitted. - responses: - "202": - description: Snapshot accepted; capsule is now snapshotting - content: - application/json: - schema: - $ref: "#/components/schemas/Capsule" - - /v1/admin/capsules/{id}/exec: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Execute a command on any capsule (admin) - operationId: adminExecCommand - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ExecRequest" - responses: - "200": - description: Command output (foreground exec) - content: - application/json: - schema: - $ref: "#/components/schemas/ExecResponse" - "202": - description: Background process started - content: - application/json: - schema: - $ref: "#/components/schemas/BackgroundExecResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/metrics: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Get per-capsule resource metrics (admin) - operationId: adminGetCapsuleMetrics - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: range - in: query - required: false - schema: - type: string - enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] - default: "10m" - responses: - "200": - description: Metrics retrieved - content: - application/json: - schema: - $ref: "#/components/schemas/CapsuleMetrics" - "404": - $ref: "#/components/responses/NotFound" - - /v1/admin/capsules/{id}/processes: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: List running processes on any capsule (admin) - operationId: adminListProcesses - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Process list - content: - application/json: - schema: - $ref: "#/components/schemas/ProcessListResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/processes/{selector}: - parameters: - - name: id - in: path - required: true - schema: {type: string} - - name: selector - in: path - required: true - schema: {type: string} - description: Process PID (numeric) or tag (string) - delete: - summary: Kill a process on any capsule (admin) - operationId: adminKillProcess - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: signal - in: query - required: false - schema: - type: string - enum: [SIGKILL, SIGTERM] - default: SIGKILL - responses: - "204": - description: Process killed - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/write: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Upload a file to any capsule (admin) - operationId: adminUploadFile - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: [path, file] - properties: - path: {type: string} - file: {type: string, format: binary} - responses: - "204": - description: File uploaded - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/read: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Download a file from any capsule (admin) - operationId: adminDownloadFile - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ReadFileRequest" - responses: - "200": - description: File content - content: - application/octet-stream: - schema: - type: string - format: binary - "404": - $ref: "#/components/responses/NotFound" - - /v1/admin/capsules/{id}/files/list: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: List directory contents on any capsule (admin) - operationId: adminListDir - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListDirRequest" - responses: - "200": - description: Directory listing - content: - application/json: - schema: - $ref: "#/components/schemas/ListDirResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/mkdir: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Create a directory on any capsule (admin) - operationId: adminMakeDir - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/MakeDirRequest" - responses: - "200": - description: Directory created - content: - application/json: - schema: - $ref: "#/components/schemas/MakeDirResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/remove: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Remove a file or directory on any capsule (admin) - operationId: adminRemovePath - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RemoveRequest" - responses: - "204": - description: File or directory removed - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/exec/stream: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Stream command execution on any capsule via WebSocket (admin) - operationId: adminExecStream - tags: [admin] - security: - - sessionAuth: [] - description: | - Admin variant of /v1/capsules/{id}/exec/stream. Same protocol — WebSocket - upgrade, client sends `{"type":"start", "cmd":..., "args":...}` to start; - server streams stdout/stderr/exit frames. - responses: - "101": - description: WebSocket upgrade - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/pty: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Interactive PTY session on any capsule via WebSocket (admin) - operationId: adminPtySession - tags: [admin] - security: - - sessionAuth: [] - description: | - Admin variant of /v1/capsules/{id}/pty. Same protocol — base64-encoded - PTY bytes, start/connect/input/resize/kill control messages, persistent - sessions reconnectable via tag. - responses: - "101": - description: WebSocket upgrade - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/processes/{selector}/stream: - parameters: - - name: id - in: path - required: true - schema: {type: string} - - name: selector - in: path - required: true - schema: {type: string} - description: Process PID (numeric) or tag (string) - get: - summary: Stream process output on any capsule via WebSocket (admin) - operationId: adminConnectProcess - tags: [admin] - security: - - sessionAuth: [] - responses: - "101": - description: WebSocket upgrade - "404": - $ref: "#/components/responses/NotFound" - components: responses: BadRequest: @@ -3437,149 +1194,37 @@ components: application/json: schema: $ref: "#/components/schemas/Error" - - Unauthorized: - description: Missing or invalid auth - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Forbidden: description: Authenticated but not permitted (e.g. non-admin on /v1/admin/*) content: application/json: schema: $ref: "#/components/schemas/Error" - NotFound: description: Resource not found content: application/json: schema: $ref: "#/components/schemas/Error" - FailedPrecondition: description: Resource state does not allow this operation (e.g. exec on a paused capsule) content: application/json: schema: $ref: "#/components/schemas/Error" - + ServiceUnavailable: + description: The host serving this capsule is unreachable (host_unavailable) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" securitySchemes: apiKeyAuth: type: apiKey in: header name: X-API-Key description: API key for capsule lifecycle operations. Create via POST /v1/api-keys. - - sessionAuth: - type: apiKey - in: cookie - name: wrenn_sid - description: | - Opaque session cookie set by POST /v1/auth/login, /v1/auth/activate, or - the OAuth callback. HttpOnly, Secure, SameSite=Strict. Idle window 6h, - absolute lifetime 24h. State-changing requests also require an - X-CSRF-Token header matching the wrenn_csrf cookie (double-submit). - csrfHeader: - type: apiKey - in: header - name: X-CSRF-Token - description: | - Double-submit CSRF token whose value must match the wrenn_csrf cookie. - Required on all non-GET requests authenticated via session cookie. - Not required for API key auth. - - hostTokenAuth: - type: apiKey - in: header - name: X-Host-Token - description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days. - schemas: - SignupRequest: - type: object - required: [email, password, name] - properties: - email: - type: string - format: email - password: - type: string - minLength: 8 - name: - type: string - maxLength: 100 - - LoginRequest: - type: object - required: [email, password] - properties: - email: - type: string - format: email - password: - type: string - - SignupResponse: - type: object - properties: - message: - type: string - description: Confirmation message instructing user to check email - - SessionResponse: - type: object - description: | - 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. - properties: - user_id: - type: string - team_id: - type: string - email: - type: string - name: - type: string - role: - type: string - is_admin: - type: boolean - - CreateAPIKeyRequest: - type: object - properties: - name: - type: string - default: Unnamed API Key - - APIKeyResponse: - type: object - properties: - id: - type: string - team_id: - type: string - name: - type: string - key_prefix: - type: string - description: Display prefix (e.g. "wrn_ab12cd34...") - created_at: - type: string - format: date-time - last_used: - type: string - format: date-time - nullable: true - key: - type: string - description: Full plaintext key. Only returned on creation, never again. - nullable: true - CreateCapsuleRequest: type: object properties: @@ -3592,23 +1237,27 @@ components: memory_mb: type: integer default: 512 - disk_size_mb: - type: integer - default: 5120 - 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). timeout_sec: type: integer minimum: 0 default: 0 - 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). + 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). + " + metadata: + type: object + additionalProperties: + type: string + nullable: true + description: + "Optional user-supplied key/value labels attached to the capsule. Max 20 keys; keys match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}; + values are at most 64 characters. Reserved system keys (kernel_version, vmm_version, agent_version, envd_version) + are rejected. Metadata is set at create-time only. + + " UsageResponse: type: object properties: @@ -3630,13 +1279,17 @@ components: type: number ram_mb_minutes: type: number - CapsuleStats: type: object properties: range: type: string - enum: [5m, 1h, 6h, 24h, 30d] + enum: + - 5m + - 1h + - 6h + - 24h + - 30d current: type: object properties: @@ -3677,7 +1330,6 @@ components: type: array items: type: integer - Capsule: type: object properties: @@ -3685,7 +1337,19 @@ components: type: string status: type: string - enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error] + enum: + - pending + - starting + - running + - pausing + - paused + - snapshotting + - resuming + - stopping + - hibernated + - stopped + - missing + - error template: type: string vcpus: @@ -3710,12 +1374,19 @@ components: format: date-time metadata: type: object - additionalProperties: {type: string} + additionalProperties: + type: string nullable: true - description: | - Free-form key/value labels attached at create-time. Also carries - agent-side version info (kernel_version, vmm_version, - agent_version, envd_version) when running. + description: + "User-supplied key/value labels attached at create-time. Once the + + capsule is running this also carries agent-side system version info + + (kernel_version, vmm_version, agent_version, envd_version); those + + reserved keys are owned by the system and cannot be set by users. + + " disk_size_mb: type: integer description: Maximum disk capacity in MiB. @@ -3723,10 +1394,10 @@ components: type: integer format: int64 description: Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses. - CreateSnapshotRequest: type: object - required: [sandbox_id] + required: + - sandbox_id properties: sandbox_id: type: string @@ -3734,7 +1405,6 @@ components: name: type: string description: Name for the snapshot template. Auto-generated if omitted. - Template: type: object properties: @@ -3742,7 +1412,9 @@ components: type: string type: type: string - enum: [base, snapshot] + enum: + - base + - snapshot vcpus: type: integer nullable: true @@ -3757,56 +1429,32 @@ components: format: date-time platform: type: boolean - description: | - True when the template is platform-managed (visible to all teams, + description: + "True when the template is platform-managed (visible to all teams, + e.g. the built-in `minimal-ubuntu` rootfs). False for team-owned + snapshot templates. + + " protected: type: boolean - description: | - True for built-in system base templates (minimal-ubuntu, + description: "True for built-in system base templates (minimal-ubuntu, + minimal-alpine, minimal-arch, minimal-fedora). Protected templates + cannot be deleted. + + " metadata: type: object - additionalProperties: {type: string} + additionalProperties: + type: string nullable: true - - AdminTemplate: - type: object - description: | - Template as returned by the admin templates list. Unlike `Template` - (the team-facing snapshot shape), this includes the owning `team_id` - and omits `platform`/`metadata`. - properties: - name: - type: string - type: - type: string - enum: [base, snapshot] - vcpus: - type: integer - memory_mb: - type: integer - size_bytes: - type: integer - format: int64 - team_id: - type: string - description: Owning team ID (formatted, e.g. `team-…`). Platform team for global templates. - created_at: - type: string - format: date-time - protected: - type: boolean - description: | - True for built-in system base templates (minimal-ubuntu, - minimal-alpine, minimal-arch, minimal-fedora). Protected templates - cannot be deleted. - ExecRequest: type: object - required: [cmd] + required: + - cmd properties: cmd: type: string @@ -3824,16 +1472,17 @@ components: description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202) tag: type: string - description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true. + description: + Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background + is true. envs: type: object additionalProperties: type: string - description: Environment variables for the process (background exec only) + description: Environment variables for the process (applies to both foreground and background exec) cwd: type: string - description: Working directory for the process (background exec only) - + description: Working directory for the process (applies to both foreground and background exec) BackgroundExecResponse: type: object properties: @@ -3845,7 +1494,6 @@ components: type: integer tag: type: string - ProcessEntry: type: object properties: @@ -3859,7 +1507,6 @@ components: type: array items: type: string - ProcessListResponse: type: object properties: @@ -3867,7 +1514,6 @@ components: type: array items: $ref: "#/components/schemas/ProcessEntry" - ExecResponse: type: object properties: @@ -3885,20 +1531,22 @@ components: type: integer encoding: type: string - enum: [utf-8, base64] + enum: + - utf-8 + - base64 description: Output encoding. "base64" when stdout/stderr contain binary data. - ReadFileRequest: type: object - required: [path] + required: + - path properties: path: type: string description: Absolute file path inside the capsule - ListDirRequest: type: object - required: [path] + required: + - path properties: path: type: string @@ -3907,7 +1555,6 @@ components: type: integer default: 1 description: Recursion depth (0 = non-recursive, 1 = immediate children) - ListDirResponse: type: object properties: @@ -3915,7 +1562,6 @@ components: type: array items: $ref: "#/components/schemas/FileEntry" - FileEntry: type: object properties: @@ -3925,7 +1571,10 @@ components: type: string type: type: string - enum: [file, directory, symlink] + enum: + - file + - directory + - symlink size: type: integer format: int64 @@ -3945,298 +1594,27 @@ components: symlink_target: type: string nullable: true - MakeDirRequest: type: object - required: [path] + required: + - path properties: path: type: string description: Directory path to create inside the capsule - MakeDirResponse: type: object properties: entry: $ref: "#/components/schemas/FileEntry" - RemoveRequest: type: object - required: [path] + required: + - path properties: path: type: string description: Path to remove inside the capsule - - CreateHostRequest: - type: object - required: [type] - properties: - type: - type: string - enum: [regular, byoc] - description: Host type. Regular hosts are shared; BYOC hosts belong to a team. - team_id: - type: string - description: Required for BYOC hosts. - provider: - type: string - description: Cloud provider (e.g. aws, gcp, hetzner, bare-metal). - availability_zone: - type: string - description: Availability zone (e.g. us-east, eu-west). - - CreateHostResponse: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - registration_token: - type: string - description: One-time registration token for the host agent. Expires in 1 hour. - - RegisterHostRequest: - type: object - required: [token, address] - properties: - token: - type: string - description: One-time registration token from POST /v1/hosts. - arch: - type: string - description: CPU architecture (e.g. x86_64, aarch64). - cpu_cores: - type: integer - memory_mb: - type: integer - disk_gb: - type: integer - address: - type: string - description: Host agent address (ip:port). - - RegisterHostResponse: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - token: - type: string - description: Host JWT for X-Host-Token header. Valid for 7 days. - refresh_token: - type: string - description: Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use. - - Host: - type: object - properties: - id: - type: string - type: - type: string - enum: [regular, byoc] - team_id: - type: string - nullable: true - provider: - type: string - nullable: true - availability_zone: - type: string - nullable: true - arch: - type: string - nullable: true - cpu_cores: - type: integer - nullable: true - memory_mb: - type: integer - nullable: true - disk_gb: - type: integer - nullable: true - address: - type: string - nullable: true - status: - type: string - enum: [pending, online, offline, draining, unreachable] - last_heartbeat_at: - type: string - format: date-time - nullable: true - created_by: - type: string - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - team_name: - type: string - nullable: true - description: Team name (included when listing hosts as an admin). - running_vcpus: - type: integer - description: Total vCPUs allocated to running capsules on this host. - running_memory_mb: - type: integer - description: Total memory in MB allocated to running capsules on this host. - running_disk_mb: - type: integer - description: Total disk in MB allocated to running capsules on this host. - paused_memory_mb: - type: integer - description: Total memory in MB allocated to paused capsules on this host. - paused_disk_mb: - type: integer - description: Total disk in MB allocated to paused capsules on this host. - - RefreshHostTokenRequest: - type: object - required: [refresh_token] - properties: - refresh_token: - type: string - description: Refresh token obtained from registration or a previous refresh. - - RefreshHostTokenResponse: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - token: - type: string - description: New host JWT. Valid for 7 days. - refresh_token: - type: string - description: New refresh token. Valid for 60 days; old token is revoked. - - HostDeletePreview: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - sandbox_ids: - type: array - items: - type: string - description: IDs of capsules that would be destroyed on force-delete. - - HostHasCapsulesError: - type: object - properties: - error: - type: object - properties: - code: - type: string - example: host_has_sandboxes - message: - type: string - sandbox_ids: - type: array - items: - type: string - description: IDs of active capsules blocking deletion. - - AddTagRequest: - type: object - required: [tag] - properties: - tag: - type: string - - UserSearchResult: - type: object - properties: - user_id: - type: string - email: - type: string - - Team: - type: object - properties: - id: - type: string - name: - type: string - slug: - type: string - description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3) - created_at: - type: string - format: date-time - - TeamWithRole: - allOf: - - $ref: "#/components/schemas/Team" - - type: object - properties: - role: - type: string - enum: [owner, admin, member] - - TeamMember: - type: object - properties: - user_id: - type: string - email: - type: string - role: - type: string - enum: [owner, admin, member] - joined_at: - type: string - format: date-time - - TeamDetail: - type: object - properties: - team: - $ref: "#/components/schemas/Team" - members: - type: array - items: - $ref: "#/components/schemas/TeamMember" - - AdminTeam: - type: object - properties: - id: - type: string - name: - type: string - slug: - type: string - is_byoc: - type: boolean - created_at: - type: string - format: date-time - deleted_at: - type: string - format: date-time - nullable: true - member_count: - type: integer - owner_name: - type: string - owner_email: - type: string - active_sandbox_count: - type: integer - channel_count: - type: integer - running_vcpus: - type: integer - running_memory_mb: - type: integer - CapsuleMetrics: type: object properties: @@ -4244,12 +1622,18 @@ components: type: string range: type: string - enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] + enum: + - 5m + - 10m + - 1h + - 2h + - 6h + - 12h + - 24h points: type: array items: $ref: "#/components/schemas/MetricPoint" - MetricPoint: type: object properties: @@ -4259,153 +1643,15 @@ components: cpu_pct: type: number format: double - description: "CPU utilization percentage (0-100), normalized to vCPU count" + description: CPU utilization percentage (0-100), normalized to vCPU count mem_bytes: type: integer format: int64 - description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)" + description: Resident memory in bytes (VmRSS of Cloud Hypervisor process) disk_bytes: type: integer format: int64 - description: "Allocated disk bytes for the CoW sparse file" - - CreateChannelRequest: - type: object - required: [name, provider, config, events] - properties: - name: - type: string - description: Unique channel name within the team. - provider: - type: string - enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] - config: - type: object - additionalProperties: - type: string - description: > - Provider-specific configuration fields. - Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. - Telegram: {"bot_token": "...", "chat_id": "..."}. - Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. - Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted). - events: - type: array - items: - type: string - enum: - - capsule.create - - capsule.pause - - capsule.resume - - capsule.destroy - - template.snapshot.create - - template.snapshot.delete - - host.up - - host.down - - TestChannelRequest: - type: object - required: [provider, config] - properties: - provider: - type: string - enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] - config: - type: object - additionalProperties: - type: string - description: Provider-specific configuration fields (same as CreateChannelRequest.config). - - RotateConfigRequest: - type: object - required: [config] - properties: - config: - type: object - additionalProperties: - type: string - description: > - New provider configuration fields. Must include all required fields - for the channel's provider. Replaces the existing config entirely. - - UpdateChannelRequest: - type: object - required: [name, events] - properties: - name: - type: string - events: - type: array - items: - type: string - enum: - - capsule.create - - capsule.pause - - capsule.resume - - capsule.destroy - - template.snapshot.create - - template.snapshot.delete - - host.up - - host.down - - ChannelResponse: - type: object - properties: - id: - type: string - team_id: - type: string - name: - type: string - provider: - type: string - enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] - events: - type: array - items: - type: string - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - secret: - type: string - nullable: true - description: Webhook secret. Only returned on creation, never again. - - MeResponse: - type: object - properties: - name: - type: string - email: - type: string - format: email - has_password: - type: boolean - description: Whether the user has a password set (false for OAuth-only accounts) - providers: - type: array - items: - type: string - description: List of linked OAuth provider names (e.g. ["github"]) - - ChangePasswordRequest: - type: object - required: [new_password] - properties: - current_password: - type: string - description: Required when changing an existing password - new_password: - type: string - minLength: 8 - confirm_password: - type: string - description: Required when adding a password to an OAuth-only account (must match new_password) - + description: Allocated disk bytes for the CoW sparse file Error: type: object properties: @@ -4416,31 +1662,14 @@ components: type: string message: type: string - - AuditLogEntry: - type: object - properties: - id: {type: string} - actor_type: {type: string, enum: [user, api_key, host, system]} - actor_id: {type: string} - actor_name: {type: string} - resource_type: {type: string} - resource_id: {type: string} - action: {type: string} - scope: {type: string} - status: {type: string, enum: [success, failure]} - metadata: - type: object - additionalProperties: true - created_at: - type: string - format: date-time - SSEEvent: type: object - description: | - Wire format of one SSE message body. The event name (`event:` line) is + description: + "Wire format of one SSE message body. The event name (`event:` line) is + the `kind` and the JSON below is the `data:` line. + + " properties: event: type: string @@ -4457,29 +1686,48 @@ components: - host.down outcome: type: string - enum: [success, error] - description: | - Present for action events (capsule.* except state.changed, + enum: + - success + - error + description: + "Present for action events (capsule.* except state.changed, + template.snapshot.*). Absent for host.up/down, capsule.state.changed, + and the connected sentinel. + + " resource: type: object properties: - id: {type: string} - type: {type: string} + id: + type: string + type: + type: string actor: type: object properties: - type: {type: string, enum: [user, api_key, system]} - id: {type: string} - name: {type: string} + type: + type: string + enum: + - user + - api_key + - system + id: + type: string + name: + type: string metadata: type: object - additionalProperties: {type: string} - description: | - Event-specific context. Examples: `reason` (ttl_expired, + additionalProperties: + type: string + description: "Event-specific context. Examples: `reason` (ttl_expired, + host_failure, cleanup_after_create_error, orphaned), + `host_ip`, `from`/`to` (for capsule.state.changed). + + " error: type: string description: Failure reason; only set when outcome=error. diff --git a/docs/reference.md b/docs/reference.md index 7c2d90c..4bfca2c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -166,6 +166,131 @@ Reset the inactivity timer for a capsule. - `WrennNotFoundError` - If no capsule with the given ID exists. + + +#### stats + +```python +def stats(range: str | None = None) -> CapsuleStats +``` + +Get aggregate capsule usage stats for the authenticated team. + +**Arguments**: + +- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + +**Returns**: + +- `CapsuleStats` - Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + + + +#### usage + +```python +def usage(from_: str | date | None = None, + to: str | date | None = None) -> UsageResponse +``` + +Get per-day CPU and RAM usage for the team. + +**Arguments**: + +- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. +- `to` _str | date | None_ - End date. Defaults to today. + + +**Returns**: + +- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + + + +#### metrics + +```python +def metrics(id: str, range: str | None = None) -> CapsuleMetrics +``` + +Get time-series CPU, memory, and disk metrics for a capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. +- `range` _str | None_ - One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + +**Returns**: + +- `CapsuleMetrics` - Sampled :class:`MetricPoint` series. + + +**Raises**: + +- `WrennNotFoundError` - If the capsule does not exist or has been + destroyed. + + Example:: + + m = wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + + + +## EventsResource Objects + +```python +class EventsResource() +``` + +Sync server-sent event stream of capsule/template/host lifecycle events. + + + +#### stream + +```python +def stream() -> Iterator[SSEEvent] +``` + +Stream lifecycle events for the team in real time. + +The connection is held open by the server; iterate the result to +receive :class:`SSEEvent` payloads as they arrive. Close the +iterator (or break out of the loop) to disconnect. + +**Yields**: + +- `SSEEvent` - One event per server frame. + + Example:: + + with WrennClient() as wrenn: + for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + ## AsyncCapsulesResource Objects @@ -326,6 +451,129 @@ Reset the inactivity timer for a capsule. - `WrennNotFoundError` - If no capsule with the given ID exists. + + +#### stats + +```python +async def stats(range: str | None = None) -> CapsuleStats +``` + +Get aggregate capsule usage stats for the authenticated team. + +**Arguments**: + +- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + +**Returns**: + +- `CapsuleStats` - Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = await wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + + + +#### usage + +```python +async def usage(from_: str | date | None = None, + to: str | date | None = None) -> UsageResponse +``` + +Get per-day CPU and RAM usage for the team. + +**Arguments**: + +- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. +- `to` _str | date | None_ - End date. Defaults to today. + + +**Returns**: + +- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = await wrenn.capsules.usage( + from_=today - timedelta(days=7), to=today + ) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + + + +#### metrics + +```python +async def metrics(id: str, range: str | None = None) -> CapsuleMetrics +``` + +Get time-series CPU, memory, and disk metrics for a capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. +- `range` _str | None_ - One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + +**Returns**: + +- `CapsuleMetrics` - Sampled :class:`MetricPoint` series. + + +**Raises**: + +- `WrennNotFoundError` - If the capsule does not exist or has been + destroyed. + + Example:: + + m = await wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + + + +## AsyncEventsResource Objects + +```python +class AsyncEventsResource() +``` + +Async server-sent event stream of capsule/template/host lifecycle events. + + + +#### stream + +```python +async def stream() -> AsyncIterator[SSEEvent] +``` + +Stream lifecycle events for the team in real time. + +**Yields**: + +- `SSEEvent` - One event per server frame. + + Example:: + + async with AsyncWrennClient() as wrenn: + async for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + ## SnapshotsResource Objects @@ -484,7 +732,11 @@ class WrennClient() Synchronous client for the Wrenn API. -Authenticates with an API key. +Authenticates with an API key. Exposes three resources: + +- :attr:`capsules` — capsule lifecycle, stats, usage, metrics +- :attr:`snapshots` — template snapshot management +- :attr:`events` — server-sent lifecycle event stream **Arguments**: @@ -496,6 +748,15 @@ Authenticates with an API key. is the default ``app.wrenn.dev`` host, else the ``base_url`` host. - `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import WrennClient + + with WrennClient() as wrenn: # reads WRENN_API_KEY + capsule = wrenn.capsules.create(template="minimal-ubuntu") + print(capsule.id, capsule.status) + wrenn.capsules.destroy(capsule.id) @@ -528,7 +789,8 @@ class AsyncWrennClient() Asynchronous client for the Wrenn API. -Authenticates with an API key. +Authenticates with an API key. Mirrors :class:`WrennClient` with +``await``-able methods on every resource. **Arguments**: @@ -540,6 +802,16 @@ Authenticates with an API key. is the default ``app.wrenn.dev`` host, else the ``base_url`` host. - `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import AsyncWrennClient + + async with AsyncWrennClient() as wrenn: + capsule = await wrenn.capsules.create(template="minimal-ubuntu") + async for event in wrenn.events.stream(): + if event.resource and event.resource.id == capsule.id: + break @@ -1936,18 +2208,6 @@ Send SIGKILL to the PTY process. # wrenn.models.\_generated - - -## SessionResponse Objects - -```python -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. - ## Peaks Objects @@ -1978,16 +2238,6 @@ class Encoding(StrEnum) Output encoding. "base64" when stdout/stderr contain binary data. - - -## Type2 Objects - -```python -class Type2(StrEnum) -``` - -Host type. Regular hosts are shared; BYOC hosts belong to a team. - ## Outcome Objects diff --git a/pyproject.toml b/pyproject.toml index b67c7a8..e32bd3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.2.0" +version = "0.3.0" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" @@ -23,7 +23,6 @@ classifiers = [ ] dependencies = [ "certifi>=2026.2.25", - "email-validator>=2.3.0", "httpx>=0.28.1", "httpx-ws>=0.9.0", "pydantic>=2.12.5", @@ -41,6 +40,7 @@ dev = [ "pydoc-markdown>=4.8.2", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "pyyaml>=6.0.3", "respx>=0.23.1", "ruff>=0.15.10", ] diff --git a/scripts/prune_openapi.py b/scripts/prune_openapi.py new file mode 100644 index 0000000..ac82997 --- /dev/null +++ b/scripts/prune_openapi.py @@ -0,0 +1,179 @@ +"""Prune the upstream Wrenn OpenAPI spec down to the SDK's surface. + +The upstream spec at ``wrennhq/wrenn/internal/api/openapi.yaml`` covers +the whole control plane: account/team/host/channel/admin routes that +require an interactive ``wrenn_sid`` cookie. The Python SDK only ever +authenticates with an API key, so those routes are dead surface area +here. This script keeps only paths that list ``apiKeyAuth`` (and the +schemas they transitively reach) and rewrites the spec in place. + +Run via ``make generate`` (curl ➜ prune ➜ datamodel-codegen) or +``uv run python scripts/prune_openapi.py [path]`` directly. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +KEEP_PATHS: set[str] = { + "/v1/capsules", + "/v1/capsules/stats", + "/v1/capsules/usage", + "/v1/capsules/{id}", + "/v1/capsules/{id}/exec", + "/v1/capsules/{id}/exec/stream", + "/v1/capsules/{id}/processes", + "/v1/capsules/{id}/processes/{selector}", + "/v1/capsules/{id}/processes/{selector}/stream", + "/v1/capsules/{id}/ping", + "/v1/capsules/{id}/metrics", + "/v1/capsules/{id}/pause", + "/v1/capsules/{id}/resume", + "/v1/capsules/{id}/files/write", + "/v1/capsules/{id}/files/read", + "/v1/capsules/{id}/files/list", + "/v1/capsules/{id}/files/mkdir", + "/v1/capsules/{id}/files/remove", + "/v1/capsules/{id}/files/stream/write", + "/v1/capsules/{id}/files/stream/read", + "/v1/capsules/{id}/pty", + "/v1/snapshots", + "/v1/snapshots/{name}", + "/v1/events/stream", +} + +KEEP_SECURITY_SCHEMES: set[str] = {"apiKeyAuth"} + +REF_RE = re.compile(r"#/components/(schemas|responses)/([A-Za-z0-9_]+)") + + +def _walk_refs(node: object, schemas: dict, responses: dict, seen: set[str]) -> None: + """Depth-first walk collecting ``schemas:Name`` / ``responses:Name`` refs.""" + if isinstance(node, dict): + for k, v in node.items(): + if k == "$ref" and isinstance(v, str): + m = REF_RE.search(v) + if m: + kind, name = m.group(1), m.group(2) + key = f"{kind}:{name}" + if key in seen: + continue + seen.add(key) + target = (schemas if kind == "schemas" else responses).get(name) + if target is not None: + _walk_refs(target, schemas, responses, seen) + else: + _walk_refs(v, schemas, responses, seen) + elif isinstance(node, list): + for item in node: + _walk_refs(item, schemas, responses, seen) + + +def _filter_security(operation: dict) -> None: + sec = operation.get("security") + if not isinstance(sec, list): + return + kept = [ + entry + for entry in sec + if isinstance(entry, dict) + and any(name in KEEP_SECURITY_SCHEMES for name in entry) + ] + if kept: + operation["security"] = kept + else: + operation.pop("security", None) + + +def prune(spec: dict) -> dict: + paths = spec.get("paths", {}) + components = spec.get("components", {}) + schemas = components.get("schemas", {}) or {} + responses = components.get("responses", {}) or {} + + missing = KEEP_PATHS - set(paths) + if missing: + raise SystemExit( + "prune_openapi: upstream spec is missing expected paths: " + + ", ".join(sorted(missing)) + ) + + kept_paths: dict[str, dict] = {} + for path, item in paths.items(): + if path not in KEEP_PATHS: + continue + if not isinstance(item, dict): + kept_paths[path] = item + continue + for method, op in list(item.items()): + if isinstance(op, dict): + _filter_security(op) + if "security" in op and not op["security"]: + del item[method] + kept_paths[path] = item + + seen: set[str] = set() + _walk_refs(kept_paths, schemas, responses, seen) + + # Walk again to close over inter-schema refs we just pulled in. + while True: + before = len(seen) + for key in list(seen): + kind, name = key.split(":", 1) + target = (schemas if kind == "schemas" else responses).get(name) + if target is not None: + _walk_refs(target, schemas, responses, seen) + if len(seen) == before: + break + + kept_schemas = {n: schemas[n] for n in schemas if f"schemas:{n}" in seen} + kept_responses = {n: responses[n] for n in responses if f"responses:{n}" in seen} + + sec_schemes = components.get("securitySchemes", {}) or {} + kept_sec_schemes = { + n: sec_schemes[n] for n in sec_schemes if n in KEEP_SECURITY_SCHEMES + } + + new_components: dict = {} + if kept_responses: + new_components["responses"] = kept_responses + if kept_sec_schemes: + new_components["securitySchemes"] = kept_sec_schemes + if kept_schemas: + new_components["schemas"] = kept_schemas + + spec["paths"] = kept_paths + spec["components"] = new_components + + top_security = spec.get("security") + if isinstance(top_security, list): + spec["security"] = [ + e + for e in top_security + if isinstance(e, dict) and any(name in KEEP_SECURITY_SCHEMES for name in e) + ] + + return spec + + +def main(argv: list[str]) -> int: + target = Path(argv[1]) if len(argv) > 1 else Path("api/openapi.yaml") + spec = yaml.safe_load(target.read_text()) + pruned = prune(spec) + target.write_text( + yaml.safe_dump(pruned, sort_keys=False, width=120, allow_unicode=True) + ) + print( + f"prune_openapi: kept {len(pruned['paths'])} paths, " + f"{len(pruned['components'].get('schemas', {}))} schemas in {target}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index 0c4cb64..1541454 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -37,7 +37,7 @@ from wrenn.exceptions import ( from wrenn.models import FileEntry from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession -__version__ = "0.1.4" +__version__ = "0.3.0" __all__ = [ "__version__", diff --git a/src/wrenn/client.py b/src/wrenn/client.py index 58ef09b..3723aaa 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import os import time +from collections.abc import AsyncIterator, Iterator +from datetime import date import httpx @@ -13,10 +15,14 @@ from wrenn._config import ( ENV_BASE_URL, ENV_PROXY_DOMAIN, ) -from wrenn.exceptions import handle_response +from wrenn.exceptions import _raise_for_status, handle_response from wrenn.models import ( + CapsuleMetrics, + CapsuleStats, + SSEEvent, Template, + UsageResponse, ) from wrenn.models import ( Capsule as CapsuleModel, @@ -151,6 +157,46 @@ def _snapshot_list_params(type: str | None) -> dict: return params +def _date_param(value: str | date | None) -> str | None: + if value is None: + return None + if isinstance(value, date): + return value.isoformat() + return value + + +def _usage_params(from_: str | date | None, to: str | date | None) -> dict: + params: dict = {} + if (v := _date_param(from_)) is not None: + params["from"] = v + if (v := _date_param(to)) is not None: + params["to"] = v + return params + + +def _range_params(range: str | None) -> dict: + return {"range": range} if range is not None else {} + + +def _iter_sse_events(lines: Iterator[str]) -> Iterator[SSEEvent]: + """Parse SSE ``data:`` frames into :class:`SSEEvent` objects. + + Ignores ``event:`` names and ``:keepalive`` comments — the payload's + own ``event`` field carries the kind. + """ + data_lines: list[str] = [] + for raw in lines: + if raw == "": + if data_lines: + yield SSEEvent.model_validate_json("\n".join(data_lines)) + data_lines = [] + continue + if raw.startswith(":"): + continue + if raw.startswith("data:"): + data_lines.append(raw[5:].lstrip()) + + class CapsulesResource: """Sync capsule control-plane operations.""" @@ -260,6 +306,106 @@ class CapsulesResource: resp = self._http.post(f"/v1/capsules/{id}/ping") handle_response(resp) + def stats(self, range: str | None = None) -> CapsuleStats: + """Get aggregate capsule usage stats for the authenticated team. + + Args: + range (str | None): Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + Returns: + CapsuleStats: Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + """ + resp = self._http.get("/v1/capsules/stats", params=_range_params(range)) + return CapsuleStats.model_validate(handle_response(resp)) + + def usage( + self, + from_: str | date | None = None, + to: str | date | None = None, + ) -> UsageResponse: + """Get per-day CPU and RAM usage for the team. + + Args: + from_ (str | date | None): Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. + to (str | date | None): End date. Defaults to today. + + Returns: + UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + """ + resp = self._http.get("/v1/capsules/usage", params=_usage_params(from_, to)) + return UsageResponse.model_validate(handle_response(resp)) + + def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics: + """Get time-series CPU, memory, and disk metrics for a capsule. + + Args: + id (str): Capsule ID. + range (str | None): One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + Returns: + CapsuleMetrics: Sampled :class:`MetricPoint` series. + + Raises: + WrennNotFoundError: If the capsule does not exist or has been + destroyed. + + Example:: + + m = wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + """ + resp = self._http.get(f"/v1/capsules/{id}/metrics", params=_range_params(range)) + return CapsuleMetrics.model_validate(handle_response(resp)) + + +class EventsResource: + """Sync server-sent event stream of capsule/template/host lifecycle events.""" + + def __init__(self, http: httpx.Client) -> None: + self._http = http + + def stream(self) -> Iterator[SSEEvent]: + """Stream lifecycle events for the team in real time. + + The connection is held open by the server; iterate the result to + receive :class:`SSEEvent` payloads as they arrive. Close the + iterator (or break out of the loop) to disconnect. + + Yields: + SSEEvent: One event per server frame. + + Example:: + + with WrennClient() as wrenn: + for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + """ + with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp: + if resp.status_code >= 400: + resp.read() + _raise_for_status(resp) + yield from _iter_sse_events(resp.iter_lines()) + class AsyncCapsulesResource: """Async capsule control-plane operations.""" @@ -370,6 +516,118 @@ class AsyncCapsulesResource: resp = await self._http.post(f"/v1/capsules/{id}/ping") handle_response(resp) + async def stats(self, range: str | None = None) -> CapsuleStats: + """Get aggregate capsule usage stats for the authenticated team. + + Args: + range (str | None): Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + Returns: + CapsuleStats: Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = await wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + """ + resp = await self._http.get("/v1/capsules/stats", params=_range_params(range)) + return CapsuleStats.model_validate(handle_response(resp)) + + async def usage( + self, + from_: str | date | None = None, + to: str | date | None = None, + ) -> UsageResponse: + """Get per-day CPU and RAM usage for the team. + + Args: + from_ (str | date | None): Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. + to (str | date | None): End date. Defaults to today. + + Returns: + UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = await wrenn.capsules.usage( + from_=today - timedelta(days=7), to=today + ) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + """ + resp = await self._http.get( + "/v1/capsules/usage", params=_usage_params(from_, to) + ) + return UsageResponse.model_validate(handle_response(resp)) + + async def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics: + """Get time-series CPU, memory, and disk metrics for a capsule. + + Args: + id (str): Capsule ID. + range (str | None): One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + Returns: + CapsuleMetrics: Sampled :class:`MetricPoint` series. + + Raises: + WrennNotFoundError: If the capsule does not exist or has been + destroyed. + + Example:: + + m = await wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + """ + resp = await self._http.get( + f"/v1/capsules/{id}/metrics", params=_range_params(range) + ) + return CapsuleMetrics.model_validate(handle_response(resp)) + + +class AsyncEventsResource: + """Async server-sent event stream of capsule/template/host lifecycle events.""" + + def __init__(self, http: httpx.AsyncClient) -> None: + self._http = http + + async def stream(self) -> AsyncIterator[SSEEvent]: + """Stream lifecycle events for the team in real time. + + Yields: + SSEEvent: One event per server frame. + + Example:: + + async with AsyncWrennClient() as wrenn: + async for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + """ + async with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp: + if resp.status_code >= 400: + await resp.aread() + _raise_for_status(resp) + data_lines: list[str] = [] + async for raw in resp.aiter_lines(): + if raw == "": + if data_lines: + yield SSEEvent.model_validate_json("\n".join(data_lines)) + data_lines = [] + continue + if raw.startswith(":"): + continue + if raw.startswith("data:"): + data_lines.append(raw[5:].lstrip()) + class SnapshotsResource: """Sync snapshot operations.""" @@ -486,7 +744,11 @@ class AsyncSnapshotsResource: class WrennClient: """Synchronous client for the Wrenn API. - Authenticates with an API key. + Authenticates with an API key. Exposes three resources: + + - :attr:`capsules` — capsule lifecycle, stats, usage, metrics + - :attr:`snapshots` — template snapshot management + - :attr:`events` — server-sent lifecycle event stream Args: api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. @@ -497,6 +759,15 @@ class WrennClient: is the default ``app.wrenn.dev`` host, else the ``base_url`` host. timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import WrennClient + + with WrennClient() as wrenn: # reads WRENN_API_KEY + capsule = wrenn.capsules.create(template="minimal-ubuntu") + print(capsule.id, capsule.status) + wrenn.capsules.destroy(capsule.id) """ def __init__( @@ -517,6 +788,7 @@ class WrennClient: self.capsules = CapsulesResource(self._http) self.snapshots = SnapshotsResource(self._http) + self.events = EventsResource(self._http) @property def http(self) -> httpx.Client: @@ -542,7 +814,8 @@ class WrennClient: class AsyncWrennClient: """Asynchronous client for the Wrenn API. - Authenticates with an API key. + Authenticates with an API key. Mirrors :class:`WrennClient` with + ``await``-able methods on every resource. Args: api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. @@ -553,6 +826,16 @@ class AsyncWrennClient: is the default ``app.wrenn.dev`` host, else the ``base_url`` host. timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import AsyncWrennClient + + async with AsyncWrennClient() as wrenn: + capsule = await wrenn.capsules.create(template="minimal-ubuntu") + async for event in wrenn.events.stream(): + if event.resource and event.resource.id == capsule.id: + break """ def __init__( @@ -573,6 +856,7 @@ class AsyncWrennClient: self.capsules = AsyncCapsulesResource(self._http) self.snapshots = AsyncSnapshotsResource(self._http) + self.events = AsyncEventsResource(self._http) @property def http(self) -> httpx.AsyncClient: diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index 49c99b1..d7e8351 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -1,17 +1,31 @@ from wrenn.models._generated import ( + Actor, Capsule, + CapsuleMetrics, + CapsuleStats, FileEntry, ListDirResponse, MakeDirResponse, + MetricPoint, + Resource, + SSEEvent, Status, Template, + UsageResponse, ) __all__ = [ + "Actor", "Capsule", + "CapsuleMetrics", + "CapsuleStats", "FileEntry", "ListDirResponse", "MakeDirResponse", + "MetricPoint", + "Resource", + "SSEEvent", "Status", "Template", + "UsageResponse", ] diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 3104a87..b8148f2 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,11 +1,91 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-05-23T11:20:02+00:00 +# timestamp: 2026-06-22T22:24:45+00:00 from __future__ import annotations -from pydantic import AwareDatetime, BaseModel, Field -from typing import Annotated + +from datetime import date as date_aliased from enum import StrEnum +from typing import Annotated + +from pydantic import AwareDatetime, BaseModel, Field + + +class CreateCapsuleRequest(BaseModel): + template: str | None = "minimal-ubuntu" + vcpus: int | None = 1 + memory_mb: int | None = 512 + 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. Positive values below 60 are silently clamped to 60 (the agent's startup envelope).\n", + ge=0, + ), + ] = 0 + metadata: Annotated[ + dict[str, str] | None, + Field( + description="Optional user-supplied key/value labels attached to the capsule. Max 20 keys; keys match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}; values are at most 64 characters. Reserved system keys (kernel_version, vmm_version, agent_version, envd_version) are rejected. Metadata is set at create-time only.\n" + ), + ] = None + + +class Point(BaseModel): + date: date_aliased | None = None + cpu_minutes: float | None = None + ram_mb_minutes: float | None = None + + +class UsageResponse(BaseModel): + from_: Annotated[date_aliased | None, Field(alias="from")] = None + to: date_aliased | None = None + points: list[Point] | None = None + + +class Range(StrEnum): + field_5m = "5m" + field_1h = "1h" + field_6h = "6h" + field_24h = "24h" + field_30d = "30d" + + +class Current(BaseModel): + running_count: int | None = None + vcpus_reserved: int | None = None + memory_mb_reserved: int | None = None + + +class Peaks(BaseModel): + """ + Maximum values over the last 30 days. + """ + + running_count: int | None = None + vcpus: int | None = None + memory_mb: int | None = None + + +class Series(BaseModel): + """ + Parallel arrays for chart rendering. + """ + + labels: list[AwareDatetime] | None = None + running: list[int] | None = None + vcpus: list[int] | None = None + memory_mb: list[int] | None = None + + +class CapsuleStats(BaseModel): + range: Range | None = None + current: Current | None = None + peaks: Annotated[ + Peaks | None, Field(description="Maximum values over the last 30 days.") + ] = None + series: Annotated[ + Series | None, Field(description="Parallel arrays for chart rendering.") + ] = None class Status(StrEnum): @@ -37,7 +117,7 @@ class Capsule(BaseModel): 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" + description="User-supplied key/value labels attached at create-time. Once the\ncapsule is running this also carries agent-side system version info\n(kernel_version, vmm_version, agent_version, envd_version); those\nreserved keys are owned by the system and cannot be set by users.\n" ), ] = None disk_size_mb: Annotated[ @@ -51,6 +131,16 @@ class Capsule(BaseModel): ] = None +class CreateSnapshotRequest(BaseModel): + sandbox_id: Annotated[ + str, Field(description="ID of the running capsule to snapshot.") + ] + name: Annotated[ + str | None, + Field(description="Name for the snapshot template. Auto-generated if omitted."), + ] = None + + class Type(StrEnum): base = "base" snapshot = "snapshot" @@ -78,7 +168,96 @@ class Template(BaseModel): metadata: dict[str, str] | None = None -class Type2(StrEnum): +class ExecRequest(BaseModel): + cmd: str + args: list[str] | None = None + timeout_sec: Annotated[ + int | None, + Field(description="Timeout in seconds (foreground exec only, default 30)"), + ] = 30 + background: Annotated[ + bool | None, + Field( + description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)" + ), + ] = False + tag: Annotated[ + str | None, + Field( + description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true." + ), + ] = None + envs: Annotated[ + dict[str, str] | None, + Field( + description="Environment variables for the process (applies to both foreground and background exec)" + ), + ] = None + cwd: Annotated[ + str | None, + Field( + description="Working directory for the process (applies to both foreground and background exec)" + ), + ] = None + + +class BackgroundExecResponse(BaseModel): + sandbox_id: str | None = None + cmd: str | None = None + pid: int | None = None + tag: str | None = None + + +class ProcessEntry(BaseModel): + pid: int | None = None + tag: str | None = None + cmd: str | None = None + args: list[str] | None = None + + +class ProcessListResponse(BaseModel): + processes: list[ProcessEntry] | None = None + + +class Encoding(StrEnum): + """ + Output encoding. "base64" when stdout/stderr contain binary data. + """ + + utf_8 = "utf-8" + base64 = "base64" + + +class ExecResponse(BaseModel): + sandbox_id: str | None = None + cmd: str | None = None + stdout: str | None = None + stderr: str | None = None + exit_code: int | None = None + duration_ms: int | None = None + encoding: Annotated[ + Encoding | None, + Field( + description='Output encoding. "base64" when stdout/stderr contain binary data.' + ), + ] = None + + +class ReadFileRequest(BaseModel): + path: Annotated[str, Field(description="Absolute file path inside the capsule")] + + +class ListDirRequest(BaseModel): + path: Annotated[str, Field(description="Directory path inside the capsule")] + depth: Annotated[ + int | None, + Field( + description="Recursion depth (0 = non-recursive, 1 = immediate children)" + ), + ] = 1 + + +class Type1(StrEnum): file = "file" directory = "directory" symlink = "symlink" @@ -87,7 +266,7 @@ class Type2(StrEnum): class FileEntry(BaseModel): name: str | None = None path: str | None = None - type: Type2 | None = None + type: Type1 | None = None size: int | None = None mode: int | None = None permissions: Annotated[ @@ -105,5 +284,127 @@ class MakeDirResponse(BaseModel): entry: FileEntry | None = None +class RemoveRequest(BaseModel): + path: Annotated[str, Field(description="Path to remove inside the capsule")] + + +class Range1(StrEnum): + field_5m = "5m" + field_10m = "10m" + field_1h = "1h" + field_2h = "2h" + field_6h = "6h" + field_12h = "12h" + field_24h = "24h" + + +class MetricPoint(BaseModel): + timestamp_unix: int | None = None + cpu_pct: Annotated[ + float | None, + Field( + description="CPU utilization percentage (0-100), normalized to vCPU count" + ), + ] = None + mem_bytes: Annotated[ + int | None, + 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") + ] = None + + +class Error1(BaseModel): + code: str | None = None + message: str | None = None + + +class Error(BaseModel): + error: Error1 | None = None + + +class Event(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 Type2(StrEnum): + user = "user" + api_key = "api_key" + system = "system" + + +class Actor(BaseModel): + type: Type2 | 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: Event | 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 + + +class CapsuleMetrics(BaseModel): + sandbox_id: str | None = None + range: Range1 | None = None + points: list[MetricPoint] | None = None diff --git a/src/wrenn/pty.py b/src/wrenn/pty.py index 0b7ff77..bd33e4f 100644 --- a/src/wrenn/pty.py +++ b/src/wrenn/pty.py @@ -1,6 +1,5 @@ from __future__ import annotations -import base64 import json from collections.abc import AsyncIterator, Iterator from enum import StrEnum @@ -8,6 +7,7 @@ from typing import Any import httpx_ws from pydantic import BaseModel +from wsproto.events import BytesMessage, TextMessage # A clean (``WebSocketDisconnect``) or abrupt (``WebSocketNetworkError``) close # both mean the PTY stream has ended; iteration must stop on either. @@ -31,7 +31,8 @@ class PtyEvent(BaseModel): fatal: bool | None = None -def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent: +def _parse_control_event(raw: dict[str, Any]) -> PtyEvent: + """Parse a JSON control frame from the server.""" msg_type = raw.get("type", "") if msg_type == "started": return PtyEvent( @@ -39,10 +40,6 @@ def _parse_pty_event(raw: dict[str, Any]) -> PtyEvent: pid=raw.get("pid"), tag=raw.get("tag"), ) - if msg_type == "output": - raw_data = raw.get("data", "") - decoded = base64.b64decode(raw_data) if raw_data else b"" - return PtyEvent(type=PtyEventType.output, data=decoded) if msg_type == "exit": return PtyEvent(type=PtyEventType.exit, exit_code=raw.get("exit_code", -1)) if msg_type == "error": @@ -133,10 +130,10 @@ class PtySession: """Send raw bytes to the PTY stdin. Args: - data: Raw bytes to send. Base64-encoded internally. + data: Raw bytes to send. Delivered as a binary WebSocket frame + verbatim — no JSON wrapping, no base64. """ - encoded = base64.b64encode(data).decode("ascii") - self._ws.send_text(json.dumps({"type": "input", "data": encoded})) + self._ws.send_bytes(data) def resize(self, cols: int, rows: int) -> None: """Resize the PTY terminal. @@ -160,27 +157,34 @@ class PtySession: return self def __next__(self) -> PtyEvent: - if self._done: - raise StopIteration - try: - raw = self._ws.receive_text() - except _WS_CLOSED: - raise StopIteration - event = _parse_pty_event(json.loads(raw)) - if event.type == PtyEventType.started: - if event.tag is not None: - self._tag = event.tag - if event.pid is not None: - self._pid = event.pid - if event.type == PtyEventType.ping: - self._send_pong() - if event.type == PtyEventType.exit: - self._done = True + while True: + if self._done: + raise StopIteration + try: + msg = self._ws.receive() + except _WS_CLOSED: + raise StopIteration + if isinstance(msg, BytesMessage): + return PtyEvent(type=PtyEventType.output, data=msg.data) + if isinstance(msg, TextMessage): + event = _parse_control_event(json.loads(msg.data)) + else: + # Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws). + continue + if event.type == PtyEventType.started: + if event.tag is not None: + self._tag = event.tag + if event.pid is not None: + self._pid = event.pid + if event.type == PtyEventType.ping: + self._send_pong() + if event.type == PtyEventType.exit: + self._done = True + return event + if event.type == PtyEventType.error and event.fatal: + self._done = True + return event return event - if event.type == PtyEventType.error and event.fatal: - self._done = True - return event - return event def __enter__(self) -> PtySession: return self @@ -269,10 +273,10 @@ class AsyncPtySession: """Send raw bytes to the PTY stdin. Args: - data: Raw bytes to send. Base64-encoded internally. + data: Raw bytes to send. Delivered as a binary WebSocket frame + verbatim — no JSON wrapping, no base64. """ - encoded = base64.b64encode(data).decode("ascii") - await self._ws.send_text(json.dumps({"type": "input", "data": encoded})) + await self._ws.send_bytes(data) async def resize(self, cols: int, rows: int) -> None: """Resize the PTY terminal. @@ -298,27 +302,34 @@ class AsyncPtySession: return self async def __anext__(self) -> PtyEvent: - if self._done: - raise StopAsyncIteration - try: - raw = await self._ws.receive_text() - except _WS_CLOSED: - raise StopAsyncIteration - event = _parse_pty_event(json.loads(raw)) - if event.type == PtyEventType.started: - if event.tag is not None: - self._tag = event.tag - if event.pid is not None: - self._pid = event.pid - if event.type == PtyEventType.ping: - await self._send_pong() - if event.type == PtyEventType.exit: - self._done = True + while True: + if self._done: + raise StopAsyncIteration + try: + msg = await self._ws.receive() + except _WS_CLOSED: + raise StopAsyncIteration + if isinstance(msg, BytesMessage): + return PtyEvent(type=PtyEventType.output, data=msg.data) + if isinstance(msg, TextMessage): + event = _parse_control_event(json.loads(msg.data)) + else: + # Ignore other wsproto events (Ping/Pong/etc handled by httpx_ws). + continue + if event.type == PtyEventType.started: + if event.tag is not None: + self._tag = event.tag + if event.pid is not None: + self._pid = event.pid + if event.type == PtyEventType.ping: + await self._send_pong() + if event.type == PtyEventType.exit: + self._done = True + return event + if event.type == PtyEventType.error and event.fatal: + self._done = True + return event return event - if event.type == PtyEventType.error and event.fatal: - self._done = True - return event - return event async def __aenter__(self) -> AsyncPtySession: return self diff --git a/tests/test_client.py b/tests/test_client.py index 3bc31ed..325ae28 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -13,9 +13,16 @@ from wrenn.exceptions import ( WrennValidationError, ) from wrenn.models import ( + Actor, Capsule, + CapsuleMetrics, + CapsuleStats, + MetricPoint, + Resource, + SSEEvent, Status, Template, + UsageResponse, ) BASE = "https://app.wrenn.dev/api" @@ -103,6 +110,212 @@ class TestCapsules: client.capsules.ping("sb-1") assert route.called + @respx.mock + def test_stats(self, client): + route = respx.get(f"{BASE}/v1/capsules/stats").respond( + 200, + json={ + "range": "6h", + "current": {"running_count": 2, "vcpus_reserved": 4}, + "peaks": {"running_count": 9}, + "series": {"running": [1, 2, 2]}, + }, + ) + stats = client.capsules.stats(range="6h") + assert "range=6h" in str(route.calls[0].request.url) + assert stats.current and stats.current.running_count == 2 + assert stats.peaks and stats.peaks.running_count == 9 + + @respx.mock + def test_usage_passes_date_params(self, client): + import datetime as _dt + + route = respx.get(f"{BASE}/v1/capsules/usage").respond( + 200, json={"from": "2026-06-01", "to": "2026-06-22", "points": []} + ) + client.capsules.usage(from_=_dt.date(2026, 6, 1), to="2026-06-22") + url = str(route.calls[0].request.url) + assert "from=2026-06-01" in url + assert "to=2026-06-22" in url + + @respx.mock + def test_metrics(self, client): + respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond( + 200, + json={ + "sandbox_id": "sb-1", + "range": "10m", + "points": [{"timestamp_unix": 1, "cpu_pct": 12.5, "mem_bytes": 4096}], + }, + ) + m = client.capsules.metrics("sb-1") + assert m.sandbox_id == "sb-1" + assert m.points and m.points[0].cpu_pct == 12.5 + assert isinstance(m, CapsuleMetrics) + assert isinstance(m.points[0], MetricPoint) + + @respx.mock + def test_stats_default_omits_range(self, client): + route = respx.get(f"{BASE}/v1/capsules/stats").respond( + 200, + json={"range": "1h", "current": {}, "peaks": {}, "series": {}}, + ) + stats = client.capsules.stats() + assert "range=" not in str(route.calls[0].request.url) + assert isinstance(stats, CapsuleStats) + + @respx.mock + def test_usage_default_omits_params(self, client): + route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []}) + usage = client.capsules.usage() + url = str(route.calls[0].request.url) + assert "from=" not in url + assert "to=" not in url + assert isinstance(usage, UsageResponse) + + @respx.mock + def test_metrics_default_omits_range(self, client): + route = respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond( + 200, json={"sandbox_id": "sb-1", "points": []} + ) + client.capsules.metrics("sb-1") + assert "range=" not in str(route.calls[0].request.url) + + @respx.mock + def test_metrics_not_found(self, client): + respx.get(f"{BASE}/v1/capsules/nope/metrics").respond( + 404, + json={"error": {"code": "not_found", "message": "capsule not found"}}, + ) + with pytest.raises(WrennNotFoundError): + client.capsules.metrics("nope") + + @respx.mock + def test_stats_auth_error(self, client): + respx.get(f"{BASE}/v1/capsules/stats").respond( + 401, + json={"error": {"code": "unauthorized", "message": "bad key"}}, + ) + with pytest.raises(WrennAuthenticationError): + client.capsules.stats() + + @respx.mock + def test_usage_string_dates(self, client): + route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []}) + client.capsules.usage(from_="2026-01-01", to="2026-01-31") + url = str(route.calls[0].request.url) + assert "from=2026-01-01" in url + assert "to=2026-01-31" in url + + @respx.mock + def test_usage_partial_dates(self, client): + route = respx.get(f"{BASE}/v1/capsules/usage").respond(200, json={"points": []}) + client.capsules.usage(from_="2026-01-01") + url = str(route.calls[0].request.url) + assert "from=2026-01-01" in url + assert "to=" not in url + + +class TestEvents: + @respx.mock + def test_stream_parses_sse_frames(self, client): + body = ( + ": keepalive\n" + "event: capsule.create\n" + 'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + "event: capsule.destroy\n" + 'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + ) + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + events = list(client.events.stream()) + assert [e.event.value for e in events] == [ + "capsule.create", + "capsule.destroy", + ] + assert events[0].resource and events[0].resource.id == "sb-1" + + @respx.mock + def test_stream_multiline_data(self, client): + body = ( + "event: capsule.create\n" + 'data: {"event":"capsule.create",\n' + 'data: "resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + ) + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + events = list(client.events.stream()) + assert len(events) == 1 + assert events[0].resource.id == "sb-1" + + @respx.mock + def test_stream_skips_keepalive_only(self, client): + body = ": keepalive\n\n: keepalive\n\n" + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + assert list(client.events.stream()) == [] + + @respx.mock + def test_stream_ignores_incomplete_trailing_frame(self, client): + body = ( + 'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + 'data: {"event":"capsule.destroy"' # no closing brace, no blank line + ) + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + events = list(client.events.stream()) + assert len(events) == 1 + assert events[0].event.value == "capsule.create" + + @respx.mock + def test_stream_full_payload_round_trip(self, client): + body = ( + 'data: {"event":"capsule.create",' + '"outcome":"success",' + '"resource":{"id":"sb-1","type":"capsule"},' + '"actor":{"type":"api_key","id":"key-1","name":"ci"},' + '"metadata":{"reason":"manual"}}\n' + "\n" + ) + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + events = list(client.events.stream()) + assert len(events) == 1 + ev = events[0] + assert isinstance(ev, SSEEvent) + assert isinstance(ev.resource, Resource) + assert isinstance(ev.actor, Actor) + assert ev.actor.name == "ci" + assert ev.metadata == {"reason": "manual"} + + @respx.mock + def test_stream_raises_on_4xx(self, client): + respx.get(f"{BASE}/v1/events/stream").respond( + 401, + json={"error": {"code": "unauthorized", "message": "bad key"}}, + ) + with pytest.raises(WrennAuthenticationError): + list(client.events.stream()) + class TestSnapshots: @respx.mock @@ -262,6 +475,156 @@ class TestAsyncClient: with pytest.raises(WrennNotFoundError): await async_client.capsules.get("nope") + @pytest.mark.asyncio + @respx.mock + async def test_async_stats(self, async_client): + async with async_client: + route = respx.get(f"{BASE}/v1/capsules/stats").respond( + 200, + json={ + "range": "24h", + "current": {"running_count": 1}, + "peaks": {"running_count": 5}, + "series": {"running": [1]}, + }, + ) + stats = await async_client.capsules.stats(range="24h") + assert "range=24h" in str(route.calls[0].request.url) + assert isinstance(stats, CapsuleStats) + assert stats.current.running_count == 1 + + @pytest.mark.asyncio + @respx.mock + async def test_async_usage(self, async_client): + import datetime as _dt + + async with async_client: + route = respx.get(f"{BASE}/v1/capsules/usage").respond( + 200, + json={ + "from": "2026-06-01", + "to": "2026-06-22", + "points": [ + { + "date": "2026-06-01", + "cpu_minutes": 1.5, + "ram_mb_minutes": 200.0, + } + ], + }, + ) + usage = await async_client.capsules.usage( + from_=_dt.date(2026, 6, 1), to="2026-06-22" + ) + url = str(route.calls[0].request.url) + assert "from=2026-06-01" in url + assert "to=2026-06-22" in url + assert isinstance(usage, UsageResponse) + assert usage.points and usage.points[0].cpu_minutes == 1.5 + + @pytest.mark.asyncio + @respx.mock + async def test_async_metrics(self, async_client): + async with async_client: + respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond( + 200, + json={ + "sandbox_id": "sb-1", + "range": "2h", + "points": [ + {"timestamp_unix": 1, "cpu_pct": 33.0, "mem_bytes": 1024} + ], + }, + ) + m = await async_client.capsules.metrics("sb-1", range="2h") + assert isinstance(m, CapsuleMetrics) + assert m.points[0].cpu_pct == 33.0 + + @pytest.mark.asyncio + @respx.mock + async def test_async_metrics_not_found(self, async_client): + async with async_client: + respx.get(f"{BASE}/v1/capsules/nope/metrics").respond( + 404, + json={"error": {"code": "not_found", "message": "not found"}}, + ) + with pytest.raises(WrennNotFoundError): + await async_client.capsules.metrics("nope") + + @pytest.mark.asyncio + @respx.mock + async def test_async_events_stream(self, async_client): + async with async_client: + body = ( + ": keepalive\n" + 'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + 'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + ) + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + events = [ev async for ev in async_client.events.stream()] + assert [e.event.value for e in events] == [ + "capsule.create", + "capsule.destroy", + ] + + @pytest.mark.asyncio + @respx.mock + async def test_async_events_raises_on_4xx(self, async_client): + async with async_client: + respx.get(f"{BASE}/v1/events/stream").respond( + 401, + json={"error": {"code": "unauthorized", "message": "bad key"}}, + ) + with pytest.raises(WrennAuthenticationError): + async for _ in async_client.events.stream(): + pass + + @pytest.mark.asyncio + @respx.mock + async def test_async_events_multiline_data(self, async_client): + async with async_client: + body = ( + 'data: {"event":"capsule.create",\n' + 'data: "resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + ) + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + events = [ev async for ev in async_client.events.stream()] + assert len(events) == 1 + assert events[0].resource.id == "sb-1" + + +class TestPackageSurface: + def test_version(self): + import wrenn + + assert wrenn.__version__ == "0.3.0" + + def test_new_models_exported(self): + import wrenn.models as m + + for name in ( + "Actor", + "CapsuleMetrics", + "CapsuleStats", + "MetricPoint", + "Resource", + "SSEEvent", + "UsageResponse", + ): + assert hasattr(m, name), name + assert name in m.__all__ + class TestClientResolution: def test_default_base_url_strips_app_subdomain(self): diff --git a/tests/test_code_runner_e2e.py b/tests/test_code_runner_e2e.py index 12cae7c..401aac7 100644 --- a/tests/test_code_runner_e2e.py +++ b/tests/test_code_runner_e2e.py @@ -430,12 +430,10 @@ class TestCodeRunnerMimeTypes: def test_requests_status_code(self): ex = self._run( "import requests\n" - "r = requests.get('https://httpbin.org/status/204', timeout=10)\n" + "r = requests.get('http://httpbingo.org/status/418', timeout=10)\n" "r.status_code\n" ) - if ex.error is not None: - pytest.skip(f"network unavailable: {ex.error.name}") - assert ex.text == "204" + assert ex.text == "418" class TestCodeRunnerIsolation: diff --git a/tests/test_code_runner_unit.py b/tests/test_code_runner_unit.py index d181749..c5be0da 100644 --- a/tests/test_code_runner_unit.py +++ b/tests/test_code_runner_unit.py @@ -388,6 +388,214 @@ class TestJupyterRequest: assert a["header"]["msg_id"] != b["header"]["msg_id"] +# ───────────────────────── _protocol direct helpers ───────────────────────── + + +class TestPickKernelId: + def test_returns_first_matching_kernel(self): + from wrenn.code_runner._protocol import pick_kernel_id + + kernels = [ + {"id": "k-1", "name": "python3"}, + {"id": "k-2", "name": "wrenn"}, + {"id": "k-3", "name": "wrenn"}, + ] + assert pick_kernel_id(kernels, "wrenn") == "k-2" + + def test_returns_none_when_no_match(self): + from wrenn.code_runner._protocol import pick_kernel_id + + kernels = [{"id": "k-1", "name": "python3"}] + assert pick_kernel_id(kernels, "wrenn") is None + + def test_returns_none_on_empty_list(self): + from wrenn.code_runner._protocol import pick_kernel_id + + assert pick_kernel_id([], "wrenn") is None + + def test_ignores_entries_missing_name(self): + from wrenn.code_runner._protocol import pick_kernel_id + + kernels = [{"id": "k-1"}, {"id": "k-2", "name": "wrenn"}] + assert pick_kernel_id(kernels, "wrenn") == "k-2" + + +class TestValidateLanguage: + def test_python_accepted(self): + from wrenn.code_runner._protocol import validate_language + + validate_language("python") # no raise + + def test_other_language_raises(self): + from wrenn.code_runner._protocol import validate_language + + with pytest.raises(ValueError, match="not supported"): + validate_language("r") + + +class TestBuildWsUrl: + def test_uses_proxy_domain_when_given(self): + from wrenn.code_runner._protocol import build_ws_url + + url = build_ws_url( + base_url="https://app.wrenn.dev/api", + capsule_id="sb-1", + kernel_id="k-1", + proxy_domain="wrenn.dev", + ) + assert url.startswith("wss://") + assert "8888-sb-1.wrenn.dev" in url + assert url.endswith("/api/kernels/k-1/channels") + + def test_falls_back_to_base_host(self): + from wrenn.code_runner._protocol import build_ws_url + + url = build_ws_url( + base_url="http://localhost:8080/api", + capsule_id="sb-1", + kernel_id="k-1", + ) + # localhost stays http→ws (not wss) only if helper preserves scheme + assert "8888-sb-1.localhost:8080" in url + assert url.endswith("/api/kernels/k-1/channels") + + +class TestApplyKernelMessage: + def _make_execution(self): + from wrenn.code_runner.models import Execution, Logs + + return Execution(results=[], logs=Logs(stdout=[], stderr=[]), error=None) + + def test_ignores_other_parent(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "stream", + "header": {"msg_type": "stream"}, + "parent_header": {"msg_id": "other"}, + "content": {"name": "stdout", "text": "hi"}, + } + emitted: list = [] + done = apply_kernel_message( + msg, "mine", execution, emitted.append, None, None, None + ) + assert done is False + assert execution.logs.stdout == [] + + def test_stream_stdout_routed(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "stream", + "parent_header": {"msg_id": "mine"}, + "content": {"name": "stdout", "text": "hi"}, + } + outs: list[str] = [] + apply_kernel_message( + msg, "mine", execution, lambda e: None, None, outs.append, None + ) + assert execution.logs.stdout == ["hi"] + assert outs == ["hi"] + + def test_stream_stderr_routed(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "stream", + "parent_header": {"msg_id": "mine"}, + "content": {"name": "stderr", "text": "boom"}, + } + errs: list[str] = [] + apply_kernel_message( + msg, "mine", execution, lambda e: None, None, None, errs.append + ) + assert execution.logs.stderr == ["boom"] + assert errs == ["boom"] + + def test_execute_result_marks_main(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "execute_result", + "parent_header": {"msg_id": "mine"}, + "content": { + "data": {"text/plain": "42"}, + "execution_count": 7, + }, + } + results: list = [] + apply_kernel_message( + msg, "mine", execution, lambda e: None, results.append, None, None + ) + assert execution.execution_count == 7 + assert execution.results[0].is_main_result is True + assert results and results[0].is_main_result is True + + def test_display_data_is_not_main(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "display_data", + "parent_header": {"msg_id": "mine"}, + "content": {"data": {"text/plain": "x"}}, + } + apply_kernel_message(msg, "mine", execution, lambda e: None, None, None, None) + assert execution.results[0].is_main_result is False + assert execution.execution_count is None + + def test_error_message_emits_error(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "error", + "parent_header": {"msg_id": "mine"}, + "content": { + "ename": "ValueError", + "evalue": "bad", + "traceback": ["line1", "line2"], + }, + } + emitted: list = [] + apply_kernel_message(msg, "mine", execution, emitted.append, None, None, None) + assert len(emitted) == 1 + assert emitted[0].name == "ValueError" + assert emitted[0].traceback == "line1\nline2" + + def test_idle_status_returns_true(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "status", + "parent_header": {"msg_id": "mine"}, + "content": {"execution_state": "idle"}, + } + done = apply_kernel_message( + msg, "mine", execution, lambda e: None, None, None, None + ) + assert done is True + + def test_busy_status_returns_false(self): + from wrenn.code_runner._protocol import apply_kernel_message + + execution = self._make_execution() + msg = { + "msg_type": "status", + "parent_header": {"msg_id": "mine"}, + "content": {"execution_state": "busy"}, + } + done = apply_kernel_message( + msg, "mine", execution, lambda e: None, None, None, None + ) + assert done is False + + # ───────────────────────── run_code (WS-mocked) ───────────────────────── diff --git a/tests/test_filesystem_pty.py b/tests/test_filesystem_pty.py index 1c963b9..855bc49 100644 --- a/tests/test_filesystem_pty.py +++ b/tests/test_filesystem_pty.py @@ -1,11 +1,11 @@ from __future__ import annotations -import base64 import json from unittest.mock import AsyncMock, MagicMock import pytest import respx +from wsproto.events import BytesMessage, TextMessage from wrenn.capsule import Capsule from wrenn.models import FileEntry @@ -13,9 +13,18 @@ from wrenn.pty import ( AsyncPtySession, PtyEventType, PtySession, - _parse_pty_event, + _parse_control_event, ) + +def _text(payload: dict) -> TextMessage: + return TextMessage(data=json.dumps(payload)) + + +def _bytes(data: bytes) -> BytesMessage: + return BytesMessage(data=data) + + BASE = "https://app.wrenn.dev/api" @@ -226,50 +235,37 @@ class TestFilesExists: class TestPtyEventParsing: def test_started_event(self): raw = {"type": "started", "tag": "pty-a1b2c3d4", "pid": 42} - event = _parse_pty_event(raw) + event = _parse_control_event(raw) assert event.type == PtyEventType.started assert event.pid == 42 assert event.tag == "pty-a1b2c3d4" - def test_output_event_base64(self): - encoded = base64.b64encode(b"ls -la\n").decode() - raw = {"type": "output", "data": encoded} - event = _parse_pty_event(raw) - assert event.type == PtyEventType.output - assert event.data == b"ls -la\n" - - def test_output_event_empty(self): - raw = {"type": "output", "data": ""} - event = _parse_pty_event(raw) - assert event.data == b"" - def test_exit_event(self): raw = {"type": "exit", "exit_code": 0} - event = _parse_pty_event(raw) + event = _parse_control_event(raw) assert event.type == PtyEventType.exit assert event.exit_code == 0 def test_error_event(self): raw = {"type": "error", "data": "process not found", "fatal": True} - event = _parse_pty_event(raw) + event = _parse_control_event(raw) assert event.type == PtyEventType.error assert event.data == "process not found" assert event.fatal is True def test_ping_event(self): raw = {"type": "ping"} - event = _parse_pty_event(raw) + event = _parse_control_event(raw) assert event.type == PtyEventType.ping class TestPtySessionWrite: - def test_write_sends_base64_input(self): + def test_write_sends_binary_frame(self): ws = MagicMock() session = PtySession(ws, "cl-abc") session.write(b"ls -la\n") - sent = json.loads(ws.send_text.call_args[0][0]) - assert sent["type"] == "input" - assert base64.b64decode(sent["data"]) == b"ls -la\n" + ws.send_bytes.assert_called_once_with(b"ls -la\n") + ws.send_text.assert_not_called() class TestPtySessionResize: @@ -303,12 +299,11 @@ class TestPtySessionKill: class TestPtySessionIteration: def test_iter_yields_events_until_exit(self): ws = MagicMock() - messages = [ - json.dumps({"type": "started", "tag": "pty-abc12345", "pid": 1}), - json.dumps({"type": "output", "data": base64.b64encode(b"hello").decode()}), - json.dumps({"type": "exit", "exit_code": 0}), + ws.receive.side_effect = [ + _text({"type": "started", "tag": "pty-abc12345", "pid": 1}), + _bytes(b"hello"), + _text({"type": "exit", "exit_code": 0}), ] - ws.receive_text.side_effect = messages session = PtySession(ws, "cl-abc") events = list(session) assert len(events) == 3 @@ -320,12 +315,22 @@ class TestPtySessionIteration: assert events[2].type == PtyEventType.exit assert events[2].exit_code == 0 + def test_iter_yields_empty_binary_frame(self): + ws = MagicMock() + ws.receive.side_effect = [ + _bytes(b""), + _text({"type": "exit", "exit_code": 0}), + ] + session = PtySession(ws, "cl-abc") + events = list(session) + assert events[0].type == PtyEventType.output + assert events[0].data == b"" + def test_iter_stops_on_fatal_error(self): ws = MagicMock() - messages = [ - json.dumps({"type": "error", "data": "fatal", "fatal": True}), + ws.receive.side_effect = [ + _text({"type": "error", "data": "fatal", "fatal": True}), ] - ws.receive_text.side_effect = messages session = PtySession(ws, "cl-abc") events = list(session) assert len(events) == 1 @@ -335,7 +340,7 @@ class TestPtySessionIteration: import httpx_ws ws = MagicMock() - ws.receive_text.side_effect = httpx_ws.WebSocketDisconnect() + ws.receive.side_effect = httpx_ws.WebSocketDisconnect() session = PtySession(ws, "cl-abc") events = list(session) assert events == [] @@ -344,9 +349,9 @@ class TestPtySessionIteration: class TestPtySessionPong: def test_ping_triggers_pong(self): ws = MagicMock() - ws.receive_text.side_effect = [ - json.dumps({"type": "ping"}), - json.dumps({"type": "exit", "exit_code": 0}), + ws.receive.side_effect = [ + _text({"type": "ping"}), + _text({"type": "exit", "exit_code": 0}), ] session = PtySession(ws, "cl-abc") events = list(session) @@ -356,9 +361,9 @@ class TestPtySessionPong: def test_no_pong_without_ping(self): ws = MagicMock() - ws.receive_text.side_effect = [ - json.dumps({"type": "output", "data": ""}), - json.dumps({"type": "exit", "exit_code": 0}), + ws.receive.side_effect = [ + _bytes(b""), + _text({"type": "exit", "exit_code": 0}), ] session = PtySession(ws, "cl-abc") list(session) @@ -431,13 +436,12 @@ class TestPtySessionSendConnect: class TestAsyncPtySession: @pytest.mark.asyncio - async def test_async_write_sends_base64(self): + async def test_async_write_sends_binary_frame(self): ws = AsyncMock() session = AsyncPtySession(ws, "cl-abc") await session.write(b"hello") - sent = json.loads(ws.send_text.call_args[0][0]) - assert sent["type"] == "input" - assert base64.b64decode(sent["data"]) == b"hello" + ws.send_bytes.assert_awaited_once_with(b"hello") + ws.send_text.assert_not_called() @pytest.mark.asyncio async def test_async_resize(self): @@ -486,9 +490,9 @@ class TestAsyncPtySession: @pytest.mark.asyncio async def test_async_ping_triggers_pong(self): ws = AsyncMock() - ws.receive_text.side_effect = [ - json.dumps({"type": "ping"}), - json.dumps({"type": "exit", "exit_code": 0}), + ws.receive.side_effect = [ + _text({"type": "ping"}), + _text({"type": "exit", "exit_code": 0}), ] session = AsyncPtySession(ws, "cl-abc") events = [e async for e in session] @@ -508,12 +512,11 @@ class TestAsyncPtySession: @pytest.mark.asyncio async def test_async_iteration(self): ws = AsyncMock() - messages = [ - json.dumps({"type": "started", "tag": "pty-xyz", "pid": 5}), - json.dumps({"type": "output", "data": base64.b64encode(b"hi").decode()}), - json.dumps({"type": "exit", "exit_code": 0}), + ws.receive.side_effect = [ + _text({"type": "started", "tag": "pty-xyz", "pid": 5}), + _bytes(b"hi"), + _text({"type": "exit", "exit_code": 0}), ] - ws.receive_text.side_effect = messages session = AsyncPtySession(ws, "cl-abc") events = [] async for event in session: @@ -522,6 +525,8 @@ class TestAsyncPtySession: assert events[0].type == PtyEventType.started assert session.tag == "pty-xyz" assert session.pid == 5 + assert events[1].type == PtyEventType.output + assert events[1].data == b"hi" assert events[2].type == PtyEventType.exit diff --git a/tests/test_integration.py b/tests/test_integration.py index 358065e..d98a695 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,8 +7,18 @@ from pathlib import Path import pytest from wrenn import Capsule, CommandResult +from wrenn.client import WrennClient from wrenn.commands import CommandHandle, ProcessInfo -from wrenn.models import Capsule as CapsuleModel, FileEntry, Status +from wrenn.exceptions import WrennNotFoundError +from wrenn.models import ( + Capsule as CapsuleModel, + CapsuleMetrics, + CapsuleStats, + FileEntry, + SSEEvent, + Status, + UsageResponse, +) pytestmark = pytest.mark.integration @@ -406,3 +416,124 @@ class TestGit: def test_get_config_missing_returns_none(self): value = self.capsule.git.get_config("nonexistent.key") assert value is None + + +class TestStatsUsageMetrics: + """Account-scoped + per-capsule observability endpoints.""" + + def setup_method(self): + _ensure_env() + + def test_stats_default(self): + with WrennClient() as client: + stats = client.capsules.stats() + assert isinstance(stats, CapsuleStats) + # current.running_count is always present even when zero + assert stats.current is not None + assert stats.peaks is not None + + def test_stats_range(self): + with WrennClient() as client: + stats = client.capsules.stats(range="24h") + assert isinstance(stats, CapsuleStats) + if stats.range is not None: + assert stats.range.value == "24h" + + def test_usage_no_args(self): + with WrennClient() as client: + usage = client.capsules.usage() + assert isinstance(usage, UsageResponse) + assert usage.points is not None + + def test_usage_date_range(self): + from datetime import date, timedelta + + today = date.today() + with WrennClient() as client: + usage = client.capsules.usage(from_=today - timedelta(days=7), to=today) + assert isinstance(usage, UsageResponse) + + def test_metrics_running_capsule(self): + capsule = Capsule(wait=True) + try: + with WrennClient() as client: + m = client.capsules.metrics(capsule.capsule_id) + assert isinstance(m, CapsuleMetrics) + assert m.sandbox_id == capsule.capsule_id + assert m.points is not None + finally: + capsule.destroy() + + def test_metrics_range_2h(self): + capsule = Capsule(wait=True) + try: + with WrennClient() as client: + m = client.capsules.metrics(capsule.capsule_id, range="2h") + assert isinstance(m, CapsuleMetrics) + finally: + capsule.destroy() + + def test_metrics_destroyed_capsule_raises(self): + # Create, destroy, then ask for metrics — guaranteed valid-shape ID + # that no longer exists. Server returns 404 for the missing capsule + # (avoids the 400 validation path for malformed IDs). + capsule = Capsule(wait=True) + capsule_id = capsule.capsule_id + capsule.destroy(wait=True) + + with WrennClient() as client: + with pytest.raises(WrennNotFoundError): + client.capsules.metrics(capsule_id) + + +class TestEventsStream: + """SSE lifecycle stream — drives a capsule create and watches for it.""" + + def setup_method(self): + _ensure_env() + + def test_stream_receives_capsule_create_event(self): + import threading + + seen: list[SSEEvent] = [] + ready = threading.Event() + stop = threading.Event() + + def _reader() -> None: + with WrennClient() as client: + stream = client.events.stream() + ready.set() + for ev in stream: + seen.append(ev) + if stop.is_set() or len(seen) > 20: + return + + t = threading.Thread(target=_reader, daemon=True) + t.start() + assert ready.wait(timeout=5) + + # Give the server a beat to fully open the SSE stream before + # the action that should appear on it. + time.sleep(0.5) + + capsule = Capsule() + capsule_id = capsule.capsule_id + try: + capsule.wait_ready(timeout=60) + finally: + capsule.destroy() + + # Wait for either our capsule's event or a timeout. + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if any( + ev.resource is not None and ev.resource.id == capsule_id for ev in seen + ): + break + time.sleep(0.5) + + stop.set() + assert any(isinstance(ev, SSEEvent) for ev in seen) + # Best-effort: assert we saw a capsule.* event for our capsule. + matching = [ev for ev in seen if ev.resource and ev.resource.id == capsule_id] + assert matching, f"no events for {capsule_id} in {len(seen)} received" diff --git a/uv.lock b/uv.lock index 0dc0352..8ce86a6 100644 --- a/uv.lock +++ b/uv.lock @@ -285,15 +285,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "docspec" version = "2.2.1" @@ -328,19 +319,6 @@ version = "0.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" } -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, -] - [[package]] name = "filelock" version = "3.29.0" @@ -1166,11 +1144,10 @@ wheels = [ [[package]] name = "wrenn" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "certifi" }, - { name = "email-validator" }, { name = "httpx" }, { name = "httpx-ws" }, { name = "pydantic" }, @@ -1184,6 +1161,7 @@ dev = [ { name = "pydoc-markdown" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pyyaml" }, { name = "respx" }, { name = "ruff" }, ] @@ -1191,7 +1169,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "certifi", specifier = ">=2026.2.25" }, - { name = "email-validator", specifier = ">=2.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx-ws", specifier = ">=0.9.0" }, { name = "pydantic", specifier = ">=2.12.5" }, @@ -1205,6 +1182,7 @@ dev = [ { name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "respx", specifier = ">=0.23.1" }, { name = "ruff", specifier = ">=0.15.10" }, ]