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