Skip to main content

heyo_sdk/
sandbox.rs

1//! Primary entry-point. Mirrors `sdk-ts/src/sandbox.ts`.
2
3use std::time::{Duration, Instant};
4
5use reqwest::Method;
6use serde::{Deserialize, Serialize};
7use tokio::time::sleep;
8
9use crate::client::{HeyoClient, HeyoClientOptions, RequestOptions};
10use crate::commands::{encode_path, Commands};
11use crate::errors::HeyoError;
12use crate::files::Files;
13use crate::shell::{ShellOptions, ShellSession};
14use crate::types::{
15    BoundUrl, PublicImage, SandboxCreateOptions, SandboxInfo, SandboxSize, SandboxStatus,
16};
17
18const DEFAULT_WAIT_FOR_READY: Duration = Duration::from_secs(5 * 60);
19const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
20
21#[derive(Clone)]
22pub struct Sandbox {
23    sandbox_id: String,
24    client: HeyoClient,
25    commands: Commands,
26    files: Files,
27}
28
29#[derive(Deserialize)]
30struct CreateResponse {
31    id: String,
32    #[allow(dead_code)]
33    #[serde(default)]
34    status: Option<String>,
35}
36
37#[derive(Deserialize)]
38struct PublicImagesEnvelope {
39    #[serde(default)]
40    images: Vec<PublicImage>,
41}
42
43#[derive(Serialize)]
44struct ReplaceMountRequest<'a> {
45    archive_id: &'a str,
46    sandbox_path: &'a str,
47}
48
49#[derive(Serialize)]
50struct TtlRequest {
51    ttl_seconds: u64,
52}
53
54#[derive(Serialize)]
55struct ResizeRequest<'a> {
56    size_class: &'a str,
57}
58
59#[derive(Deserialize)]
60struct BindPortResponse {
61    subdomain: String,
62    #[serde(default)]
63    hostname: Option<String>,
64    #[serde(default)]
65    url: Option<String>,
66    port: u16,
67    #[serde(default, rename = "is_public")]
68    is_public: Option<bool>,
69}
70
71impl Sandbox {
72    fn from_id(client: HeyoClient, sandbox_id: String) -> Self {
73        let commands = Commands::new(client.clone(), sandbox_id.clone());
74        let files = Files::new(client.clone(), sandbox_id.clone());
75        Self {
76            sandbox_id,
77            client,
78            commands,
79            files,
80        }
81    }
82
83    pub fn sandbox_id(&self) -> &str {
84        &self.sandbox_id
85    }
86
87    pub fn commands(&self) -> &Commands {
88        &self.commands
89    }
90
91    pub fn files(&self) -> &Files {
92        &self.files
93    }
94
95    pub fn client(&self) -> &HeyoClient {
96        &self.client
97    }
98
99    /// Create a new sandbox and (by default) wait for it to leave the
100    /// `provisioning` state before returning.
101    ///
102    /// Set `options.wait_for_ready = Some(Duration::ZERO)` to return
103    /// immediately and `wait_for_ready()` later.
104    pub async fn create(
105        mut options: SandboxCreateOptions,
106        client_options: HeyoClientOptions,
107    ) -> Result<Self, HeyoError> {
108        let wait_for = options.wait_for_ready.take().unwrap_or(DEFAULT_WAIT_FOR_READY);
109        let client = HeyoClient::new(client_options)?;
110        let body = serde_json::to_value(&options)
111            .map_err(|e| HeyoError::api(0, format!("serialize create body: {}", e)))?;
112        let body = augment_create_body(body);
113        let created: CreateResponse = client
114            .request(Method::POST, "/sandbox-deploy", Some(&body), RequestOptions::default())
115            .await?;
116        let sandbox = Sandbox::from_id(client, created.id);
117        if !wait_for.is_zero() {
118            sandbox.wait_for_ready(wait_for).await?;
119        }
120        Ok(sandbox)
121    }
122
123    /// Reattach to an existing sandbox by ID. Issues no network call.
124    pub fn connect(sandbox_id: String, client_options: HeyoClientOptions) -> Result<Self, HeyoError> {
125        let client = HeyoClient::new(client_options)?;
126        Ok(Sandbox::from_id(client, sandbox_id))
127    }
128
129    /// List all deployed sandboxes the caller can see.
130    pub async fn list(client_options: HeyoClientOptions) -> Result<Vec<SandboxInfo>, HeyoError> {
131        let client = HeyoClient::new(client_options)?;
132        client
133            .request(Method::GET, "/deployed-sandboxes", None::<&()>, RequestOptions::default())
134            .await
135    }
136
137    /// List public images available to deploy.
138    pub async fn list_public_images(
139        backend: Option<&str>,
140        client_options: HeyoClientOptions,
141    ) -> Result<Vec<PublicImage>, HeyoError> {
142        let client = HeyoClient::new(client_options)?;
143        let mut opts = RequestOptions::default();
144        if let Some(b) = backend {
145            opts.query.push(("backend".to_string(), b.to_string()));
146        }
147        let env: PublicImagesEnvelope = client
148            .request(Method::GET, "/public-images", None::<&()>, opts)
149            .await?;
150        Ok(env.images)
151    }
152
153    /// Fetch the latest info for this sandbox.
154    pub async fn info(&self) -> Result<SandboxInfo, HeyoError> {
155        let all: Vec<SandboxInfo> = self
156            .client
157            .request(Method::GET, "/deployed-sandboxes", None::<&()>, RequestOptions::default())
158            .await?;
159        all.into_iter()
160            .find(|s| s.id == self.sandbox_id)
161            .ok_or_else(|| HeyoError::NotFound(format!("Sandbox {} not found", self.sandbox_id)))
162    }
163
164    /// Block until the sandbox transitions out of `provisioning`.
165    pub async fn wait_for_ready(&self, timeout: Duration) -> Result<SandboxInfo, HeyoError> {
166        let deadline = Instant::now() + timeout;
167        loop {
168            match self.info().await {
169                Ok(info) => match info.status {
170                    SandboxStatus::Running => return Ok(info),
171                    SandboxStatus::Failed => {
172                        return Err(HeyoError::SandboxFailed {
173                            sandbox_id: self.sandbox_id.clone(),
174                            reason: info
175                                .error_message
176                                .clone()
177                                .unwrap_or_else(|| "no reason reported".to_string()),
178                        });
179                    }
180                    SandboxStatus::Provisioning | SandboxStatus::Unknown => {}
181                    _ => return Ok(info),
182                },
183                Err(HeyoError::NotFound(_)) if Instant::now() < deadline => {
184                    sleep(READY_POLL_INTERVAL).await;
185                    continue;
186                }
187                Err(e) => return Err(e),
188            }
189            if Instant::now() >= deadline {
190                return Err(HeyoError::Timeout(
191                    timeout,
192                    format!("Sandbox {} did not become ready", self.sandbox_id),
193                ));
194            }
195            sleep(READY_POLL_INTERVAL).await;
196        }
197    }
198
199    /// Permanently delete this sandbox. 404 is treated as success.
200    pub async fn kill(&self) -> Result<(), HeyoError> {
201        let path = format!("/deployed-sandboxes/{}", encode_path(&self.sandbox_id));
202        match self
203            .client
204            .request::<serde_json::Value>(Method::DELETE, &path, None::<&()>, RequestOptions::default())
205            .await
206        {
207            Ok(_) => Ok(()),
208            Err(HeyoError::NotFound(_)) => Ok(()),
209            Err(e) => Err(e),
210        }
211    }
212
213    pub async fn stop(&self) -> Result<(), HeyoError> {
214        let path = format!("/sandbox/{}/stop", encode_path(&self.sandbox_id));
215        self.client
216            .request::<serde_json::Value>(Method::POST, &path, None::<&()>, RequestOptions::default())
217            .await?;
218        Ok(())
219    }
220
221    pub async fn start(&self) -> Result<(), HeyoError> {
222        let path = format!("/sandbox/{}/start", encode_path(&self.sandbox_id));
223        self.client
224            .request::<serde_json::Value>(Method::POST, &path, None::<&()>, RequestOptions::default())
225            .await?;
226        Ok(())
227    }
228
229    pub async fn restart(&self) -> Result<(), HeyoError> {
230        let path = format!("/deployed-sandboxes/{}/restart", encode_path(&self.sandbox_id));
231        self.client
232            .request::<serde_json::Value>(Method::POST, &path, None::<&()>, RequestOptions::default())
233            .await?;
234        Ok(())
235    }
236
237    /// Update the sandbox's TTL (seconds). `0` for unlimited (if plan allows).
238    pub async fn set_ttl(&self, ttl_seconds: u64) -> Result<(), HeyoError> {
239        let body = TtlRequest { ttl_seconds };
240        let path = format!("/deployed-sandboxes/{}/ttl", encode_path(&self.sandbox_id));
241        self.client
242            .request::<serde_json::Value>(Method::POST, &path, Some(&body), RequestOptions::default())
243            .await?;
244        Ok(())
245    }
246
247    pub async fn resize(&self, size: SandboxSize) -> Result<(), HeyoError> {
248        let body = ResizeRequest {
249            size_class: size.as_str(),
250        };
251        let path = format!("/deployed-sandboxes/{}/resize", encode_path(&self.sandbox_id));
252        self.client
253            .request::<serde_json::Value>(Method::POST, &path, Some(&body), RequestOptions::default())
254            .await?;
255        Ok(())
256    }
257
258    pub async fn checkpoint(&self) -> Result<(), HeyoError> {
259        let path = format!("/deployed-sandboxes/{}/checkpoint", encode_path(&self.sandbox_id));
260        self.client
261            .request::<serde_json::Value>(Method::POST, &path, None::<&()>, RequestOptions::default())
262            .await?;
263        Ok(())
264    }
265
266    pub async fn restore(&self) -> Result<(), HeyoError> {
267        let path = format!("/deployed-sandboxes/{}/restore", encode_path(&self.sandbox_id));
268        self.client
269            .request::<serde_json::Value>(Method::POST, &path, None::<&()>, RequestOptions::default())
270            .await?;
271        Ok(())
272    }
273
274    pub async fn replace_mount(
275        &self,
276        archive_id: &str,
277        sandbox_path: Option<&str>,
278    ) -> Result<(), HeyoError> {
279        let body = ReplaceMountRequest {
280            archive_id,
281            sandbox_path: sandbox_path.unwrap_or("/workspace"),
282        };
283        let path = format!(
284            "/deployed-sandboxes/{}/replace-mount",
285            encode_path(&self.sandbox_id)
286        );
287        self.client
288            .request::<serde_json::Value>(Method::POST, &path, Some(&body), RequestOptions::default())
289            .await?;
290        Ok(())
291    }
292
293    /// Public URL for a port the sandbox has bound, or `None` if not exposed.
294    pub async fn get_host(&self, port: u16) -> Result<Option<String>, HeyoError> {
295        let info = self.info().await?;
296        Ok(info.urls.into_iter().find(|u| u.port == port).map(|u| u.url))
297    }
298
299    /// Open a persistent interactive shell. The returned [`ShellSession`]
300    /// is already past the `init`/`ready` handshake.
301    pub async fn shell(&self, options: ShellOptions) -> Result<ShellSession, HeyoError> {
302        ShellSession::open(self.client.clone(), self.sandbox_id.clone(), options).await
303    }
304
305    /// Bind a port and return the resulting public URL.
306    pub async fn bind_port(
307        &self,
308        port: u16,
309        is_public: Option<bool>,
310    ) -> Result<BoundUrl, HeyoError> {
311        let mut body = serde_json::Map::new();
312        body.insert("sandbox_id".into(), serde_json::Value::String(self.sandbox_id.clone()));
313        body.insert("port".into(), serde_json::Value::Number(port.into()));
314        if let Some(p) = is_public {
315            body.insert("is_public".into(), serde_json::Value::Bool(p));
316        }
317        let raw: BindPortResponse = self
318            .client
319            .request(
320                Method::POST,
321                "/proxy-endpoints/for-deployed",
322                Some(&serde_json::Value::Object(body)),
323                RequestOptions::default(),
324            )
325            .await?;
326        let hostname = raw
327            .hostname
328            .clone()
329            .or_else(|| {
330                raw.url
331                    .as_ref()
332                    .and_then(|u| url::Url::parse(u).ok())
333                    .and_then(|u| u.host_str().map(String::from))
334            })
335            .unwrap_or_else(|| raw.subdomain.clone());
336        let url = raw.url.unwrap_or_else(|| format!("https://{}", hostname));
337        Ok(BoundUrl {
338            subdomain: raw.subdomain,
339            hostname,
340            url,
341            port: raw.port,
342            is_public: raw.is_public.unwrap_or(true),
343        })
344    }
345}
346
347/// Apply server-side defaults that the TS SDK mirrors: region=US,
348/// image=ubuntu:24.04, size_class=small.
349fn augment_create_body(mut body: serde_json::Value) -> serde_json::Value {
350    if let serde_json::Value::Object(map) = &mut body {
351        map.entry("region")
352            .or_insert(serde_json::Value::String("US".to_string()));
353        map.entry("image")
354            .or_insert(serde_json::Value::String("ubuntu:24.04".to_string()));
355        map.entry("size_class")
356            .or_insert(serde_json::Value::String("small".to_string()));
357        map.entry("open_ports").or_insert(serde_json::Value::Array(vec![]));
358    }
359    body
360}