Skip to main content

sail/
client.rs

1//! The Sail client: the canonical async surface that owns configuration and
2//! transport, shared by the Python and TypeScript SDKs and the CLI.
3//!
4//! [`Client`] is a cheap-to-clone handle (`Arc` inside, like `reqwest::Client`):
5//! clone it freely to share the connection pools and config. Construct it with
6//! [`Client::from_env`] or [`Client::builder`].
7//!
8//! Every method is `async`. Synchronous callers (the Python SDK, the CLI)
9//! drive these futures with [`crate::block_on`]; an async host awaits them
10//! directly.
11//!
12//! ```no_run
13//! # async fn run() -> Result<(), sail::error::SailError> {
14//! use sail::Client;
15//!
16//! // From the environment (SAIL_API_KEY):
17//! let client = Client::from_env()?;
18//! let page = client.list_sailboxes(&Default::default()).await?;
19//! println!("{} Sailboxes", page.items.len());
20//!
21//! // Or build one explicitly:
22//! let client = Client::builder("sk_...").build()?;
23//! let app = client.find_app("my-app", /* mint_if_missing */ true).await?;
24//! # let _ = (client, app);
25//! # Ok(())
26//! # }
27//! ```
28
29use std::sync::Arc;
30use std::time::Duration;
31use time::OffsetDateTime;
32
33use crate::app::{self, App};
34use crate::config::Config;
35use crate::credential::api::CredentialApi;
36use crate::credential::types::{
37    CredentialInjectionPolicyInfo, CredentialInjectionPolicyPage, InjectionRule,
38    ListCredentialInjectionPoliciesQuery, SecretInfo,
39};
40use crate::error::{RpcStatus, SailError};
41use crate::exec::{ExecOptions, ExecParams, ExecProcess, ExecResult, OutputStream};
42use crate::http::HttpCore;
43use crate::imagebuilder::ImageBuilder;
44use crate::sailbox::api::{SailboxApi, UpgradeResult};
45use crate::sailbox::fs::{DirEntry, EntryType};
46use crate::sailbox::object::Sailbox;
47use crate::sailbox::types::{
48    CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
49    SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
50    SailboxSpendResponse, VolumeInfo, WhoAmI,
51};
52use crate::worker::{
53    is_transient_transport_message, FileReader, FileWriter, Listener, WorkerProxy, WriteOptions,
54};
55
56/// A configured Sail client. Cheap to clone; shares transport across clones.
57#[derive(Clone)]
58pub struct Client {
59    inner: Arc<Inner>,
60}
61
62impl std::fmt::Debug for Client {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("Client")
65            .field("config", &self.inner.config)
66            .finish_non_exhaustive()
67    }
68}
69
70struct Inner {
71    config: Config,
72    /// Sailbox-API host: lifecycle, list/get, listeners, volume.
73    sailbox_http: HttpCore,
74    /// Central public-API host: app find, inference, voyages.
75    api_http: HttpCore,
76    /// Per-sailbox worker proxy: exec, files, listener reads. Its own `Arc` so
77    /// the streaming file/exec methods (which take `&Arc<Self>`) can share it.
78    worker: Arc<WorkerProxy>,
79    imagebuilder: ImageBuilder,
80    /// Successful image-readiness builds, shared by every clone of this
81    /// client (see [`crate::imagecache`]).
82    image_ready: crate::imagecache::ImageReadyCache,
83}
84
85/// Maximum time spent probing a create/resume routing hint before resolving
86/// current placement. The relaunch reuses the idempotency key if this expires.
87const HINTED_EXEC_ENDPOINT_PROBE_TIMEOUT: Duration = Duration::from_secs(1);
88
89/// Builds a [`Client`] from explicit values, falling back to the default
90/// endpoints. Prefer [`Client::from_env`] for the common env-driven case.
91///
92/// `Debug` redacts the API key, so a logged builder never leaks the
93/// credential.
94#[derive(Default, Clone)]
95pub struct ClientBuilder {
96    mode: Option<String>,
97    api_key: Option<String>,
98    api_url: Option<String>,
99    sailbox_api_url: Option<String>,
100    imagebuilder_url: Option<String>,
101    ingress_url: Option<String>,
102    client_label: Option<String>,
103}
104
105impl std::fmt::Debug for ClientBuilder {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("ClientBuilder")
108            .field(
109                "api_key",
110                &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
111            )
112            .field("mode", &self.mode)
113            .field("api_url", &self.api_url)
114            .field("sailbox_api_url", &self.sailbox_api_url)
115            .field("imagebuilder_url", &self.imagebuilder_url)
116            .field("ingress_url", &self.ingress_url)
117            .field("client_label", &self.client_label)
118            .finish()
119    }
120}
121
122impl ClientBuilder {
123    /// A builder with the given API key; unset endpoints use the Sail
124    /// defaults.
125    pub fn new(api_key: impl Into<String>) -> ClientBuilder {
126        ClientBuilder {
127            api_key: Some(api_key.into()),
128            ..ClientBuilder::default()
129        }
130    }
131
132    /// Select the named environment (`prod`/`dev`/`staging`/`local`), which
133    /// picks the endpoint defaults. Unset means prod.
134    #[doc(hidden)]
135    pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
136        self.mode = Some(mode.into());
137        self
138    }
139
140    /// Override the Sail API URL.
141    pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
142        self.api_url = Some(api_url.into());
143        self
144    }
145
146    /// Override the sailbox-API URL.
147    pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
148        self.sailbox_api_url = Some(url.into());
149        self
150    }
151
152    /// Override the image-build endpoint (`host:port`).
153    pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
154        self.imagebuilder_url = Some(url.into());
155        self
156    }
157
158    /// Override the listener ingress base URL (what `SAILBOX_INGRESS_URL`
159    /// sets from the environment), for custom or self-hosted Sailbox stacks.
160    pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
161        self.ingress_url = Some(url.into());
162        self
163    }
164
165    /// Identify the first-party binding using the shared transport.
166    #[doc(hidden)]
167    pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
168        self.client_label = Some(label.into());
169        self
170    }
171
172    /// Build the client, resolving any unset endpoint from the defaults.
173    pub fn build(self) -> Result<Client, SailError> {
174        let api_key = self.api_key.unwrap_or_default();
175        let config = Config::resolve(
176            self.mode.as_deref(),
177            api_key,
178            self.api_url,
179            self.sailbox_api_url,
180            self.imagebuilder_url,
181            self.ingress_url,
182        )?;
183        Client::from_config_with_label(
184            config,
185            self.client_label
186                .as_deref()
187                .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
188        )
189    }
190}
191
192/// Bound on the transparent image rebuild inside a create retry when the
193/// request carries no image-build timeout; matches the default build budget
194/// the SDK wrappers document.
195const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
196
197/// The scheduler's create rejection for an image it cannot resolve as ready.
198/// CreateSailbox in backend/internal/sailbox/scheduler stamps the
199/// "resolve image:" prefix on that arm; keep them in sync.
200fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
201    matches!(
202        result,
203        Err(SailError::Creation {
204            status: 409,
205            message,
206            ..
207        }) if message.starts_with("resolve image:")
208    )
209}
210
211impl Client {
212    /// Start a [`ClientBuilder`].
213    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
214        ClientBuilder::new(api_key)
215    }
216
217    /// Build a client from the environment (`SAIL_API_KEY`, …).
218    pub fn from_env() -> Result<Client, SailError> {
219        Client::from_config(Config::from_env()?)
220    }
221
222    /// Build a client from the environment and identify a first-party binding.
223    #[doc(hidden)]
224    pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
225        Client::from_config_with_label(Config::from_env()?, label)
226    }
227
228    /// Build a client from an already-resolved [`Config`].
229    pub fn from_config(config: Config) -> Result<Client, SailError> {
230        Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
231    }
232
233    fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
234        let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
235            .with_client_label(client_label);
236        let api_http =
237            HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
238        let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
239        let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
240        Ok(Client {
241            inner: Arc::new(Inner {
242                config,
243                sailbox_http,
244                api_http,
245                worker,
246                imagebuilder,
247                image_ready: crate::imagecache::ImageReadyCache::new(),
248            }),
249        })
250    }
251
252    pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
253        &self.inner.image_ready
254    }
255
256    /// Test hook: shrink the window after which a cached successful image
257    /// build is re-verified with the server. Compiled only for tests (this
258    /// crate's own and, under `test-fakes`, the integration crate), so it
259    /// never widens the published API.
260    #[cfg(any(test, feature = "test-fakes"))]
261    pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
262        self.inner.image_ready.set_refresh_window(window);
263    }
264
265    /// The resolved configuration.
266    pub fn config(&self) -> &Config {
267        &self.inner.config
268    }
269
270    /// The worker proxy for exec, file copy, and listener reads.
271    #[doc(hidden)]
272    pub fn worker(&self) -> Arc<WorkerProxy> {
273        Arc::clone(&self.inner.worker)
274    }
275
276    /// The imagebuilder dispatcher client.
277    #[doc(hidden)]
278    pub fn imagebuilder(&self) -> &ImageBuilder {
279        &self.inner.imagebuilder
280    }
281
282    /// The sailbox-API HTTP host (for binding-built requests).
283    #[doc(hidden)]
284    pub fn sailbox_http(&self) -> &HttpCore {
285        &self.inner.sailbox_http
286    }
287
288    /// The central public-API HTTP host (for binding-built requests).
289    #[doc(hidden)]
290    pub fn api_http(&self) -> &HttpCore {
291        &self.inner.api_http
292    }
293
294    fn sailbox_api(&self) -> SailboxApi<'_> {
295        SailboxApi::new(&self.inner.sailbox_http)
296    }
297
298    /// Send a create; when the scheduler rejects it because the image is not
299    /// ready even though readiness was cached, rebuild once and retry. A
300    /// backend deploy can change the canonical image identity behind the same
301    /// spec, so a cached "ready" can be stale until the refresh window. The
302    /// scheduler resolves the image before it creates any row, so nothing
303    /// exists server-side and the retried create is safe. Unrelated create
304    /// conflicts (name, idempotency) pass through untouched.
305    async fn create_with_image_revalidation(
306        &self,
307        req: &CreateSailboxRequest,
308        timeout: Option<Duration>,
309    ) -> Result<SailboxHandle, SailError> {
310        let create_started = std::time::Instant::now();
311        let result = self.sailbox_api().create(req, timeout).await;
312        let custom_image = req.image != crate::image::ImageSpec::default()
313            && !crate::imagebuild::is_builtin_base_spec(&req.image);
314        if !custom_image || !image_not_ready_conflict(&result) {
315            return result;
316        }
317        if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
318            // Drop every entry whose build started before this create began:
319            // those may carry the identity the server just rejected. A build
320            // started after conflict discovery is another stale caller's
321            // recovery, joined below rather than clobbered.
322            self.image_ready_cache()
323                .invalidate_spec_started_before(&spec_hash, create_started);
324        }
325        // The hard envelope means joining another caller's in-flight rebuild
326        // cannot outlive this caller's budget; the recovery marking keeps the
327        // rebuild joinable through later stale creates' invalidations.
328        let rebuild_timeout = req
329            .image_build_timeout
330            .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
331        let rebuild =
332            self.build_spec_ready_cached(&req.image, rebuild_timeout, /* recovery */ true);
333        tokio::time::timeout(rebuild_timeout, rebuild)
334            .await
335            .unwrap_or_else(|_| {
336                Err(SailError::Transport {
337                    kind: crate::error::TransportKind::Timeout,
338                    message: "timed out building the image".to_string(),
339                    source: None,
340                })
341            })?;
342        self.sailbox_api().create(req, timeout).await
343    }
344
345    // --- sailbox lifecycle ---
346
347    /// Create a Sailbox. `timeout` bounds each attempt of the synchronous
348    /// create (which can take minutes server-side); the call retries
349    /// with one idempotency key so the backend can dedupe rather than
350    /// duplicate, and gives up after roughly `max_attempts * timeout`.
351    /// An interrupted or re-invoked create is a new request and may leave a
352    /// prior Sailbox behind under the same name. 10 minutes is a good default;
353    /// `None` leaves each attempt unbounded. If the budget is exhausted the
354    /// Sailbox may still be coming up server-side; find or terminate it by
355    /// `name`.
356    pub async fn create_sailbox(
357        &self,
358        req: &CreateSailboxRequest,
359        timeout: Option<Duration>,
360    ) -> Result<Sailbox, SailError> {
361        let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
362        if !req.ssh {
363            return self
364                .create_with_image_revalidation(req, timeout)
365                .await
366                .map(bind);
367        }
368        // Validate the full request now, port-22 entries included: they are
369        // stripped below (their allowlist applies at the enable_ssh expose),
370        // so create's own validation never sees them, and an invalid entry
371        // must fail here rather than after the VM exists.
372        crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
373        // SSH setup is org-scoped: preflight the org CA (created on first use)
374        // so a CA outage fails before the VM exists.
375        self.org_ssh_ca_public_key().await?;
376        // Port 22 belongs to enable_ssh, which exposes it only after verifying
377        // the CA-only sshd owns it (never the create request), so a failed
378        // setup can't leave port 22 exposed. An explicit port-22 entry
379        // contributes just its allowlist, applied at that expose.
380        let mut req = req.clone();
381        let ssh_allowlist = req
382            .ingress_ports
383            .iter()
384            .find(|port| port.guest_port == 22)
385            .map(|port| port.allowlist.clone())
386            .unwrap_or_default();
387        req.ingress_ports.retain(|port| port.guest_port != 22);
388        let handle = self.create_with_image_revalidation(&req, timeout).await?;
389        let handle_id = handle.sailbox_id.clone();
390        // The VM is already up, so skip the readiness probe (wait: false).
391        if let Err(err) = self
392            .enable_ssh(
393                &handle_id,
394                &ssh_allowlist,
395                /* wait */ false,
396                Duration::ZERO,
397            )
398            .await
399        {
400            // The sailbox exists; surface its id so the caller can fetch it to
401            // retry enable_ssh or terminate it.
402            return Err(SailError::Creation {
403                message: format!(
404                    "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
405                     id to retry enable_ssh or terminate it."
406                ),
407                status: 0,
408                body: serde_json::Value::Null,
409            });
410        }
411        Ok(bind(handle))
412    }
413
414    /// Fetch a single Sailbox.
415    #[doc(hidden)]
416    pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
417        self.sailbox_api().get(sailbox_id).await
418    }
419
420    /// Fetch the identity (org, and user when user-scoped) behind the API key.
421    #[doc(hidden)]
422    pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
423        self.sailbox_api().whoami().await
424    }
425
426    /// List Sailboxes in the current org.
427    pub async fn list_sailboxes(
428        &self,
429        query: &ListSailboxesQuery,
430    ) -> Result<SailboxPage, SailError> {
431        self.sailbox_api().list(query).await
432    }
433
434    /// Estimate Sailbox spend for the current organization over a time window.
435    pub async fn sailbox_spend(
436        &self,
437        query: &SailboxSpendQuery,
438    ) -> Result<SailboxSpendResponse, SailError> {
439        self.sailbox_api().spend(query).await
440    }
441
442    /// Fetch a Sailbox's resource-usage time series.
443    pub async fn sailbox_metrics(
444        &self,
445        sailbox_id: &str,
446        query: &SailboxMetricsQuery,
447    ) -> Result<SailboxMetricsResponse, SailError> {
448        self.sailbox_api().metrics(sailbox_id, query).await
449    }
450
451    /// Terminate a Sailbox (idempotent).
452    #[doc(hidden)]
453    pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
454        self.sailbox_api().terminate(sailbox_id).await
455    }
456
457    /// Pause a Sailbox.
458    #[doc(hidden)]
459    pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
460        self.sailbox_api().pause(sailbox_id).await
461    }
462
463    /// Sleep a Sailbox, optionally scheduling a wall-clock wake first.
464    #[doc(hidden)]
465    pub async fn sleep_sailbox(
466        &self,
467        sailbox_id: &str,
468        wake_at: Option<OffsetDateTime>,
469    ) -> Result<Option<OffsetDateTime>, SailError> {
470        self.sailbox_api().sleep(sailbox_id, wake_at).await
471    }
472
473    /// Resume a paused/sleeping Sailbox.
474    #[doc(hidden)]
475    pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
476        self.sailbox_api().resume(sailbox_id).await
477    }
478
479    /// Checkpoint a running Sailbox.
480    #[doc(hidden)]
481    pub async fn checkpoint_sailbox(
482        &self,
483        sailbox_id: &str,
484        name: Option<&str>,
485        ttl_seconds: Option<i64>,
486    ) -> Result<SailboxCheckpoint, SailError> {
487        self.sailbox_api()
488            .checkpoint(sailbox_id, name, ttl_seconds)
489            .await
490    }
491
492    /// Fork a Sailbox into a new child in one call. Id-form of
493    /// [`Sailbox::fork`](crate::Sailbox::fork), which documents the contract.
494    #[doc(hidden)]
495    pub async fn fork_sailbox(
496        &self,
497        sailbox_id: &str,
498        name: Option<&str>,
499        timeout: Option<Duration>,
500    ) -> Result<Sailbox, SailError> {
501        self.sailbox_api()
502            .fork(sailbox_id, name, timeout.map(duration_to_whole_seconds))
503            .await
504            .map(|handle| Sailbox::bind(self.clone(), handle))
505    }
506
507    /// Create a new running Sailbox from a durable checkpoint handle. The new
508    /// Sailbox restores the memory saved in the checkpoint as well as the
509    /// writable disk, so processes the original was running carry on here, and
510    /// it runs independently of the Sailbox that took the checkpoint. Commands
511    /// started with [`Sailbox::exec`] stop here, though their writes up to the
512    /// checkpoint are kept, and one started with `background` keeps running.
513    /// Start the other execs the new Sailbox needs. Sometimes it comes up
514    /// cold instead, with the disk intact and nothing running, and a
515    /// Sailbox that mounts a volume always does. Volumes are mounted on it at
516    /// the same paths as on the original, and they are the same volumes, so
517    /// both Sailboxes read and write the same files.
518    ///
519    /// `name` sets the new Sailbox's display name, and the server derives one
520    /// when it is omitted. `timeout` is accepted and ignored: the call blocks
521    /// until the restore finishes, so apply your own deadline if you need one.
522    /// It must be positive when given.
523    pub async fn create_from_checkpoint(
524        &self,
525        checkpoint_id: &str,
526        name: Option<&str>,
527        timeout: Option<Duration>,
528    ) -> Result<Sailbox, SailError> {
529        self.sailbox_api()
530            .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
531            .await
532            .map(|handle| Sailbox::bind(self.clone(), handle))
533    }
534
535    /// Upgrade the Sailbox runtime (applies now if running, else at next wake).
536    #[doc(hidden)]
537    pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
538        self.sailbox_api().upgrade(sailbox_id).await
539    }
540
541    /// Expose a guest port at runtime; returns the add-listener response.
542    /// Re-exposing a port under the same protocol sets its allowlist to what
543    /// you pass, so pass the whole list every time; an empty one clears the
544    /// restriction and reopens the port.
545    #[doc(hidden)]
546    pub async fn expose_listener(
547        &self,
548        sailbox_id: &str,
549        guest_port: u32,
550        protocol: crate::sailbox::types::IngressProtocol,
551        allowlist: &[String],
552    ) -> Result<Listener, SailError> {
553        let mut response = self
554            .sailbox_api()
555            .expose(sailbox_id, guest_port, protocol, allowlist)
556            .await?;
557        self.fill_listener_url(sailbox_id, &mut response);
558        Ok(response)
559    }
560
561    /// Remove a runtime ingress port.
562    #[doc(hidden)]
563    pub async fn unexpose_listener(
564        &self,
565        sailbox_id: &str,
566        guest_port: u32,
567    ) -> Result<(), SailError> {
568        self.sailbox_api().unexpose(sailbox_id, guest_port).await
569    }
570
571    /// List a Sailbox's ingress listeners without resuming (waking) the Sailbox.
572    #[doc(hidden)]
573    pub async fn list_listeners(
574        &self,
575        sailbox_id: &str,
576    ) -> Result<Vec<crate::worker::Listener>, SailError> {
577        let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
578        for listener in &mut listeners {
579            self.fill_listener_url(sailbox_id, listener);
580        }
581        Ok(listeners)
582    }
583
584    /// Fetch one ingress listener by guest port without resuming (waking) the
585    /// Sailbox; a missing port is a [`SailError::NotFound`].
586    #[doc(hidden)]
587    pub async fn get_listener(
588        &self,
589        sailbox_id: &str,
590        guest_port: u32,
591    ) -> Result<crate::worker::Listener, SailError> {
592        let mut listener = self
593            .sailbox_api()
594            .get_listener(sailbox_id, guest_port)
595            .await?;
596        self.fill_listener_url(sailbox_id, &mut listener);
597        Ok(listener)
598    }
599
600    /// Fetch the current organization's custom-domain CNAME target.
601    #[doc(hidden)]
602    pub async fn custom_domain_dns_target(&self) -> Result<String, SailError> {
603        self.sailbox_api().custom_domain_dns_target().await
604    }
605
606    /// Attach a custom domain to a Sailbox HTTP listener.
607    #[doc(hidden)]
608    pub async fn attach_custom_domain(
609        &self,
610        sailbox_id: &str,
611        domain: &str,
612        guest_port: u32,
613    ) -> Result<crate::sailbox::types::CustomDomainInfo, SailError> {
614        self.sailbox_api()
615            .attach_custom_domain(sailbox_id, domain, guest_port)
616            .await
617    }
618
619    /// List the custom domains attached to a Sailbox.
620    #[doc(hidden)]
621    pub async fn list_custom_domains(
622        &self,
623        sailbox_id: &str,
624    ) -> Result<Vec<crate::sailbox::types::CustomDomainInfo>, SailError> {
625        self.sailbox_api().list_custom_domains(sailbox_id).await
626    }
627
628    /// Detach a custom domain from a Sailbox.
629    #[doc(hidden)]
630    pub async fn detach_custom_domain(
631        &self,
632        sailbox_id: &str,
633        domain: &str,
634    ) -> Result<(), SailError> {
635        self.sailbox_api()
636            .detach_custom_domain(sailbox_id, domain)
637            .await
638    }
639
640    /// Fill an empty `public_url` on a non-TCP listener with the URL
641    /// synthesized from this client's ingress config (the server leaves
642    /// listener URLs empty in local/path mode).
643    fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
644        if listener.public_url.is_empty()
645            && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
646        {
647            listener.public_url = crate::sailbox::listeners::synthesized_public_url(
648                self.config(),
649                sailbox_id,
650                listener.guest_port,
651            );
652        }
653    }
654
655    /// Ingress-identity headers for this Sailbox.
656    #[doc(hidden)]
657    pub async fn ingress_auth_headers(
658        &self,
659        sailbox_id: &str,
660    ) -> Result<Vec<(String, String)>, SailError> {
661        self.sailbox_api().ingress_auth_headers(sailbox_id).await
662    }
663
664    /// The caller org's SSH CA public key (created on first use).
665    pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
666        self.sailbox_api().org_ssh_ca_public_key().await
667    }
668
669    /// Sign `public_key` into a short-lived org-CA certificate (principal
670    /// `root`). `timeout` (seconds) bounds a single no-retry attempt; `None`
671    /// retries.
672    pub async fn issue_user_cert(
673        &self,
674        public_key: &str,
675        timeout: Option<Duration>,
676    ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
677        self.sailbox_api()
678            .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
679            .await
680    }
681
682    // --- NFS volumes ---
683
684    /// Look up (optionally minting) an NFS volume by name.
685    pub async fn get_volume(
686        &self,
687        name: &str,
688        mint_if_missing: bool,
689    ) -> Result<VolumeInfo, SailError> {
690        self.sailbox_api().get_volume(name, mint_if_missing).await
691    }
692
693    /// List NFS volumes in the current org.
694    pub async fn list_volumes(
695        &self,
696        max_objects: Option<i64>,
697    ) -> Result<Vec<VolumeInfo>, SailError> {
698        self.sailbox_api().list_volumes(max_objects).await
699    }
700
701    /// Delete a volume by id.
702    pub async fn delete_volume(
703        &self,
704        volume_id: &str,
705        allow_missing: bool,
706    ) -> Result<Option<VolumeInfo>, SailError> {
707        self.sailbox_api()
708            .delete_volume(volume_id, allow_missing)
709            .await
710    }
711
712    // --- secrets and credential injection policies ---
713    //
714    // Id-forms of the surface documented on [`crate::Credentials`],
715    // [`crate::Secret`], and [`crate::CredentialInjectionPolicy`]; the bound
716    // objects delegate here, and the language bridges call these directly.
717
718    fn credential_api(&self) -> CredentialApi<'_> {
719        CredentialApi::new(&self.inner.sailbox_http)
720    }
721
722    /// Set (create or update) a secret's value; returns its metadata.
723    #[doc(hidden)]
724    pub async fn set_secret(&self, name: &str, value: &str) -> Result<SecretInfo, SailError> {
725        self.credential_api().set_secret(name, value).await
726    }
727
728    /// Fetch one secret's metadata (never the value).
729    #[doc(hidden)]
730    pub async fn get_secret(&self, name: &str) -> Result<SecretInfo, SailError> {
731        self.credential_api().get_secret(name).await
732    }
733
734    /// List the org's secrets, metadata only.
735    #[doc(hidden)]
736    pub async fn list_secrets(&self) -> Result<Vec<SecretInfo>, SailError> {
737        self.credential_api().list_secrets().await
738    }
739
740    /// Delete a secret by name.
741    #[doc(hidden)]
742    pub async fn delete_secret(&self, name: &str) -> Result<(), SailError> {
743        self.credential_api().delete_secret(name).await
744    }
745
746    /// Create a credential injection policy.
747    #[doc(hidden)]
748    pub async fn create_credential_policy(
749        &self,
750        name: &str,
751        rules: &[InjectionRule],
752    ) -> Result<CredentialInjectionPolicyInfo, SailError> {
753        self.credential_api().create_policy(name, rules).await
754    }
755
756    /// Fetch one credential injection policy by id.
757    #[doc(hidden)]
758    pub async fn get_credential_policy(
759        &self,
760        policy_id: &str,
761    ) -> Result<CredentialInjectionPolicyInfo, SailError> {
762        self.credential_api().get_policy(policy_id).await
763    }
764
765    /// List credential injection policies.
766    #[doc(hidden)]
767    pub async fn list_credential_policies(
768        &self,
769        query: &ListCredentialInjectionPoliciesQuery,
770    ) -> Result<CredentialInjectionPolicyPage, SailError> {
771        self.credential_api().list_policies(query).await
772    }
773
774    /// Rename a credential injection policy.
775    #[doc(hidden)]
776    pub async fn rename_credential_policy(
777        &self,
778        policy_id: &str,
779        name: &str,
780    ) -> Result<CredentialInjectionPolicyInfo, SailError> {
781        self.credential_api().rename_policy(policy_id, name).await
782    }
783
784    /// Delete a credential injection policy by id.
785    #[doc(hidden)]
786    pub async fn delete_credential_policy(&self, policy_id: &str) -> Result<(), SailError> {
787        self.credential_api().delete_policy(policy_id).await
788    }
789
790    /// The policy attached to a Sailbox, or `None`.
791    #[doc(hidden)]
792    pub async fn sailbox_credential_policy(
793        &self,
794        sailbox_id: &str,
795    ) -> Result<Option<CredentialInjectionPolicyInfo>, SailError> {
796        self.credential_api().sailbox_policy(sailbox_id).await
797    }
798
799    /// Attach a policy to a Sailbox, replacing any previous one.
800    #[doc(hidden)]
801    pub async fn set_sailbox_credential_policy(
802        &self,
803        sailbox_id: &str,
804        policy_id: &str,
805    ) -> Result<(), SailError> {
806        self.credential_api()
807            .attach_sailbox_policy(sailbox_id, policy_id)
808            .await
809    }
810
811    /// Detach a Sailbox's credential policy (idempotent).
812    #[doc(hidden)]
813    pub async fn clear_sailbox_credential_policy(&self, sailbox_id: &str) -> Result<(), SailError> {
814        self.credential_api()
815            .detach_sailbox_policy(sailbox_id)
816            .await
817    }
818
819    // --- apps (central API) ---
820
821    /// Find an app by name, optionally minting it.
822    pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
823        app::find_app(&self.inner.api_http, name, mint_if_missing).await
824    }
825
826    /// Every app the current org owns, newest first.
827    pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
828        app::list_apps(&self.inner.api_http).await
829    }
830
831    // --- exec and files (per-sailbox worker proxy) ---
832
833    /// Resolve a Sailbox's current worker-proxy endpoint.
834    ///
835    /// `resume` wakes a paused/sleeping Sailbox and returns its *current*
836    /// endpoint, which is the host worker's address and changes when the Sailbox
837    /// migrates (e.g. after preemption). The GET Sailbox API omits this routing
838    /// field, so resuming is the only way to learn it, and resolving it fresh per
839    /// call avoids ever dialing a stale worker.
840    #[doc(hidden)]
841    pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
842        let handle = self.resume_sailbox(sailbox_id).await?;
843        if handle.exec_endpoint.is_empty() {
844            return Err(SailError::Internal {
845                message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
846            });
847        }
848        Ok(handle.exec_endpoint)
849    }
850
851    /// Id-form of [`Sailbox::exec`](crate::Sailbox::exec), which documents the
852    /// contract. Spawns the output pump on the calling task's tokio runtime.
853    #[doc(hidden)]
854    pub async fn exec(
855        &self,
856        sailbox_id: &str,
857        argv: Vec<String>,
858        options: ExecOptions,
859    ) -> Result<ExecProcess, SailError> {
860        self.exec_at_endpoint(sailbox_id, None, argv, options).await
861    }
862
863    /// Start an exec through a previously returned stable workerproxy endpoint.
864    /// Create/resume-born Sailbox objects use this to avoid a redundant resume;
865    /// id-only objects pass `None` and retain the normal wake-and-resolve path.
866    #[doc(hidden)]
867    pub async fn exec_at_endpoint(
868        &self,
869        sailbox_id: &str,
870        exec_endpoint: Option<&str>,
871        argv: Vec<String>,
872        options: ExecOptions,
873    ) -> Result<ExecProcess, SailError> {
874        if argv.is_empty() {
875            return Err(SailError::InvalidArgument {
876                message: "command must be non-empty".to_string(),
877            });
878        }
879        if options.cwd.is_some() || options.background {
880            return Err(SailError::InvalidArgument {
881                message: "cwd and background require a shell command; use exec_shell or run_shell"
882                    .to_string(),
883            });
884        }
885        // Validate and encode the env before resolving the endpoint: it is
886        // purely local, so a malformed key must not first wake a paused sailbox.
887        let env = crate::exec::encode_env(&options.env)?;
888        let hinted_endpoint = exec_endpoint.filter(|endpoint| !endpoint.is_empty());
889        let exec_endpoint = match hinted_endpoint {
890            Some(endpoint) => endpoint.to_string(),
891            None => self.exec_endpoint(sailbox_id).await?,
892        };
893        let params = ExecParams {
894            sailbox_id: sailbox_id.to_string(),
895            exec_endpoint,
896            argv,
897            // The wire is whole seconds where 0 means "no limit", so a set
898            // sub-second timeout rounds up to 1s rather than collapsing to 0.
899            timeout_seconds: options
900                .timeout
901                .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
902            idempotency_key: options.idempotency_key,
903            // A pty always feeds keystrokes to the command, so it implies an
904            // open stdin regardless of the flag.
905            open_stdin: options.open_stdin || options.pty,
906            pty: options.pty,
907            term: options.term,
908            cols: options.cols,
909            rows: options.rows,
910            env,
911            retry_timeout: options.retry_timeout.as_secs_f64(),
912            forward_ports: options.forward_ports,
913            forward_browser: options.forward_browser,
914            extra_metadata: Vec::new(),
915            // The clipboard bridge is a pty-session behavior; the guest would
916            // ignore it elsewhere, so don't ask.
917            forward_clipboard: options.forward_clipboard && options.pty,
918        };
919        self.start_exec_params_at_endpoint(params, hinted_endpoint.is_some())
920            .await
921    }
922
923    /// Starts already-encoded exec parameters and safely re-resolves a hinted
924    /// endpoint after migration. Bindings use this to share the exact retry and
925    /// idempotency semantics of [`Client::exec_at_endpoint`].
926    #[doc(hidden)]
927    pub async fn start_exec_params_at_endpoint(
928        &self,
929        mut params: ExecParams,
930        endpoint_was_hint: bool,
931    ) -> Result<ExecProcess, SailError> {
932        if !endpoint_was_hint {
933            return ExecProcess::start(self.worker(), params).await;
934        }
935
936        // A create/resume handle is authoritative when returned, but the VM
937        // may migrate before its caller launches exec. Try the hint once with
938        // the normal idempotency key, then resolve fresh on any failure that a
939        // stale worker can produce. The resolved attempt receives the caller's
940        // full retry budget and safely reattaches if the first worker launched
941        // the command but lost its Started response.
942        params.ensure_idempotency_key();
943        let hinted_start =
944            ExecProcess::start_with_initial_retry_timeout(self.worker(), params.clone(), Some(0.0));
945        match tokio::time::timeout(HINTED_EXEC_ENDPOINT_PROBE_TIMEOUT, hinted_start).await {
946            Ok(Ok(process)) => return Ok(process),
947            Ok(Err(err)) if !should_reresolve_hinted_exec_endpoint(&err) => return Err(err),
948            Ok(Err(_)) | Err(_) => {}
949        }
950
951        // connect_lazy owns the dial in tonic's background channel worker, so
952        // dropping the timed-out RPC future above does not cancel a stuck
953        // connection. Evict the hint before resolving placement: when the
954        // public endpoint is unchanged, the fallback must still dial a fresh
955        // channel instead of reusing the one whose probe just timed out.
956        self.worker().channels().invalidate(&params.exec_endpoint);
957        let endpoint = self.exec_endpoint(&params.sailbox_id).await?;
958        params.exec_endpoint = endpoint;
959        ExecProcess::start(self.worker(), params).await
960    }
961
962    /// Run a shell command in a Sailbox via `/bin/sh -lc`, honoring the
963    /// `cwd`/`background` conveniences in [`ExecOptions`]. See [`Client::exec`]
964    /// for the argv form and the runtime notes.
965    #[doc(hidden)]
966    pub async fn exec_shell(
967        &self,
968        sailbox_id: &str,
969        command: &str,
970        options: ExecOptions,
971    ) -> Result<ExecProcess, SailError> {
972        self.exec_shell_at_endpoint(sailbox_id, None, command, options)
973            .await
974    }
975
976    /// Shell-command counterpart to [`Client::exec_at_endpoint`].
977    #[doc(hidden)]
978    pub async fn exec_shell_at_endpoint(
979        &self,
980        sailbox_id: &str,
981        exec_endpoint: Option<&str>,
982        command: &str,
983        mut options: ExecOptions,
984    ) -> Result<ExecProcess, SailError> {
985        let argv = crate::exec::shell_argv(command, &options)?;
986        // The conveniences are baked into the argv now; clear them so the argv
987        // path's guard does not re-reject them.
988        options.cwd = None;
989        options.background = false;
990        self.exec_at_endpoint(sailbox_id, exec_endpoint, argv, options)
991            .await
992    }
993
994    /// Open a streaming read of a guest file. Resumes (wakes) the Sailbox to
995    /// reach it; the returned [`FileReader`] yields chunks until end of file.
996    ///
997    /// # Runtime
998    ///
999    /// Spawns the read pump on the calling task's tokio runtime (see
1000    /// [`crate::worker::WorkerProxy::read_file`]).
1001    #[doc(hidden)]
1002    pub async fn read_stream(
1003        &self,
1004        sailbox_id: &str,
1005        remote_path: &str,
1006    ) -> Result<FileReader, SailError> {
1007        let endpoint = self.exec_endpoint(sailbox_id).await?;
1008        Ok(self
1009            .inner
1010            .worker
1011            .read_file(&endpoint, sailbox_id, remote_path))
1012    }
1013
1014    /// Read a guest file into memory in one call (convenience over
1015    /// [`Client::read_stream`], which streams a large file without
1016    /// buffering it whole).
1017    #[doc(hidden)]
1018    pub async fn read_file(
1019        &self,
1020        sailbox_id: &str,
1021        remote_path: &str,
1022    ) -> Result<Vec<u8>, SailError> {
1023        let reader = self.read_stream(sailbox_id, remote_path).await?;
1024        let mut contents = Vec::new();
1025        while let Some(chunk) = reader.next().await {
1026            contents.extend_from_slice(&chunk?);
1027        }
1028        Ok(contents)
1029    }
1030
1031    /// Open a streaming write to a guest file. Resumes (wakes) the Sailbox to
1032    /// reach it; feed the returned [`FileWriter`] with `write_chunk` and end with
1033    /// `finish`, so a large source is never buffered whole.
1034    ///
1035    /// # Runtime
1036    ///
1037    /// Spawns the write RPC on the calling task's tokio runtime (see
1038    /// [`crate::worker::WorkerProxy::write_file`]).
1039    #[doc(hidden)]
1040    pub async fn write_stream(
1041        &self,
1042        sailbox_id: &str,
1043        remote_path: &str,
1044        options: WriteOptions,
1045    ) -> Result<FileWriter, SailError> {
1046        let endpoint = self.exec_endpoint(sailbox_id).await?;
1047        Ok(self.inner.worker.write_file(
1048            &endpoint,
1049            sailbox_id,
1050            remote_path,
1051            options.create_parents,
1052            options.mode,
1053        ))
1054    }
1055
1056    /// Write `data` to a guest file in one call (convenience over
1057    /// [`Client::write_stream`], which streams a large source without
1058    /// buffering it whole).
1059    #[doc(hidden)]
1060    pub async fn write_file(
1061        &self,
1062        sailbox_id: &str,
1063        remote_path: &str,
1064        data: &[u8],
1065        options: WriteOptions,
1066    ) -> Result<(), SailError> {
1067        let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
1068        writer.write(data).await?;
1069        writer.finish().await
1070    }
1071
1072    // --- filesystem helpers ---
1073    //
1074    // These build a coreutils command, run it to completion, and inspect the
1075    // result. They live in the core so the command construction and the `find`
1076    // output parse are defined once, and every language binding consumes the
1077    // structured results rather than re-parsing `find`'s output.
1078
1079    /// Run a command to completion and return its buffered result.
1080    async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
1081        self.exec(sailbox_id, argv, ExecOptions::default())
1082            .await?
1083            .wait()
1084            .await
1085    }
1086
1087    /// Create a directory and any missing parents (like `mkdir -p`); a no-op if
1088    /// it already exists.
1089    #[doc(hidden)]
1090    pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
1091        crate::sailbox::fs::require_path(path)?;
1092        let result = self
1093            .run_argv(
1094                sailbox_id,
1095                vec![
1096                    "mkdir".to_string(),
1097                    "-p".to_string(),
1098                    "--".to_string(),
1099                    path.to_string(),
1100                ],
1101            )
1102            .await?;
1103        fs_command_ok(&result, &format!("create directory {path}"))
1104    }
1105
1106    /// Remove a file or directory tree (like `rm -rf`); a no-op if it is already
1107    /// absent.
1108    #[doc(hidden)]
1109    pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
1110        crate::sailbox::fs::require_path(path)?;
1111        let result = self
1112            .run_argv(
1113                sailbox_id,
1114                vec![
1115                    "rm".to_string(),
1116                    "-rf".to_string(),
1117                    "--".to_string(),
1118                    path.to_string(),
1119                ],
1120            )
1121            .await?;
1122        fs_command_ok(&result, &format!("remove {path}"))
1123    }
1124
1125    /// Whether `path` exists in the guest. Follows symlinks (like `test -e`), so
1126    /// a dangling symlink reports `false`.
1127    #[doc(hidden)]
1128    pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
1129        crate::sailbox::fs::require_path(path)?;
1130        let result = self
1131            .run_argv(
1132                sailbox_id,
1133                vec!["test".to_string(), "-e".to_string(), path.to_string()],
1134            )
1135            .await?;
1136        // `test -e` answers with its exit code: 0 exists, 1 does not. Any other
1137        // code (for example a signal-killed process) is a failed check, not an
1138        // answer, so surface it rather than reading it as absent.
1139        match result.exit_code {
1140            0 => Ok(true),
1141            1 => Ok(false),
1142            _ => Err(fs_command_error(
1143                &result,
1144                &format!("check whether {path} exists"),
1145            )),
1146        }
1147    }
1148
1149    /// List a directory's immediate entries (files and subdirectories, no
1150    /// recursion). Requires GNU `find`, which the default Debian image ships. A
1151    /// missing path errors, as does a path that exists but is not a directory.
1152    #[doc(hidden)]
1153    pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
1154        crate::sailbox::fs::require_path(path)?;
1155        let process = self
1156            .exec(
1157                sailbox_id,
1158                crate::sailbox::fs::list_dir_argv(path),
1159                ExecOptions::default(),
1160            )
1161            .await?;
1162        let result = process.wait().await?;
1163        fs_command_ok(&result, &format!("list directory {path}"))?;
1164        // Buffered stdout is a capped, drop-oldest tail, so parsing a truncated
1165        // listing would silently drop entries.
1166        if result.stdout_truncated {
1167            return Err(SailError::Execution {
1168                code: RpcStatus::FailedPrecondition,
1169                detail: format!(
1170                    "directory listing for {path} was truncated because it has \
1171                     too many entries; list a smaller subtree"
1172                ),
1173            });
1174        }
1175        // The records are NUL-terminated, and only the raw buffered bytes keep
1176        // NUL: the string-typed `ExecResult` replaces it, as does the persisted
1177        // tail that `wait` falls back to when the live stream loses its ending.
1178        // So parse the local raw bytes, and require that the stream delivered
1179        // them all; when it did not, the local buffer may be missing entries.
1180        if !result.stdout_complete {
1181            return Err(SailError::Execution {
1182                code: RpcStatus::FailedPrecondition,
1183                detail: format!(
1184                    "directory listing for {path} was interrupted before it \
1185                     finished streaming; retry the call"
1186                ),
1187            });
1188        }
1189        let mut entries =
1190            crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
1191                .map_err(|detail| SailError::Execution {
1192                code: RpcStatus::FailedPrecondition,
1193                detail: format!("directory listing for {path} could not be used: {detail}"),
1194            })?;
1195        // `find` emits the start point itself as the first record, carrying the
1196        // path's own type.
1197        if entries.is_empty() {
1198            return Err(SailError::Execution {
1199                code: RpcStatus::FailedPrecondition,
1200                detail: format!(
1201                    "directory listing for {path} produced no records; \
1202                     listing requires GNU find in the guest"
1203                ),
1204            });
1205        }
1206        let start = entries.remove(0);
1207        if start.entry_type != EntryType::Directory {
1208            return Err(SailError::Execution {
1209                code: RpcStatus::FailedPrecondition,
1210                detail: format!(
1211                    "{path} is not a directory (it is a {})",
1212                    start.entry_type.as_str()
1213                ),
1214            });
1215        }
1216        Ok(entries)
1217    }
1218}
1219
1220/// Whole seconds for the wire, rounding up so the server never enforces a
1221/// shorter bound than the caller asked for. A zero duration stays zero, which
1222/// the request builders refuse as a non-positive value.
1223pub(crate) fn duration_to_whole_seconds(duration: Duration) -> i64 {
1224    duration.as_secs_f64().ceil() as i64
1225}
1226
1227/// Fail on a non-zero exit from a filesystem helper command.
1228fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
1229    if result.exit_code != 0 {
1230        return Err(fs_command_error(result, action));
1231    }
1232    Ok(())
1233}
1234
1235/// The error for a failed filesystem helper command, folding the guest's stderr
1236/// into the message.
1237fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
1238    let stderr = result.stderr.trim();
1239    let suffix = if stderr.is_empty() {
1240        String::new()
1241    } else {
1242        format!(": {stderr}")
1243    };
1244    SailError::Execution {
1245        code: RpcStatus::FailedPrecondition,
1246        detail: format!(
1247            "failed to {action} (exit code {}){suffix}",
1248            result.exit_code
1249        ),
1250    }
1251}
1252
1253/// Whether an exec failure can mean a create/resume endpoint hint went stale.
1254/// Transport messages relayed as source-less UNKNOWN/INTERNAL statuses need
1255/// the same treatment as structurally retryable transport failures.
1256fn should_reresolve_hinted_exec_endpoint(err: &SailError) -> bool {
1257    err.retryable()
1258        || matches!(
1259            err,
1260            SailError::Terminated { .. } | SailError::HostLost { .. }
1261        )
1262        || matches!(
1263            err,
1264            SailError::Execution {
1265                code: RpcStatus::Unknown | RpcStatus::Internal,
1266                detail,
1267            } if is_transient_transport_message(detail)
1268        )
1269}
1270
1271#[cfg(test)]
1272mod timeout_tests {
1273    use super::*;
1274
1275    #[test]
1276    fn durations_round_up_to_whole_seconds() {
1277        assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1278        assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1279        assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1280        assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1281    }
1282
1283    #[test]
1284    fn hinted_exec_reresolves_source_less_transport_statuses() {
1285        let relayed_transport = SailError::Execution {
1286            code: RpcStatus::Unknown,
1287            detail: "error reading server preface: EOF".to_string(),
1288        };
1289        assert!(should_reresolve_hinted_exec_endpoint(&relayed_transport));
1290
1291        let server_verdict = SailError::Execution {
1292            code: RpcStatus::Unknown,
1293            detail: "application rejected exec".to_string(),
1294        };
1295        assert!(!should_reresolve_hinted_exec_endpoint(&server_verdict));
1296    }
1297}