Skip to main content

boxxkite_client/
sandboxes.rs

1//! Sandbox lifecycle: `POST/GET/DELETE /v1/sandboxes*`. Mirrors
2//! `sdk-python`'s `create_sandbox`/`get_sandbox`/`list_sandboxes`/
3//! `destroy_sandbox`.
4
5use std::collections::HashMap;
6
7use reqwest::Method;
8use serde::{Deserialize, Serialize};
9
10use crate::client::Client;
11use crate::error::BoxxkiteError;
12
13/// CPU/memory size preset for a sandbox. Capped per-account by the
14/// deployment's `BOXXKITE_MAX_SANDBOX_SIZE` -- requesting a larger size than
15/// the account is allowed returns `429`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum SandboxSize {
19    Small,
20    Medium,
21    Large,
22}
23
24/// Builder for `POST /v1/sandboxes`'s optional fields. All fields are
25/// unset (server defaults apply) unless set via the chained setters.
26///
27/// ```no_run
28/// use boxxkite_client::CreateSandboxOptions;
29///
30/// let options = CreateSandboxOptions::new()
31///     .label("demo")
32///     .storage_gb(20.0)
33///     .lifetime_minutes(120);
34/// ```
35#[derive(Debug, Clone, Default, Serialize)]
36pub struct CreateSandboxOptions {
37    #[serde(skip_serializing_if = "Option::is_none")]
38    label: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    size: Option<SandboxSize>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    storage_gb: Option<f64>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    lifetime_minutes: Option<i64>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    count: Option<u32>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    secret_names: Option<Vec<String>>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    image_id: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    mcp_connection_names: Option<Vec<String>>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    volume_mounts: Option<HashMap<String, String>>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    gpu_count: Option<i64>,
57}
58
59impl CreateSandboxOptions {
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Optional human-readable label for the sandbox.
65    pub fn label(mut self, label: impl Into<String>) -> Self {
66        self.label = Some(label.into());
67        self
68    }
69
70    /// CPU/memory size preset. Defaults to `Small` server-side when omitted.
71    pub fn size(mut self, size: SandboxSize) -> Self {
72        self.size = Some(size);
73        self
74    }
75
76    /// Requested persistent storage in GB.
77    pub fn storage_gb(mut self, storage_gb: f64) -> Self {
78        self.storage_gb = Some(storage_gb);
79        self
80    }
81
82    /// Maximum lifetime of the sandbox in minutes before automatic teardown.
83    pub fn lifetime_minutes(mut self, lifetime_minutes: i64) -> Self {
84        self.lifetime_minutes = Some(lifetime_minutes);
85        self
86    }
87
88    /// Number of sandboxes to create in this request (1-10). Use
89    /// [`Client::create_sandbox_batch`], not [`Client::create_sandbox`], to
90    /// create more than one -- the server returns a JSON array only when
91    /// `count` is greater than 1.
92    pub fn count(mut self, count: u32) -> Self {
93        self.count = Some(count);
94        self
95    }
96
97    /// Names of this account's secrets (see `docs/SECRETS-DESIGN.md`) this
98    /// session should be granted access to via the sidecar's secrets-broker
99    /// `http_request` tool. A name that doesn't exist for this account 404s
100    /// before any sandbox is created.
101    pub fn secret_names<I, S>(mut self, secret_names: I) -> Self
102    where
103        I: IntoIterator<Item = S>,
104        S: Into<String>,
105    {
106        self.secret_names = Some(secret_names.into_iter().map(Into::into).collect());
107        self
108    }
109
110    /// Id of a completed custom image built via [`Client::create_image`].
111    /// 404s if not owned by this account or not yet status `"completed"`.
112    pub fn image_id(mut self, image_id: impl Into<String>) -> Self {
113        self.image_id = Some(image_id.into());
114        self
115    }
116
117    /// Labels of this account's outbound-MCP connections (see
118    /// [`Client::create_mcp_connection`]) this session should be granted
119    /// network egress to.
120    pub fn mcp_connection_names<I, S>(mut self, names: I) -> Self
121    where
122        I: IntoIterator<Item = S>,
123        S: Into<String>,
124    {
125        self.mcp_connection_names = Some(names.into_iter().map(Into::into).collect());
126        self
127    }
128
129    /// `{volume_id: mount_path}` mapping of independent PVC-backed volumes
130    /// (see [`Client::create_volume`]) to mount into this sandbox.
131    pub fn volume_mounts(mut self, volume_mounts: HashMap<String, String>) -> Self {
132        self.volume_mounts = Some(volume_mounts);
133        self
134    }
135
136    /// Opt-in, experimental (`docs/GPU-SUPPORT-SCOPING.md`) -- requests this
137    /// many GPUs as a Kubernetes extended-resource limit on the sandbox
138    /// container. 422s (`gpu_support_disabled`) unless the deployment has
139    /// `BOXXKITE_GPU_ENABLED` set and a GPU-equipped node pool with a device
140    /// plugin provisioned; not verified against real GPU hardware in this
141    /// codebase. Bounded by `BOXXKITE_MAX_GPU_COUNT_PER_SESSION`
142    /// (422 `invalid_gpu_count` otherwise).
143    pub fn gpu_count(mut self, gpu_count: i64) -> Self {
144        self.gpu_count = Some(gpu_count);
145        self
146    }
147}
148
149/// How to reach the sandbox session -- an opaque handle for operators with
150/// cluster access; external callers operate on the session through this
151/// client's own `exec`/file-op methods instead (see `docs/API.md`).
152#[derive(Debug, Clone, Deserialize)]
153pub struct SandboxConnectInfo {
154    pub pod_name: Option<String>,
155    pub note: String,
156}
157
158/// Usage against this account's fair-use limits, included on
159/// [`Client::create_sandbox`]'s response.
160#[derive(Debug, Clone, Deserialize)]
161pub struct UsageSummary {
162    pub monthly_sandbox_hours_used: f64,
163    pub monthly_sandbox_hours_limit: f64,
164    pub concurrent_sandboxes: i64,
165    pub concurrent_sandboxes_limit: i64,
166}
167
168/// A sandbox session (`SandboxSessionOut`/`SandboxCreatedResponse`).
169/// `usage` is only present on [`Client::create_sandbox`]'s response, not on
170/// [`Client::get_sandbox`]/[`Client::list_sandboxes`].
171#[derive(Debug, Clone, Deserialize)]
172pub struct Sandbox {
173    pub id: String,
174    /// `"active"` or `"destroyed"`.
175    pub status: String,
176    pub label: Option<String>,
177    pub created_at: String,
178    pub destroyed_at: Option<String>,
179    pub expires_at: Option<String>,
180    pub connect: Option<SandboxConnectInfo>,
181    pub usage: Option<UsageSummary>,
182}
183
184impl Client {
185    /// `POST /v1/sandboxes` -- create a single sandbox. Use
186    /// [`Client::create_sandbox_batch`] instead if `options.count(n)` with
187    /// `n > 1` -- the server responds with a JSON array in that case, which
188    /// won't deserialize as a single [`Sandbox`].
189    pub async fn create_sandbox(
190        &self,
191        options: CreateSandboxOptions,
192    ) -> Result<Sandbox, BoxxkiteError> {
193        let builder = self.request(Method::POST, "/v1/sandboxes").json(&options);
194        self.send(builder).await
195    }
196
197    /// `POST /v1/sandboxes` with `options.count(n)`, `n > 1` -- creates a
198    /// batch of sandboxes in one call and returns all of them. Each session
199    /// in the batch is created and limit-checked one at a time, so a later
200    /// item can still fail the concurrent-sandbox or monthly-usage cap even
201    /// if earlier items succeeded (surfaces as a `BoxxkiteError::Api` for the
202    /// whole call, per the underlying REST contract).
203    pub async fn create_sandbox_batch(
204        &self,
205        options: CreateSandboxOptions,
206    ) -> Result<Vec<Sandbox>, BoxxkiteError> {
207        let builder = self.request(Method::POST, "/v1/sandboxes").json(&options);
208        self.send(builder).await
209    }
210
211    /// `GET /v1/sandboxes/{session_id}` -- fetch one sandbox session by id.
212    /// Resolves destroyed sessions too (a lookup, not an operational route
213    /// that requires a live pod).
214    pub async fn get_sandbox(&self, session_id: &str) -> Result<Sandbox, BoxxkiteError> {
215        let builder = self.request(Method::GET, &format!("/v1/sandboxes/{session_id}"));
216        self.send(builder).await
217    }
218
219    /// `GET /v1/sandboxes` -- list sandbox sessions owned by this account.
220    pub async fn list_sandboxes(&self, active_only: bool) -> Result<Vec<Sandbox>, BoxxkiteError> {
221        let builder = self
222            .request(Method::GET, "/v1/sandboxes")
223            .query(&[("active_only", active_only.to_string())]);
224        self.send_or_default(builder).await
225    }
226
227    /// `DELETE /v1/sandboxes/{session_id}` -- tear down a sandbox session.
228    pub async fn destroy_sandbox(&self, session_id: &str) -> Result<(), BoxxkiteError> {
229        let builder = self.request(Method::DELETE, &format!("/v1/sandboxes/{session_id}"));
230        self.send_no_content(builder).await
231    }
232}