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, StartResponse,
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    /// Legacy, gateway-side FALLBACK idle window in seconds, only used when
52    /// `timeout_ms` is unset — prefer `timeout_ms`.
53    pub ttl_seconds: Option<u64>,
54    pub from_snapshot: Option<String>,
55    /// VM isolation tier: `"shared"` (sandbox-only), `"dedicated"` (default)
56    /// or `"hardened"` (own VM, no outbound network). See `types::CreateSandboxRequest::isolation`.
57    pub isolation: Option<String>,
58    pub lifecycle: Option<Lifecycle>,
59    /// Initial display resolution, e.g. `"1280x720"`. Desktops only — a headless
60    /// sandbox has no display.
61    pub resolution: Option<String>,
62    /// Record the session server-side; the create response carries a presigned
63    /// playback URL. Desktops only.
64    pub record: Option<bool>,
65    /// Persistent volumes to mount before the session starts.
66    pub volumes: Option<Vec<VolumeAttachment>>,
67}
68
69impl CreateOptions {
70    /// Build the `POST /sandboxes` body for a given VM flavour.
71    ///
72    /// THE SINGLE PLACE an option becomes a wire field, and therefore the place
73    /// the cross-language wire contract is pinned (see `tests/contract_wire.rs`).
74    /// A field added above but not mapped here is silently invisible on the wire.
75    pub(crate) fn into_request(self, kind: &str) -> CreateSandboxRequest {
76        CreateSandboxRequest {
77            template: self.template,
78            kind: Some(kind.to_string()),
79            cpu: self.cpu,
80            mem_mb: self.mem_mb,
81            disk_gb: self.disk_gb,
82            envs: self.envs,
83            metadata: self.metadata,
84            timeout_ms: self.timeout_ms,
85            ttl_seconds: self.ttl_seconds,
86            from_snapshot: self.from_snapshot,
87            isolation: self.isolation,
88            lifecycle: self.lifecycle,
89            resolution: self.resolution,
90            record: self.record,
91            volumes: self.volumes,
92        }
93    }
94}
95
96impl From<CreateOptions> for CreateSandboxRequest {
97    fn from(o: CreateOptions) -> Self {
98        o.into_request("sandbox")
99    }
100}
101
102/// Talks the SDK ⇆ Gateway `/sandboxes` REST API and hands back [`Sandbox`]es.
103pub struct Client {
104    http: Arc<HttpTransport>,
105    call_timeout_ms: Option<u64>,
106}
107
108impl Client {
109    pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
110        let http = HttpTransport::new(HttpOptions::new(opts.api_key, opts.base_url))?;
111        Ok(Client {
112            http: Arc::new(http),
113            call_timeout_ms: opts.call_timeout_ms,
114        })
115    }
116
117    /// Create a new headless sandbox (`POST /sandboxes`).
118    pub async fn create(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
119        self.create_with_kind(opts, "sandbox").await
120    }
121
122    /// Create a GUI desktop VM (`POST /sandboxes` with `kind:"desktop"`).
123    ///
124    /// Mirrors TS `createDesktop()` / Python `create_desktop()`. The returned
125    /// handle carries [`Sandbox::stream_url`] for the live view, and exposes the
126    /// same core namespaces as a sandbox (`commands`/`files`/`code`/`git`).
127    /// Driving the GUI (mouse/keyboard/screenshot) is not part of this surface.
128    ///
129    /// Requires the `desktop` entitlement on the org's plan; the gateway
130    /// answers 402 `FeatureRequiresPlan` otherwise.
131    pub async fn create_desktop(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
132        self.create_with_kind(opts, "desktop").await
133    }
134
135    async fn create_with_kind(
136        &self,
137        opts: CreateOptions,
138        kind: &str,
139    ) -> Result<Sandbox, SolariError> {
140        let req = opts.into_request(kind);
141        let body = serde_json::to_value(&req).unwrap();
142        let resp: CreateSandboxResponse = self
143            .http
144            .request("POST", "/sandboxes", Some(body), Some(new_idempotency_key()))
145            .await?;
146        Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
147    }
148
149    /// Re-attach to a running sandbox by id (`GET /sandboxes/:id`).
150    pub async fn connect(&self, sandbox_id: &str) -> Result<Sandbox, SolariError> {
151        // Guard B: the caller supplied this id, so a blank one is their input to
152        // fix -> Validation (distinct from the response-side Other above).
153        if sandbox_id.trim().is_empty() {
154            return Err(SolariError::Validation {
155                message: "Solari: connect() requires a non-blank sandbox id.".to_string(),
156            });
157        }
158        let view = self.get(sandbox_id).await?;
159        let control_url = view.control_url.clone().unwrap_or_else(|| {
160            format!(
161                "{}/control/{}",
162                self.http.ws_origin(),
163                encode_uri_component(sandbox_id)
164            )
165        });
166        // `GET /sandboxes/:id` carries no streamUrl (the gateway's view
167        // serializer omits it), so derive it exactly as control_url is derived
168        // above — both are built from the same origin. Desktops only: a
169        // headless sandbox has no display to stream.
170        let stream_url = if view.kind == "desktop" {
171            Some(format!(
172                "{}/stream/{}",
173                self.http.ws_origin(),
174                encode_uri_component(sandbox_id)
175            ))
176        } else {
177            None
178        };
179        let resp = CreateSandboxResponse {
180            sandbox_id: view.sandbox_id,
181            kind: view.kind,
182            control_url,
183            expires_at: view.expires_at,
184            stream_url,
185        };
186        Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
187    }
188
189    /// Fetch a sandbox's current view (`GET /sandboxes/:id`).
190    pub async fn get(&self, sandbox_id: &str) -> Result<SandboxView, SolariError> {
191        self.http
192            .request(
193                "GET",
194                &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
195                None,
196                None,
197            )
198            .await
199    }
200
201    /// Destroy a sandbox (`DELETE /sandboxes/:id`). Idempotent.
202    pub async fn kill(&self, sandbox_id: &str) -> Result<(), SolariError> {
203        let _: serde_json::Value = self
204            .http
205            .request(
206                "DELETE",
207                &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
208                None,
209                None,
210            )
211            .await?;
212        Ok(())
213    }
214
215    /// Pause a session (`POST /sandboxes/:id/pause`): snapshot its RAM+disk and
216    /// free its host slot. The session keeps its id; bring it back with
217    /// [`Client::resume`].
218    pub async fn pause(&self, sandbox_id: &str) -> Result<(), SolariError> {
219        let _: serde_json::Value = self
220            .http
221            .request(
222                "POST",
223                &format!("/sandboxes/{}/pause", encode_uri_component(sandbox_id)),
224                None,
225                None,
226            )
227            .await?;
228        Ok(())
229    }
230
231    /// Resume a paused session (`POST /sandboxes/:id/resume`) and return the
232    /// control URL to re-attach to.
233    ///
234    /// The session comes back on a FRESH slot, so its pre-pause control URL is
235    /// stale. The gateway normally returns the new one; when it does not, derive
236    /// it from the gateway origin exactly as [`Client::connect`] does.
237    pub async fn resume(&self, sandbox_id: &str) -> Result<String, SolariError> {
238        let r: ResumeResponse = self
239            .http
240            .request(
241                "POST",
242                &format!("/sandboxes/{}/resume", encode_uri_component(sandbox_id)),
243                None,
244                None,
245            )
246            .await?;
247        Ok(r.control_url.unwrap_or_else(|| {
248            format!(
249                "{}/control/{}",
250                self.http.ws_origin(),
251                encode_uri_component(sandbox_id)
252            )
253        }))
254    }
255
256    /// Cold-boot a STOPPED sandbox (`POST /sandboxes/:id/start`) from the disk the
257    /// host kept, and return the fresh control URL to re-attach to.
258    ///
259    /// A sandbox is stopped when its guest powered itself off. This is a cold
260    /// boot, not a resume: running processes and memory are gone, files on disk
261    /// are kept. It takes a concurrency slot (429 at the cap) and opens a new
262    /// billing segment. Idempotent: an already-running sandbox answers 200 and its
263    /// current control URL.
264    ///
265    /// Any other state is refused with `SolariError::Gateway { status: 409, code }`
266    /// where `code` is `NotStopped` (use [`Client::resume`] for a paused one) or
267    /// `StartInProgress`. A stopped disk that cannot be started answers 409 with
268    /// its code (`StoppedDiskMissing`, `StoppedDiskUnsafe`, `GoldenMismatch`,
269    /// `DiskPlatformMismatch`, `DiskShaMismatch`, `DiskExceedsSpec`,
270    /// `DiskOwnerMismatch`). 409 `HostCapabilityMissing` means the host pool is not
271    /// armed for start (not retryable). 503 `NoCapacity` / `StartTimeout` is
272    /// `SolariError::NoCapacity` and 502 `StartHostError` is a gateway error, both
273    /// retryable; 500 `StartFailed` is not, and leaves the sandbox stopped with its
274    /// disk, so a later start is safe.
275    pub async fn start(&self, sandbox_id: &str) -> Result<String, SolariError> {
276        let r: StartResponse = self
277            .http
278            .request(
279                "POST",
280                &format!("/sandboxes/{}/start", encode_uri_component(sandbox_id)),
281                None,
282                None,
283            )
284            .await?;
285        Ok(r.control_url.unwrap_or_else(|| {
286            format!(
287                "{}/control/{}",
288                self.http.ws_origin(),
289                encode_uri_component(sandbox_id)
290            )
291        }))
292    }
293}
294
295/// A live sandbox handle. Exposes the core namespaces.
296pub struct Sandbox {
297    id: String,
298    kind: String,
299    control_url: String,
300    stream_url: Option<String>,
301    expires_at: String,
302    http: Arc<HttpTransport>,
303    channel: Arc<ControlChannel>,
304}
305
306impl Sandbox {
307    pub(crate) fn from_response(
308        http: Arc<HttpTransport>,
309        resp: CreateSandboxResponse,
310        call_timeout_ms: Option<u64>,
311    ) -> Result<Self, SolariError> {
312        // A blank id in the response builds a nearly-right "/sandboxes//exec"
313        // (double slash), not an obviously wrong path, so it slips through while
314        // every call the handle makes is malformed. A blank id in a RESPONSE is
315        // a bad reply from our side, not caller input -> Other. (Test blank.)
316        if resp.sandbox_id.trim().is_empty() {
317            return Err(SolariError::Other(
318                "Solari: the session response carried a blank id - a blank id builds a malformed /sandboxes// request"
319                    .to_string(),
320            ));
321        }
322        let channel = ControlChannel::new(
323            resp.control_url.clone(),
324            http.auth_headers(),
325            call_timeout_ms,
326        );
327        Ok(Sandbox {
328            id: resp.sandbox_id,
329            kind: resp.kind,
330            control_url: resp.control_url,
331            stream_url: resp.stream_url,
332            expires_at: resp.expires_at,
333            http,
334            channel: Arc::new(channel),
335        })
336    }
337
338    /// The session id.
339    pub fn id(&self) -> &str {
340        &self.id
341    }
342    /// The VM flavour — `"sandbox"` or `"desktop"`.
343    pub fn kind(&self) -> &str {
344        &self.kind
345    }
346    /// The control-WS URL.
347    pub fn control_url(&self) -> &str {
348        &self.control_url
349    }
350    /// The live-view stream URL. `Some` for desktops only — a headless sandbox
351    /// has no display, so the gateway omits it.
352    pub fn stream_url(&self) -> Option<&str> {
353        self.stream_url.as_deref()
354    }
355    /// ISO-8601 expiry timestamp.
356    pub fn expires_at(&self) -> &str {
357        &self.expires_at
358    }
359    /// Whether the control channel is open.
360    pub fn connected(&self) -> bool {
361        self.channel.connected()
362    }
363
364    /// Open the control WebSocket. Idempotent.
365    pub async fn connect(&self) -> Result<(), SolariError> {
366        self.channel.connect().await
367    }
368
369    /// Close the control channel locally (does NOT release the remote session).
370    pub fn close(&self) {
371        self.channel.close();
372    }
373
374    /// Pause this session: snapshot its RAM+disk, free its host slot, and close
375    /// the control channel. The session keeps its id; bring it back with
376    /// [`Sandbox::resume`].
377    ///
378    /// Mirrors TS `handle.pause()` / Python `handle.pause()`: remote call first,
379    /// then the local channel close.
380    pub async fn pause(&self) -> Result<(), SolariError> {
381        let _: serde_json::Value = self
382            .http
383            .request(
384                "POST",
385                &format!("/sandboxes/{}/pause", encode_uri_component(&self.id)),
386                None,
387                None,
388            )
389            .await?;
390        self.close();
391        Ok(())
392    }
393
394    /// Resume this paused session and re-point the control channel at the fresh
395    /// slot it came back on.
396    ///
397    /// Mirrors TS `handle.resume()` / Python `handle.resume()`: resume, adopt the
398    /// new control URL, reconnect.
399    ///
400    /// Takes `&mut self` where the rest of the handle takes `&self`: resuming
401    /// genuinely replaces this handle's `control_url`, and [`Sandbox::control_url`]
402    /// hands out a borrow of it. Interior mutability would let that borrow go
403    /// stale behind the caller's back — the compiler should be the one saying
404    /// this call mutates the handle.
405    pub async fn resume(&mut self) -> Result<(), SolariError> {
406        let r: ResumeResponse = self
407            .http
408            .request(
409                "POST",
410                &format!("/sandboxes/{}/resume", encode_uri_component(&self.id)),
411                None,
412                None,
413            )
414            .await?;
415        let control_url = r.control_url.unwrap_or_else(|| {
416            format!(
417                "{}/control/{}",
418                self.http.ws_origin(),
419                encode_uri_component(&self.id)
420            )
421        });
422        self.control_url = control_url.clone();
423        self.channel.set_control_url(control_url);
424        self.channel.reconnect().await
425    }
426
427    /// Cold-boot this STOPPED session from its kept disk and re-point the control
428    /// channel at the fresh control URL. Processes and memory are gone, files on
429    /// disk are kept. See [`Client::start`] for the refusal codes.
430    ///
431    /// Takes `&mut self` for the same reason as [`Sandbox::resume`]: it replaces
432    /// this handle's `control_url`.
433    pub async fn start(&mut self) -> Result<(), SolariError> {
434        let r: StartResponse = self
435            .http
436            .request(
437                "POST",
438                &format!("/sandboxes/{}/start", encode_uri_component(&self.id)),
439                None,
440                None,
441            )
442            .await?;
443        let control_url = r.control_url.unwrap_or_else(|| {
444            format!(
445                "{}/control/{}",
446                self.http.ws_origin(),
447                encode_uri_component(&self.id)
448            )
449        });
450        self.control_url = control_url.clone();
451        self.channel.set_control_url(control_url);
452        self.channel.reconnect().await
453    }
454
455    /// Destroy the remote session and close the channel. Idempotent.
456    pub async fn kill(&self) -> Result<(), SolariError> {
457        let _: serde_json::Value = self
458            .http
459            .request(
460                "DELETE",
461                &format!("/sandboxes/{}", encode_uri_component(&self.id)),
462                None,
463                None,
464            )
465            .await?;
466        self.close();
467        Ok(())
468    }
469
470    // --- namespaces ----------------------------------------------------------
471
472    pub fn commands(&self) -> Commands<'_> {
473        Commands { sb: self }
474    }
475    pub fn files(&self) -> Files<'_> {
476        Files { sb: self }
477    }
478    pub fn code(&self) -> Code<'_> {
479        Code { sb: self }
480    }
481    pub fn git(&self) -> Git<'_> {
482        Git::new(self)
483    }
484
485    // --- internal accessors (used by the namespace modules) ------------------
486
487    pub(crate) fn channel(&self) -> &ControlChannel {
488        &self.channel
489    }
490    pub(crate) fn channel_arc(&self) -> Arc<ControlChannel> {
491        self.channel.clone()
492    }
493    pub(crate) fn http(&self) -> &HttpTransport {
494        &self.http
495    }
496}
497
498/// `Sandbox` runs `git` through `commands.run("git", …)` (no shell).
499impl GitRunner for Sandbox {
500    fn run_git<'a>(
501        &'a self,
502        args: Vec<String>,
503        cwd: Option<String>,
504    ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
505        Box::pin(async move {
506            let opts = RunOptions {
507                args: Some(args),
508                cwd,
509                ..Default::default()
510            };
511            run_command(self, "git", &opts).await
512        })
513    }
514}