1use std::collections::HashMap;
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use crate::channel::ControlChannel;
11use crate::code::Code;
12use crate::commands::{run_command, Commands, RunOptions};
13use crate::error::SolariError;
14use crate::files::Files;
15use crate::git::{Git, GitRunner};
16use crate::http::{encode_uri_component, new_idempotency_key, HttpOptions, HttpTransport};
17use crate::types::{
18 CommandResult, CreateSandboxRequest, CreateSandboxResponse, Lifecycle, ResumeResponse,
19 SandboxView, VolumeAttachment,
20};
21
22#[derive(Debug, Clone)]
24pub struct ClientOptions {
25 pub api_key: String,
26 pub base_url: String,
27 pub call_timeout_ms: Option<u64>,
29}
30
31impl ClientOptions {
32 pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
33 ClientOptions {
34 api_key: api_key.into(),
35 base_url: base_url.into(),
36 call_timeout_ms: None,
37 }
38 }
39}
40
41#[derive(Default, Clone)]
43pub struct CreateOptions {
44 pub template: Option<String>,
45 pub cpu: Option<u32>,
46 pub mem_mb: Option<u32>,
47 pub disk_gb: Option<u32>,
48 pub envs: Option<HashMap<String, String>>,
49 pub metadata: Option<HashMap<String, String>>,
50 pub timeout_ms: Option<u64>,
51 pub from_snapshot: Option<String>,
52 pub lifecycle: Option<Lifecycle>,
53 pub resolution: Option<String>,
56 pub record: Option<bool>,
59 pub volumes: Option<Vec<VolumeAttachment>>,
61}
62
63impl CreateOptions {
64 pub(crate) fn into_request(self, kind: &str) -> CreateSandboxRequest {
70 CreateSandboxRequest {
71 template: self.template,
72 kind: Some(kind.to_string()),
73 cpu: self.cpu,
74 mem_mb: self.mem_mb,
75 disk_gb: self.disk_gb,
76 envs: self.envs,
77 metadata: self.metadata,
78 timeout_ms: self.timeout_ms,
79 from_snapshot: self.from_snapshot,
80 lifecycle: self.lifecycle,
81 resolution: self.resolution,
82 record: self.record,
83 volumes: self.volumes,
84 }
85 }
86}
87
88impl From<CreateOptions> for CreateSandboxRequest {
89 fn from(o: CreateOptions) -> Self {
90 o.into_request("sandbox")
91 }
92}
93
94pub struct Client {
96 http: Arc<HttpTransport>,
97 call_timeout_ms: Option<u64>,
98}
99
100impl Client {
101 pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
102 let http = HttpTransport::new(HttpOptions::new(opts.api_key, opts.base_url))?;
103 Ok(Client {
104 http: Arc::new(http),
105 call_timeout_ms: opts.call_timeout_ms,
106 })
107 }
108
109 pub async fn create(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
111 self.create_with_kind(opts, "sandbox").await
112 }
113
114 pub async fn create_desktop(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
124 self.create_with_kind(opts, "desktop").await
125 }
126
127 async fn create_with_kind(
128 &self,
129 opts: CreateOptions,
130 kind: &str,
131 ) -> Result<Sandbox, SolariError> {
132 let req = opts.into_request(kind);
133 let body = serde_json::to_value(&req).unwrap();
134 let resp: CreateSandboxResponse = self
135 .http
136 .request("POST", "/sandboxes", Some(body), Some(new_idempotency_key()))
137 .await?;
138 Ok(Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms))
139 }
140
141 pub async fn connect(&self, sandbox_id: &str) -> Result<Sandbox, SolariError> {
143 let view = self.get(sandbox_id).await?;
144 let control_url = view.control_url.clone().unwrap_or_else(|| {
145 format!(
146 "{}/control/{}",
147 self.http.ws_origin(),
148 encode_uri_component(sandbox_id)
149 )
150 });
151 let stream_url = if view.kind == "desktop" {
156 Some(format!(
157 "{}/stream/{}",
158 self.http.ws_origin(),
159 encode_uri_component(sandbox_id)
160 ))
161 } else {
162 None
163 };
164 let resp = CreateSandboxResponse {
165 sandbox_id: view.sandbox_id,
166 kind: view.kind,
167 control_url,
168 expires_at: view.expires_at,
169 stream_url,
170 };
171 Ok(Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms))
172 }
173
174 pub async fn get(&self, sandbox_id: &str) -> Result<SandboxView, SolariError> {
176 self.http
177 .request(
178 "GET",
179 &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
180 None,
181 None,
182 )
183 .await
184 }
185
186 pub async fn kill(&self, sandbox_id: &str) -> Result<(), SolariError> {
188 let _: serde_json::Value = self
189 .http
190 .request(
191 "DELETE",
192 &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
193 None,
194 None,
195 )
196 .await?;
197 Ok(())
198 }
199
200 pub async fn pause(&self, sandbox_id: &str) -> Result<(), SolariError> {
204 let _: serde_json::Value = self
205 .http
206 .request(
207 "POST",
208 &format!("/sandboxes/{}/pause", encode_uri_component(sandbox_id)),
209 None,
210 None,
211 )
212 .await?;
213 Ok(())
214 }
215
216 pub async fn resume(&self, sandbox_id: &str) -> Result<String, SolariError> {
223 let r: ResumeResponse = self
224 .http
225 .request(
226 "POST",
227 &format!("/sandboxes/{}/resume", encode_uri_component(sandbox_id)),
228 None,
229 None,
230 )
231 .await?;
232 Ok(r.control_url.unwrap_or_else(|| {
233 format!(
234 "{}/control/{}",
235 self.http.ws_origin(),
236 encode_uri_component(sandbox_id)
237 )
238 }))
239 }
240}
241
242pub struct Sandbox {
244 id: String,
245 kind: String,
246 control_url: String,
247 stream_url: Option<String>,
248 expires_at: String,
249 http: Arc<HttpTransport>,
250 channel: Arc<ControlChannel>,
251}
252
253impl Sandbox {
254 pub(crate) fn from_response(
255 http: Arc<HttpTransport>,
256 resp: CreateSandboxResponse,
257 call_timeout_ms: Option<u64>,
258 ) -> Self {
259 let channel = ControlChannel::new(
260 resp.control_url.clone(),
261 http.auth_headers(),
262 call_timeout_ms,
263 );
264 Sandbox {
265 id: resp.sandbox_id,
266 kind: resp.kind,
267 control_url: resp.control_url,
268 stream_url: resp.stream_url,
269 expires_at: resp.expires_at,
270 http,
271 channel: Arc::new(channel),
272 }
273 }
274
275 pub fn id(&self) -> &str {
277 &self.id
278 }
279 pub fn kind(&self) -> &str {
281 &self.kind
282 }
283 pub fn control_url(&self) -> &str {
285 &self.control_url
286 }
287 pub fn stream_url(&self) -> Option<&str> {
290 self.stream_url.as_deref()
291 }
292 pub fn expires_at(&self) -> &str {
294 &self.expires_at
295 }
296 pub fn connected(&self) -> bool {
298 self.channel.connected()
299 }
300
301 pub async fn connect(&self) -> Result<(), SolariError> {
303 self.channel.connect().await
304 }
305
306 pub fn close(&self) {
308 self.channel.close();
309 }
310
311 pub async fn pause(&self) -> Result<(), SolariError> {
318 let _: serde_json::Value = self
319 .http
320 .request(
321 "POST",
322 &format!("/sandboxes/{}/pause", encode_uri_component(&self.id)),
323 None,
324 None,
325 )
326 .await?;
327 self.close();
328 Ok(())
329 }
330
331 pub async fn resume(&mut self) -> Result<(), SolariError> {
343 let r: ResumeResponse = self
344 .http
345 .request(
346 "POST",
347 &format!("/sandboxes/{}/resume", encode_uri_component(&self.id)),
348 None,
349 None,
350 )
351 .await?;
352 let control_url = r.control_url.unwrap_or_else(|| {
353 format!(
354 "{}/control/{}",
355 self.http.ws_origin(),
356 encode_uri_component(&self.id)
357 )
358 });
359 self.control_url = control_url.clone();
360 self.channel.set_control_url(control_url);
361 self.channel.reconnect().await
362 }
363
364 pub async fn kill(&self) -> Result<(), SolariError> {
366 let _: serde_json::Value = self
367 .http
368 .request(
369 "DELETE",
370 &format!("/sandboxes/{}", encode_uri_component(&self.id)),
371 None,
372 None,
373 )
374 .await?;
375 self.close();
376 Ok(())
377 }
378
379 pub fn commands(&self) -> Commands<'_> {
382 Commands { sb: self }
383 }
384 pub fn files(&self) -> Files<'_> {
385 Files { sb: self }
386 }
387 pub fn code(&self) -> Code<'_> {
388 Code { sb: self }
389 }
390 pub fn git(&self) -> Git<'_> {
391 Git::new(self)
392 }
393
394 pub(crate) fn channel(&self) -> &ControlChannel {
397 &self.channel
398 }
399 pub(crate) fn channel_arc(&self) -> Arc<ControlChannel> {
400 self.channel.clone()
401 }
402 pub(crate) fn http(&self) -> &HttpTransport {
403 &self.http
404 }
405}
406
407impl GitRunner for Sandbox {
409 fn run_git<'a>(
410 &'a self,
411 args: Vec<String>,
412 cwd: Option<String>,
413 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
414 Box::pin(async move {
415 let opts = RunOptions {
416 args: Some(args),
417 cwd,
418 ..Default::default()
419 };
420 run_command(self, "git", &opts).await
421 })
422 }
423}