Skip to main content

solari/
client.rs

1//! `Client` (REST create/connect/get/kill) and the `Sandbox` handle exposing
2//! `.commands()`, `.files()`, `.code()`, and `.git()`. Mirrors
3//! `sandbox-client.ts` (REST shapes) + `SessionHandle` (namespaces).
4
5use 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/// Options for constructing a [`Client`].
23#[derive(Debug, Clone)]
24pub struct ClientOptions {
25    pub api_key: String,
26    pub base_url: String,
27    /// Per-call control-WS RPC timeout override (ms). Defaults to 300000.
28    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/// Options accepted by `POST /sandboxes` (headless sandbox flavour).
42#[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    /// Initial display resolution, e.g. `"1280x720"`. Desktops only — a headless
54    /// sandbox has no display.
55    pub resolution: Option<String>,
56    /// Record the session server-side; the create response carries a presigned
57    /// playback URL. Desktops only.
58    pub record: Option<bool>,
59    /// Persistent volumes to mount before the session starts.
60    pub volumes: Option<Vec<VolumeAttachment>>,
61}
62
63impl CreateOptions {
64    /// Build the `POST /sandboxes` body for a given VM flavour.
65    ///
66    /// THE SINGLE PLACE an option becomes a wire field, and therefore the place
67    /// the cross-language wire contract is pinned (see `tests/contract_wire.rs`).
68    /// A field added above but not mapped here is silently invisible on the wire.
69    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
94/// Talks the SDK ⇆ Gateway `/sandboxes` REST API and hands back [`Sandbox`]es.
95pub 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    /// Create a new headless sandbox (`POST /sandboxes`).
110    pub async fn create(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
111        self.create_with_kind(opts, "sandbox").await
112    }
113
114    /// Create a GUI desktop VM (`POST /sandboxes` with `kind:"desktop"`).
115    ///
116    /// Mirrors TS `createDesktop()` / Python `create_desktop()`. The returned
117    /// handle carries [`Sandbox::stream_url`] for the live view, and exposes the
118    /// same core namespaces as a sandbox (`commands`/`files`/`code`/`git`).
119    /// Driving the GUI (mouse/keyboard/screenshot) is not part of this surface.
120    ///
121    /// Requires the `desktop` entitlement on the org's plan; the gateway
122    /// answers 402 `FeatureRequiresPlan` otherwise.
123    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    /// Re-attach to a running sandbox by id (`GET /sandboxes/:id`).
142    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        // `GET /sandboxes/:id` carries no streamUrl (the gateway's view
152        // serializer omits it), so derive it exactly as control_url is derived
153        // above — both are built from the same origin. Desktops only: a
154        // headless sandbox has no display to stream.
155        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    /// Fetch a sandbox's current view (`GET /sandboxes/:id`).
175    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    /// Destroy a sandbox (`DELETE /sandboxes/:id`). Idempotent.
187    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    /// Pause a session (`POST /sandboxes/:id/pause`): snapshot its RAM+disk and
201    /// free its host slot. The session keeps its id; bring it back with
202    /// [`Client::resume`].
203    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    /// Resume a paused session (`POST /sandboxes/:id/resume`) and return the
217    /// control URL to re-attach to.
218    ///
219    /// The session comes back on a FRESH slot, so its pre-pause control URL is
220    /// stale. The gateway normally returns the new one; when it does not, derive
221    /// it from the gateway origin exactly as [`Client::connect`] does.
222    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
242/// A live sandbox handle. Exposes the core namespaces.
243pub 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    /// The session id.
276    pub fn id(&self) -> &str {
277        &self.id
278    }
279    /// The VM flavour — `"sandbox"` or `"desktop"`.
280    pub fn kind(&self) -> &str {
281        &self.kind
282    }
283    /// The control-WS URL.
284    pub fn control_url(&self) -> &str {
285        &self.control_url
286    }
287    /// The live-view stream URL. `Some` for desktops only — a headless sandbox
288    /// has no display, so the gateway omits it.
289    pub fn stream_url(&self) -> Option<&str> {
290        self.stream_url.as_deref()
291    }
292    /// ISO-8601 expiry timestamp.
293    pub fn expires_at(&self) -> &str {
294        &self.expires_at
295    }
296    /// Whether the control channel is open.
297    pub fn connected(&self) -> bool {
298        self.channel.connected()
299    }
300
301    /// Open the control WebSocket. Idempotent.
302    pub async fn connect(&self) -> Result<(), SolariError> {
303        self.channel.connect().await
304    }
305
306    /// Close the control channel locally (does NOT release the remote session).
307    pub fn close(&self) {
308        self.channel.close();
309    }
310
311    /// Pause this session: snapshot its RAM+disk, free its host slot, and close
312    /// the control channel. The session keeps its id; bring it back with
313    /// [`Sandbox::resume`].
314    ///
315    /// Mirrors TS `handle.pause()` / Python `handle.pause()`: remote call first,
316    /// then the local channel close.
317    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    /// Resume this paused session and re-point the control channel at the fresh
332    /// slot it came back on.
333    ///
334    /// Mirrors TS `handle.resume()` / Python `handle.resume()`: resume, adopt the
335    /// new control URL, reconnect.
336    ///
337    /// Takes `&mut self` where the rest of the handle takes `&self`: resuming
338    /// genuinely replaces this handle's `control_url`, and [`Sandbox::control_url`]
339    /// hands out a borrow of it. Interior mutability would let that borrow go
340    /// stale behind the caller's back — the compiler should be the one saying
341    /// this call mutates the handle.
342    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    /// Destroy the remote session and close the channel. Idempotent.
365    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    // --- namespaces ----------------------------------------------------------
380
381    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    // --- internal accessors (used by the namespace modules) ------------------
395
396    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
407/// `Sandbox` runs `git` through `commands.run("git", …)` (no shell).
408impl 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}