1use 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 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 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 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 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 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 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 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 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 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 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 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
347fn 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}