Skip to main content

sail/sailbox/
types.rs

1//! Typed domain models for the Sailbox API: lifecycle, listing, ingress
2//! listeners, and volumes.
3//!
4//! The core owns the wire schema: it deserializes API responses into these
5//! structs (applying the wire-schema field defaults) and serializes them back
6//! out for bindings. A binding maps a struct onto its own public type by field
7//! name without re-parsing the wire shape.
8
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13
14use crate::error::SailError;
15
16/// Lifecycle status of a Sailbox. An unrecognized server value is preserved in
17/// `Other` so a status a newer backend introduces never fails parsing. The
18/// default is `Other("")`: the status of a handle no server response has
19/// filled in yet.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum SailboxStatus {
22    /// Running and serving.
23    Running,
24    /// Paused in memory.
25    Paused,
26    /// Sleeping (checkpointed to disk).
27    Sleeping,
28    /// In a failed state.
29    Failed,
30    /// Terminated.
31    Terminated,
32    /// A status this SDK version does not recognize, kept verbatim.
33    Other(String),
34}
35
36impl Default for SailboxStatus {
37    fn default() -> Self {
38        SailboxStatus::Other(String::new())
39    }
40}
41
42impl SailboxStatus {
43    /// The wire string for this status.
44    pub fn as_str(&self) -> &str {
45        match self {
46            SailboxStatus::Running => "running",
47            SailboxStatus::Paused => "paused",
48            SailboxStatus::Sleeping => "sleeping",
49            SailboxStatus::Failed => "failed",
50            SailboxStatus::Terminated => "terminated",
51            SailboxStatus::Other(s) => s,
52        }
53    }
54}
55
56impl From<&str> for SailboxStatus {
57    fn from(s: &str) -> SailboxStatus {
58        match s {
59            "running" => SailboxStatus::Running,
60            "paused" => SailboxStatus::Paused,
61            "sleeping" => SailboxStatus::Sleeping,
62            "failed" => SailboxStatus::Failed,
63            "terminated" => SailboxStatus::Terminated,
64            other => SailboxStatus::Other(other.to_string()),
65        }
66    }
67}
68
69impl std::fmt::Display for SailboxStatus {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(self.as_str())
72    }
73}
74
75impl Serialize for SailboxStatus {
76    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
77        serializer.serialize_str(self.as_str())
78    }
79}
80
81impl<'de> Deserialize<'de> for SailboxStatus {
82    fn deserialize<D: serde::Deserializer<'de>>(
83        deserializer: D,
84    ) -> Result<SailboxStatus, D::Error> {
85        Ok(SailboxStatus::from(
86            String::deserialize(deserializer)?.as_str(),
87        ))
88    }
89}
90
91/// A lifecycle status a client can filter by in [`ListSailboxesQuery`]. A closed set: a
92/// client only filters by statuses the SDK knows. Server responses use the open
93/// [`SailboxStatus`], which tolerates a status a newer backend introduces.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SailboxStatusFilter {
96    /// Running and serving.
97    Running,
98    /// Paused in memory.
99    Paused,
100    /// Sleeping (checkpointed to disk).
101    Sleeping,
102    /// In a failed state.
103    Failed,
104    /// Terminated.
105    Terminated,
106}
107
108/// Server-side ordering for list results.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum SailboxListOrder {
111    /// Most recently active first: active/restorable Sailboxes, then updated_at.
112    #[default]
113    NewestActive,
114    /// Newest-created Sailboxes first.
115    NewestCreated,
116}
117
118impl SailboxListOrder {
119    /// The order's name, which is also the value the list API expects.
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            SailboxListOrder::NewestActive => "newest_active",
123            SailboxListOrder::NewestCreated => "newest_created",
124        }
125    }
126
127    /// Parse a canonical name.
128    pub fn parse(raw: &str) -> Option<SailboxListOrder> {
129        match raw.trim() {
130            "newest_active" => Some(SailboxListOrder::NewestActive),
131            "newest_created" => Some(SailboxListOrder::NewestCreated),
132            _ => None,
133        }
134    }
135}
136
137impl std::str::FromStr for SailboxListOrder {
138    type Err = SailError;
139
140    fn from_str(s: &str) -> Result<Self, Self::Err> {
141        SailboxListOrder::parse(s).ok_or_else(|| SailError::InvalidArgument {
142            message: format!(
143                "unknown list order {s:?}; use \"newest_active\" or \"newest_created\""
144            ),
145        })
146    }
147}
148
149impl std::fmt::Display for SailboxListOrder {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.write_str(self.as_str())
152    }
153}
154
155impl SailboxStatusFilter {
156    /// The wire string for this status.
157    pub fn as_str(&self) -> &'static str {
158        match self {
159            SailboxStatusFilter::Running => "running",
160            SailboxStatusFilter::Paused => "paused",
161            SailboxStatusFilter::Sleeping => "sleeping",
162            SailboxStatusFilter::Failed => "failed",
163            SailboxStatusFilter::Terminated => "terminated",
164        }
165    }
166
167    /// Parse a wire string, returning `None` for a status the SDK does not know.
168    pub fn parse(s: &str) -> Option<SailboxStatusFilter> {
169        match s {
170            "running" => Some(SailboxStatusFilter::Running),
171            "paused" => Some(SailboxStatusFilter::Paused),
172            "sleeping" => Some(SailboxStatusFilter::Sleeping),
173            "failed" => Some(SailboxStatusFilter::Failed),
174            "terminated" => Some(SailboxStatusFilter::Terminated),
175            _ => None,
176        }
177    }
178}
179
180impl std::str::FromStr for SailboxStatusFilter {
181    type Err = SailError;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        SailboxStatusFilter::parse(s).ok_or_else(|| SailError::InvalidArgument {
185            message: format!(
186                "unknown status filter {s:?}; use \"running\", \"paused\", \"sleeping\", \
187                 \"failed\", or \"terminated\""
188            ),
189        })
190    }
191}
192
193impl std::fmt::Display for SailboxStatusFilter {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.write_str(self.as_str())
196    }
197}
198
199/// Wire protocol for an ingress port or listener. An unrecognized value is
200/// preserved in `Other` so a protocol a newer backend introduces never fails.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ListenerProtocol {
203    /// Raw TCP.
204    Tcp,
205    /// HTTP.
206    Http,
207    /// A protocol this SDK version does not recognize, kept verbatim.
208    Other(String),
209}
210
211impl ListenerProtocol {
212    /// The wire string for this protocol.
213    pub fn as_str(&self) -> &str {
214        match self {
215            ListenerProtocol::Tcp => "tcp",
216            ListenerProtocol::Http => "http",
217            ListenerProtocol::Other(s) => s,
218        }
219    }
220}
221
222impl From<&str> for ListenerProtocol {
223    fn from(s: &str) -> ListenerProtocol {
224        match s {
225            "tcp" => ListenerProtocol::Tcp,
226            "http" => ListenerProtocol::Http,
227            other => ListenerProtocol::Other(other.to_string()),
228        }
229    }
230}
231
232impl std::fmt::Display for ListenerProtocol {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.write_str(self.as_str())
235    }
236}
237
238impl Serialize for ListenerProtocol {
239    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
240        serializer.serialize_str(self.as_str())
241    }
242}
243
244impl<'de> Deserialize<'de> for ListenerProtocol {
245    fn deserialize<D: serde::Deserializer<'de>>(
246        deserializer: D,
247    ) -> Result<ListenerProtocol, D::Error> {
248        Ok(ListenerProtocol::from(
249            String::deserialize(deserializer)?.as_str(),
250        ))
251    }
252}
253
254/// Status of a listener's ingress route. An unrecognized value is preserved in
255/// `Other` so a status a newer backend introduces never fails to parse.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum ListenerRouteStatus {
258    /// The route state is not yet known.
259    Unknown,
260    /// The route is being set up.
261    Pending,
262    /// The route is active and ready to carry traffic.
263    Active,
264    /// The route is being restored after a checkpoint or migration.
265    Restoring,
266    /// The route is temporarily unavailable.
267    Unavailable,
268    /// A status this SDK version does not recognize, kept verbatim.
269    Other(String),
270}
271
272impl ListenerRouteStatus {
273    /// The user-facing string for this status. The backend's proto spelling
274    /// (`LISTENER_ROUTE_STATUS_*`) is normalized here, once, so no wrapper
275    /// ever sees it.
276    pub fn as_str(&self) -> &str {
277        match self {
278            ListenerRouteStatus::Unknown => "unknown",
279            ListenerRouteStatus::Pending => "pending",
280            ListenerRouteStatus::Active => "active",
281            ListenerRouteStatus::Restoring => "restoring",
282            ListenerRouteStatus::Unavailable => "unavailable",
283            ListenerRouteStatus::Other(s) => s,
284        }
285    }
286}
287
288impl From<&str> for ListenerRouteStatus {
289    /// Parse the backend's proto spelling (`LISTENER_ROUTE_STATUS_*`) or the
290    /// friendly form that [`as_str`](Self::as_str) emits, so a serialized
291    /// [`Listener`](crate::Listener) round-trips through serde. An unrecognized
292    /// value is kept in `Other` with the `LISTENER_ROUTE_STATUS_` prefix
293    /// stripped and lowercased.
294    fn from(s: &str) -> ListenerRouteStatus {
295        match s {
296            "LISTENER_ROUTE_STATUS_UNSPECIFIED" | "unknown" => ListenerRouteStatus::Unknown,
297            "LISTENER_ROUTE_STATUS_PENDING" | "pending" => ListenerRouteStatus::Pending,
298            "LISTENER_ROUTE_STATUS_ACTIVE" | "active" => ListenerRouteStatus::Active,
299            "LISTENER_ROUTE_STATUS_RESTORING" | "restoring" => ListenerRouteStatus::Restoring,
300            "LISTENER_ROUTE_STATUS_UNAVAILABLE" | "unavailable" => ListenerRouteStatus::Unavailable,
301            other => ListenerRouteStatus::Other(
302                other
303                    .strip_prefix("LISTENER_ROUTE_STATUS_")
304                    .map_or_else(|| other.to_string(), str::to_ascii_lowercase),
305            ),
306        }
307    }
308}
309
310impl std::fmt::Display for ListenerRouteStatus {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        f.write_str(self.as_str())
313    }
314}
315
316impl Serialize for ListenerRouteStatus {
317    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
318        serializer.serialize_str(self.as_str())
319    }
320}
321
322impl<'de> Deserialize<'de> for ListenerRouteStatus {
323    fn deserialize<D: serde::Deserializer<'de>>(
324        deserializer: D,
325    ) -> Result<ListenerRouteStatus, D::Error> {
326        Ok(ListenerRouteStatus::from(
327            String::deserialize(deserializer)?.as_str(),
328        ))
329    }
330}
331
332/// Transport protocol a client requests when exposing an ingress port. A closed
333/// set: a client can only request a protocol the SDK supports. Server responses
334/// use the open [`ListenerProtocol`], which tolerates a protocol a newer backend adds.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum IngressProtocol {
337    /// Raw TCP.
338    Tcp,
339    /// HTTP.
340    Http,
341}
342
343impl IngressProtocol {
344    /// The wire string for this protocol.
345    pub fn as_str(&self) -> &'static str {
346        match self {
347            IngressProtocol::Tcp => "tcp",
348            IngressProtocol::Http => "http",
349        }
350    }
351
352    /// Parse a wire string, returning `None` for a protocol the SDK does not
353    /// support.
354    pub fn parse(s: &str) -> Option<IngressProtocol> {
355        match s {
356            "tcp" => Some(IngressProtocol::Tcp),
357            "http" => Some(IngressProtocol::Http),
358            _ => None,
359        }
360    }
361}
362
363impl std::str::FromStr for IngressProtocol {
364    type Err = SailError;
365
366    fn from_str(s: &str) -> Result<Self, Self::Err> {
367        IngressProtocol::parse(s).ok_or_else(|| SailError::InvalidArgument {
368            message: format!("unknown ingress protocol {s:?}; use \"tcp\" or \"http\""),
369        })
370    }
371}
372
373impl std::fmt::Display for IngressProtocol {
374    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375        f.write_str(self.as_str())
376    }
377}
378
379impl Serialize for IngressProtocol {
380    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
381        serializer.serialize_str(self.as_str())
382    }
383}
384
385/// Options for [`Sailbox::checkpoint`](crate::Sailbox::checkpoint).
386#[derive(Debug, Clone, Default)]
387pub struct CheckpointOptions {
388    /// Human-readable label for the checkpoint.
389    pub name: Option<String>,
390    /// Retention override; must be positive when given.
391    pub ttl: Option<std::time::Duration>,
392}
393
394/// Options for
395/// [`Sailbox::wait_for_listener`](crate::Sailbox::wait_for_listener).
396/// `Default` gives up after a minute.
397#[derive(Debug, Clone)]
398pub struct WaitForListenerOptions {
399    /// Fail with a timeout error after this long. `Duration::MAX` waits
400    /// indefinitely.
401    pub timeout: std::time::Duration,
402}
403
404impl Default for WaitForListenerOptions {
405    fn default() -> WaitForListenerOptions {
406        WaitForListenerOptions {
407            timeout: std::time::Duration::from_mins(1),
408        }
409    }
410}
411
412/// Named Sailbox resource size. Sailboxes are offered in a small discrete CPU
413/// menu rather than free-form vCPU/memory/disk. Each size sets the vCPU count
414/// plus a default memory and disk ceiling. Ongoing billing is by observed
415/// usage, so a larger size raises the ceiling without reserving resources;
416/// each size also has its documented one-time creation charge.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub enum SailboxSize {
419    /// Small (1 vCPU): fastest cold starts and resumes, with
420    /// lower ceilings that cap what a runaway workload can consume.
421    Small,
422    /// Medium (4 vCPU, default).
423    Medium,
424    /// Large (8 vCPU).
425    Large,
426}
427
428impl SailboxSize {
429    /// The wire string for this size.
430    pub fn as_str(&self) -> &'static str {
431        match self {
432            SailboxSize::Small => "s",
433            SailboxSize::Medium => "m",
434            SailboxSize::Large => "l",
435        }
436    }
437
438    /// Parse a wire string, returning `None` for an unsupported size.
439    pub fn parse(s: &str) -> Option<SailboxSize> {
440        match s {
441            "s" => Some(SailboxSize::Small),
442            "m" => Some(SailboxSize::Medium),
443            "l" => Some(SailboxSize::Large),
444            _ => None,
445        }
446    }
447
448    /// The allowed size labels in ascending order, for error messages and help.
449    pub fn allowed() -> &'static [&'static str] {
450        &["s", "m", "l"]
451    }
452}
453
454impl std::str::FromStr for SailboxSize {
455    type Err = SailError;
456
457    fn from_str(s: &str) -> Result<Self, Self::Err> {
458        SailboxSize::parse(s).ok_or_else(|| SailError::InvalidArgument {
459            message: format!("unknown size {s:?}; use one of s, m, l"),
460        })
461    }
462}
463
464impl std::fmt::Display for SailboxSize {
465    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466        f.write_str(self.as_str())
467    }
468}
469
470impl Serialize for SailboxSize {
471    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
472        serializer.serialize_str(self.as_str())
473    }
474}
475
476/// A handle to a running Sailbox: the fields needed to exec against it, copy
477/// files, or reach a listener. Returned by create/resume/from_checkpoint.
478#[derive(Debug, Clone, Default, Serialize)]
479#[non_exhaustive]
480pub struct SailboxHandle {
481    /// The Sailbox's stable identifier.
482    pub sailbox_id: String,
483    /// The Sailbox's name: the one given at create, or the one Sail derived
484    /// from the source when a restore left it out.
485    pub name: String,
486    /// The Sailbox's lifecycle status (for example `running`).
487    pub status: SailboxStatus,
488    /// Internal routing address for the host serving the Sailbox.
489    #[doc(hidden)]
490    pub worker_address: String,
491    /// Internal endpoint for opening exec/file/listener streams to the Sailbox.
492    #[doc(hidden)]
493    pub exec_endpoint: String,
494}
495
496/// Actionable notice that a Sailbox's runtime should be upgraded.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[non_exhaustive]
499pub struct SailboxDeprecation {
500    /// Date after which the Sailbox may be upgraded automatically on its
501    /// next wake, stopping running processes and clearing in-memory state.
502    pub deadline: String,
503    /// Upgrade guidance from the server.
504    pub message: String,
505}
506
507/// Read-only snapshot from get/list. Deliberately omits worker_address /
508/// exec_endpoint (the list/get endpoints do not return them).
509#[derive(Debug, Clone, Serialize, Deserialize)]
510#[non_exhaustive]
511pub struct SailboxInfo {
512    /// The Sailbox's stable identifier.
513    pub sailbox_id: String,
514    /// Identifier of the owning app.
515    pub app_id: String,
516    /// Name of the owning app.
517    pub app_name: String,
518    /// Identifier of the image the Sailbox was created from.
519    pub image_id: String,
520    /// The Sailbox's display name: the one given at create or restore time, or
521    /// one derived from the source when that call omitted it.
522    pub name: String,
523    /// The Sailbox's lifecycle status (for example `running`).
524    pub status: SailboxStatus,
525    /// Configured memory size, in mebibytes.
526    pub memory_mib: i64,
527    /// Configured number of virtual CPUs.
528    pub vcpu_count: i64,
529    /// Configured state-disk size, in gibibytes.
530    pub state_disk_size_gib: i64,
531    /// Requested CPU allocation, in vCPUs.
532    pub cpu_requested_vcpu: i64,
533    /// Current CPU usage, in vCPUs.
534    pub cpu_used_vcpu: f64,
535    /// Requested memory allocation, in bytes.
536    pub memory_requested_bytes: i64,
537    /// Current memory usage, in bytes.
538    pub memory_used_bytes: i64,
539    /// Requested disk allocation, in bytes.
540    pub disk_requested_bytes: i64,
541    /// Current disk usage, in bytes.
542    pub disk_used_bytes: i64,
543    /// CPU architecture of the Sailbox, in the create-side vocabulary
544    /// (`amd64` or `arm64`).
545    #[serde(default)]
546    pub architecture: String,
547    /// Guest schema version the Sailbox is running, when reported by the backend.
548    #[serde(default)]
549    pub guest_schema_version: Option<i64>,
550    /// Actionable runtime deprecation notice, when an upgrade is needed.
551    #[serde(default)]
552    pub deprecation: Option<SailboxDeprecation>,
553    /// Human-readable error detail, present when the Sailbox is in an error state.
554    #[serde(default)]
555    pub error_message: Option<String>,
556    /// Monotonic checkpoint generation counter for the Sailbox.
557    pub checkpoint_generation: i64,
558    /// When the Sailbox first started running, if it has. A resume does not
559    /// rewrite it.
560    #[serde(with = "crate::rfc3339_micros::option", default)]
561    pub started_at: Option<OffsetDateTime>,
562    /// When the most recent checkpoint was taken, if any.
563    #[serde(with = "crate::rfc3339_micros::option", default)]
564    pub last_checkpointed_at: Option<OffsetDateTime>,
565    /// When the Sailbox was created.
566    #[serde(with = "crate::rfc3339_micros")]
567    pub created_at: OffsetDateTime,
568    /// When the Sailbox was last updated.
569    #[serde(with = "crate::rfc3339_micros")]
570    pub updated_at: OffsetDateTime,
571    /// The user whose credential created this Sailbox (for a restore, the user
572    /// who ran it). `None` for service-key creates.
573    #[serde(default)]
574    pub created_by_user_id: Option<String>,
575    /// Access visibility: `Some("private")` restricts exec/files/SSH and
576    /// lifecycle to the creator; `None`/`Some("org")` is org-wide access.
577    #[serde(default)]
578    pub visibility: Option<String>,
579    /// When Sail may sleep this Sailbox on its own.
580    #[serde(default)]
581    pub auto_sleep: AutoSleep,
582}
583
584/// The identity behind the presented API key.
585#[doc(hidden)]
586#[derive(Debug, Clone, Deserialize)]
587#[non_exhaustive]
588pub struct WhoAmI {
589    /// The organization the key belongs to.
590    pub org_id: String,
591    /// The user the key is scoped to, or `None` for a service key.
592    #[serde(default)]
593    pub user_id: Option<String>,
594}
595
596/// One page of list results plus the pagination envelope.
597#[derive(Debug, Clone, Serialize)]
598#[non_exhaustive]
599pub struct SailboxPage {
600    /// The Sailboxes in this page.
601    pub items: Vec<SailboxInfo>,
602    /// Maximum number of items requested for this page.
603    pub limit: i64,
604    /// Zero-based offset of the first item in this page.
605    pub offset: i64,
606    /// Total number of Sailboxes matching the query across all pages.
607    pub total: i64,
608    /// True when further pages exist beyond this one.
609    pub has_more: bool,
610}
611
612/// Query parameters for [`Client::sailbox_spend`](crate::Client::sailbox_spend).
613#[derive(Debug, Clone, Default)]
614pub struct SailboxSpendQuery {
615    /// Restrict spend to Sailboxes owned by this app id.
616    pub app_id: Option<String>,
617    /// Restrict spend to one Sailbox id.
618    pub sailbox_id: Option<String>,
619    /// Inclusive lower bound for the spend window. The API defaults to the
620    /// current UTC month when omitted.
621    pub from: Option<OffsetDateTime>,
622    /// Exclusive upper bound for the spend window. The API defaults to now when
623    /// omitted.
624    pub to: Option<OffsetDateTime>,
625}
626
627/// Estimated Sailbox spend over a requested time window.
628#[derive(Debug, Clone, Serialize, Deserialize)]
629#[non_exhaustive]
630pub struct SailboxSpendResponse {
631    /// Start of the spend window.
632    #[serde(with = "crate::rfc3339_micros")]
633    pub start_at: OffsetDateTime,
634    /// End of the spend window.
635    #[serde(with = "crate::rfc3339_micros")]
636    pub end_at: OffsetDateTime,
637    /// Cost from closed billing segments, in USD nanos.
638    pub finalized_cost_usd_nanos: i64,
639    /// Estimated cost from still-open billing segments, in USD nanos.
640    pub estimated_active_cost_usd_nanos: i64,
641    /// Total estimated cost, in USD nanos.
642    pub estimated_total_cost_usd_nanos: i64,
643    /// Total observed duration, in seconds.
644    pub duration_seconds: i64,
645    /// Total vCPU-seconds observed.
646    pub vcpu_seconds: f64,
647    /// Total GiB-seconds of memory observed.
648    pub memory_gib_seconds: f64,
649    /// Total GiB-seconds of state disk observed.
650    pub state_disk_gib_seconds: f64,
651    /// Whether non-zero pricing rates were configured by the backend.
652    pub pricing_configured: bool,
653    /// Pricing rates used for the local estimate.
654    pub rates: SailboxSpendRates,
655    /// Per-Sailbox spend summaries.
656    pub sailboxes: Vec<SailboxSpendItem>,
657}
658
659/// Rate metadata returned with a Sailbox spend estimate.
660#[derive(Debug, Clone, Default, Serialize, Deserialize)]
661#[non_exhaustive]
662pub struct SailboxSpendRates {
663    /// Nanodollars per vCPU-second.
664    pub vcpu_second_usd_nanos: i64,
665    /// Nanodollars per memory GiB-second.
666    pub memory_gib_second_usd_nanos: i64,
667    /// Nanodollars per arm64 state-disk GiB-second.
668    pub state_disk_gib_second_usd_nanos: i64,
669    /// Nanodollars per Sailbox creation.
670    pub creation_usd_nanos: i64,
671    /// Nanodollars per amd64 state-disk GiB-second.
672    #[serde(default)]
673    pub amd64_state_disk_gib_second_usd_nanos: i64,
674}
675
676/// Estimated spend for one Sailbox over a requested time window.
677#[derive(Debug, Clone, Serialize, Deserialize)]
678#[non_exhaustive]
679pub struct SailboxSpendItem {
680    /// Sailbox id.
681    pub sailbox_id: String,
682    /// Owning app id.
683    pub app_id: String,
684    /// Cost from closed billing segments, in USD nanos.
685    pub finalized_cost_usd_nanos: i64,
686    /// Estimated cost from still-open billing segments, in USD nanos.
687    pub estimated_active_cost_usd_nanos: i64,
688    /// Total estimated cost, in USD nanos.
689    pub estimated_total_cost_usd_nanos: i64,
690    /// Observed duration, in seconds.
691    pub duration_seconds: i64,
692    /// Observed vCPU-seconds.
693    pub vcpu_seconds: f64,
694    /// Observed GiB-seconds of memory.
695    pub memory_gib_seconds: f64,
696    /// Observed GiB-seconds of state disk.
697    pub state_disk_gib_seconds: f64,
698    /// Whether this row includes an active billing segment.
699    pub active: bool,
700}
701
702/// Query parameters for [`Client::sailbox_metrics`](crate::Client::sailbox_metrics).
703#[derive(Debug, Clone)]
704pub struct SailboxMetricsQuery {
705    /// Window name accepted by the API, for example `1h`, `6h`, `24h`, or `7d`.
706    pub range: String,
707}
708
709impl Default for SailboxMetricsQuery {
710    fn default() -> SailboxMetricsQuery {
711        SailboxMetricsQuery {
712            range: "24h".to_string(),
713        }
714    }
715}
716
717/// Resource-usage time series for one Sailbox.
718#[derive(Debug, Clone, Serialize, Deserialize)]
719#[non_exhaustive]
720pub struct SailboxMetricsResponse {
721    /// Echoed range selected by the API.
722    pub range: String,
723    /// Time-ordered metric points.
724    pub data: Vec<SailboxMetricPoint>,
725}
726
727/// One bucket in a Sailbox resource-usage time series.
728#[derive(Debug, Clone, Serialize, Deserialize)]
729#[non_exhaustive]
730pub struct SailboxMetricPoint {
731    /// Bucket timestamp.
732    #[serde(with = "crate::rfc3339_micros")]
733    pub timestamp: OffsetDateTime,
734    /// Current CPU usage, in vCPUs.
735    pub cpu_used_vcpu: f64,
736    /// Requested CPU allocation, in vCPUs.
737    pub cpu_requested_vcpu: i64,
738    /// Current memory usage, in bytes.
739    pub memory_used_bytes: i64,
740    /// Requested memory allocation, in bytes.
741    pub memory_requested_bytes: i64,
742    /// Current disk usage, in bytes.
743    pub disk_used_bytes: i64,
744    /// Requested disk allocation, in bytes.
745    pub disk_requested_bytes: i64,
746}
747
748/// A durable checkpoint handle. `status` echoes the source Sailbox's lifecycle
749/// status after checkpointing (a running Sailbox is snapshotted; a paused/sleeping
750/// one returns its existing checkpoint), so the binding can sync its handle.
751#[derive(Debug, Clone, Serialize)]
752#[non_exhaustive]
753pub struct SailboxCheckpoint {
754    /// Stable identifier of the checkpoint.
755    pub checkpoint_id: String,
756    /// Identifier of the Sailbox the checkpoint was taken from.
757    pub sailbox_id: String,
758    /// Checkpoint generation counter captured by this checkpoint.
759    pub checkpoint_generation: i64,
760    /// When the checkpoint expires: seven days out unless `ttl` asked for a
761    /// different window. Starting a Sailbox from it after that fails.
762    #[serde(with = "crate::rfc3339_micros::option")]
763    pub expires_at: Option<OffsetDateTime>,
764    /// Source Sailbox's lifecycle status after checkpointing.
765    pub status: SailboxStatus,
766}
767
768/// Filters for list/list_page.
769#[derive(Debug, Clone)]
770pub struct ListSailboxesQuery {
771    /// Restrict results to Sailboxes owned by the app with this id.
772    pub app_id: Option<String>,
773    /// Restrict results to Sailboxes in this lifecycle status.
774    pub status: Option<SailboxStatusFilter>,
775    /// Free-text search filter applied by the backend.
776    pub search: Option<String>,
777    /// Restrict results to Sailboxes with the credential injection policy
778    /// with this id attached.
779    #[doc(hidden)]
780    pub credential_policy_id: Option<String>,
781    /// Ordering to apply before pagination.
782    pub order: SailboxListOrder,
783    /// Maximum number of items to return.
784    pub limit: i64,
785    /// Zero-based offset of the first item to return.
786    pub offset: i64,
787}
788
789/// Default page size for listing, matching the Sailbox API's own default. The
790/// API rejects `limit=0`, so the derived all-zero default cannot be used.
791#[doc(hidden)]
792pub const DEFAULT_LIST_LIMIT: i64 = 50;
793
794/// Largest page size the listing endpoints accept; larger limits are rejected,
795/// not clamped. Auto-paginating listers request full pages of this size.
796#[doc(hidden)]
797pub const MAX_LIST_LIMIT: i64 = 100;
798
799impl Default for ListSailboxesQuery {
800    fn default() -> ListSailboxesQuery {
801        ListSailboxesQuery {
802            app_id: None,
803            status: None,
804            search: None,
805            credential_policy_id: None,
806            order: SailboxListOrder::NewestActive,
807            limit: DEFAULT_LIST_LIMIT,
808            offset: 0,
809        }
810    }
811}
812
813/// One guest ingress port to reserve at create time.
814#[derive(Debug, Clone, Serialize)]
815pub struct IngressPort {
816    /// Port inside the guest to expose for ingress.
817    pub guest_port: u32,
818    /// Transport protocol for the port (for example `tcp`).
819    pub protocol: IngressProtocol,
820    /// Sources allowed to reach the port: an address or a range, or a Sail app
821    /// name on an `http` listener. An app name cannot read as an address or a
822    /// range, and cannot contain a `/`. An address must not carry an IPv6 zone,
823    /// such as `fe80::1%eth0`, which names an interface on one machine rather
824    /// than a source. Empty means all sources are allowed.
825    pub allowlist: Vec<String>,
826}
827
828/// One NFS volume mount.
829#[derive(Debug, Clone, Serialize)]
830pub struct VolumeMount {
831    /// Identifier of the NFS volume to mount.
832    pub volume_id: String,
833    /// Absolute path inside the guest where the volume is mounted.
834    pub mount_path: String,
835}
836
837/// A custom domain attached to a Sailbox HTTP listener.
838#[doc(hidden)]
839#[derive(Debug, Clone, Serialize, Deserialize)]
840#[non_exhaustive]
841pub struct CustomDomainInfo {
842    /// Normalized hostname.
843    pub domain: String,
844    /// Sailbox receiving requests for the hostname.
845    pub sailbox_id: String,
846    /// Guest HTTP port receiving requests for the hostname.
847    pub guest_port: u32,
848    /// HTTPS URL served by the hostname.
849    pub url: String,
850    /// Organization-specific hostname the domain's CNAME record must target.
851    pub cname_target: String,
852    /// When the domain was attached.
853    #[serde(with = "crate::rfc3339_micros")]
854    pub created_at: OffsetDateTime,
855}
856
857/// A managed NFS volume as returned by the volume API.
858#[derive(Debug, Clone, Serialize, Deserialize)]
859#[non_exhaustive]
860pub struct VolumeInfo {
861    /// Stable identifier of the volume.
862    pub volume_id: String,
863    /// Caller-supplied volume name.
864    pub name: String,
865    /// Storage backend serving the volume.
866    pub backend: String,
867    /// Lifecycle status of the volume.
868    pub status: String,
869    /// When the volume was created, if reported.
870    #[serde(with = "crate::rfc3339_micros::option", default)]
871    pub created_at: Option<OffsetDateTime>,
872    /// When the volume was last updated, if reported.
873    #[serde(with = "crate::rfc3339_micros::option", default)]
874    pub updated_at: Option<OffsetDateTime>,
875}
876
877/// The wire shape of the add-listener response: the scheduler resolves the
878/// public endpoint in the response (so a caller renders it without a second
879/// lookup) but reports nothing about routing, so it converts to a [`Listener`]
880/// with an unknown route status.
881#[derive(Debug, Clone, Deserialize)]
882pub(crate) struct AddListenerWire {
883    pub(crate) guest_port: u32,
884    pub(crate) protocol: ListenerProtocol,
885    #[serde(default)]
886    pub(crate) public_url: String,
887    #[serde(default)]
888    pub(crate) public_host: String,
889    #[serde(default)]
890    pub(crate) public_port: u32,
891}
892
893impl From<AddListenerWire> for crate::worker::Listener {
894    fn from(wire: AddListenerWire) -> crate::worker::Listener {
895        crate::worker::Listener {
896            guest_port: wire.guest_port,
897            protocol: wire.protocol,
898            // The expose response says nothing about reachability; confirm
899            // with wait_for_listener or re-fetch via get/list.
900            route_status: ListenerRouteStatus::Unknown,
901            public_url: wire.public_url,
902            public_host: wire.public_host,
903            public_port: wire.public_port,
904        }
905    }
906}
907
908/// A CA-signed SSH user certificate plus the key id that identifies the signing
909/// org (`org=<id>;fp=...;iat=...`).
910#[derive(Debug, Clone)]
911#[non_exhaustive]
912pub struct IssuedUserCert {
913    /// The OpenSSH certificate (`<type>-cert-v01@openssh.com AAAA...`).
914    pub certificate: String,
915    /// The certificate key id.
916    pub key_id: String,
917}
918
919/// How to reach an exposed listener: a routable HTTPS URL for `http`
920/// listeners, or a host/port any TCP client can dial for `tcp` listeners.
921#[derive(Debug, Clone, PartialEq, Eq)]
922pub enum ListenerEndpoint {
923    /// The HTTPS URL to reach the guest service.
924    Http {
925        /// Routable URL.
926        url: String,
927    },
928    /// The address to dial for a raw-TCP listener.
929    Tcp {
930        /// Hostname to dial.
931        host: String,
932        /// Port to dial.
933        port: u32,
934    },
935}
936
937impl std::fmt::Display for ListenerEndpoint {
938    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
939        match self {
940            ListenerEndpoint::Http { url } => f.write_str(url),
941            ListenerEndpoint::Tcp { host, port } => write!(f, "{host}:{port}"),
942        }
943    }
944}
945
946/// When Sail may put a Sailbox to sleep on its own.
947///
948/// Sail watches for Sailboxes that are doing nothing and sleeps them, freeing
949/// their memory and waking them the moment anything needs them again. Waking
950/// takes a couple of seconds, which is free for a batch job and unwelcome if
951/// someone is waiting at a terminal.
952///
953/// Every choice here can only make Sail sleep the Sailbox less often than it
954/// otherwise would. None of them can cause a sleep, and Sail still sleeps a
955/// Sailbox only when it sits fully idle: no busy process, no imminent timer,
956/// nothing a sleep would interrupt.
957///
958/// Calling [`sleep`](crate::Sailbox::sleep) yourself is unaffected, and
959/// so are `pause`, `resume`, and scheduled wakes.
960#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
961pub enum AutoSleep {
962    /// Sail decides, on its own timing. The default.
963    #[default]
964    Automatic,
965    /// Sail decides, but waits at least this long first. A duration shorter
966    /// than Sail's own wait is accepted and simply has no effect; anything
967    /// longer holds the Sailbox awake for that much idle time. A wait longer
968    /// than an hour is rejected; use [`Never`](AutoSleep::Never) instead.
969    NotBefore(Duration),
970    /// Sail never sleeps this Sailbox on its own.
971    Never,
972}
973
974/// Inputs to create a Sailbox. The image is a typed [`ImageSpec`](crate::image::ImageSpec);
975/// describe a custom build by filling an
976/// [`ImageDefinition`](crate::imagebuild::ImageDefinition). `Default` selects
977/// the prebuilt Debian base image and the platform defaults for everything
978/// else; `app_id` (and usually `name`) must be set before the request is
979/// valid.
980#[derive(Debug, Clone)]
981pub struct CreateSailboxRequest {
982    /// Identifier of the app that will own the Sailbox.
983    pub app_id: String,
984    /// Caller-supplied name for the Sailbox.
985    pub name: String,
986    /// Guest ingress ports to reserve at create time.
987    pub ingress_ports: Vec<IngressPort>,
988    /// NFS volumes to mount into the guest.
989    pub volume_mounts: Vec<VolumeMount>,
990    /// The image to create the Sailbox from. Serialized to canonical proto-JSON
991    /// and sent as the `image` field.
992    pub image: crate::image::ImageSpec,
993    /// Requested resource size; [`SailboxSize::Medium`] when absent.
994    pub size: Option<SailboxSize>,
995    /// Optional memory limit in whole GiB, within the size's range; the
996    /// size's default when absent.
997    pub memory_limit_gib: Option<u32>,
998    /// Optional disk limit in whole GiB, within the size's range; the size's
999    /// default when absent.
1000    pub disk_limit_gib: Option<u32>,
1001    /// Enable SSH on the new Sailbox after create: trust the org SSH CA, start
1002    /// `sshd`, and expose guest port 22 as TCP once the CA-only daemon owns it.
1003    /// An explicit port-22 ingress entry contributes just its allowlist.
1004    pub ssh: bool,
1005    /// By default a Sailbox is org-wide: any credential in the org can exec,
1006    /// copy files, SSH, or run lifecycle operations on it. `true` restricts all
1007    /// of that to the creating user. An org admin can override that with a
1008    /// recorded reason for exec, files, setting a wake time, and the pause,
1009    /// sleep, resume, terminate, and upgrade operations. SSH, exposing or
1010    /// removing listeners, checkpoint, and restore stay creator-only.
1011    /// Requires a user-scoped API key.
1012    pub private: bool,
1013    /// Budget for rebuilding the image if Sail needs to rebuild it before
1014    /// the Sailbox is created; the default build budget applies when absent.
1015    pub image_build_timeout: Option<Duration>,
1016    /// When Sail may sleep this Sailbox on its own.
1017    pub auto_sleep: AutoSleep,
1018}
1019
1020impl Default for CreateSailboxRequest {
1021    /// An empty request for a plain Debian-base Sailbox: fill in
1022    /// `app_id` and `name`, override the rest as needed.
1023    fn default() -> CreateSailboxRequest {
1024        CreateSailboxRequest {
1025            app_id: String::new(),
1026            name: String::new(),
1027            ingress_ports: Vec::new(),
1028            volume_mounts: Vec::new(),
1029            image: crate::image::ImageSpec {
1030                base: Some(crate::image::BaseImage::Debian),
1031                ..crate::image::ImageSpec::default()
1032            },
1033            size: None,
1034            memory_limit_gib: None,
1035            disk_limit_gib: None,
1036            ssh: false,
1037            private: false,
1038            image_build_timeout: None,
1039            auto_sleep: AutoSleep::Automatic,
1040        }
1041    }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    /// Every auto-sleep choice may only make Sail wait longer, so a floor that
1049    /// is not a whole number of seconds rounds up. Truncating it would be the
1050    /// one setting that let a Sailbox sleep earlier than the caller asked.
1051    #[test]
1052    fn a_part_second_auto_sleep_floor_rounds_up() {
1053        let seconds = |wait: Duration| {
1054            AutoSleep::NotBefore(wait).to_json()["min_seconds_before_sleep"].clone()
1055        };
1056        assert_eq!(seconds(Duration::from_millis(1500)), serde_json::json!(2));
1057        assert_eq!(seconds(Duration::from_millis(1)), serde_json::json!(1));
1058        assert_eq!(seconds(Duration::from_mins(5)), serde_json::json!(300));
1059        // Exactly zero is the default, not a minimum of nothing, and reads
1060        // back that way.
1061        assert_eq!(
1062            AutoSleep::NotBefore(Duration::ZERO).to_json(),
1063            AutoSleep::Automatic.to_json()
1064        );
1065        // Rounding up can never carry a wait past the cap: anything above it is
1066        // refused before transport.
1067        assert_eq!(
1068            seconds(crate::sailbox::api::MAX_AUTO_SLEEP_WAIT),
1069            serde_json::json!(3600)
1070        );
1071    }
1072
1073    #[test]
1074    fn closed_enums_round_trip_through_fromstr_and_display() {
1075        for (text, value) in [
1076            ("newest_active", SailboxListOrder::NewestActive),
1077            ("newest_created", SailboxListOrder::NewestCreated),
1078        ] {
1079            assert_eq!(text.parse::<SailboxListOrder>().unwrap(), value);
1080            assert_eq!(value.to_string(), text);
1081        }
1082        for (text, value) in [
1083            ("running", SailboxStatusFilter::Running),
1084            ("terminated", SailboxStatusFilter::Terminated),
1085        ] {
1086            assert_eq!(text.parse::<SailboxStatusFilter>().unwrap(), value);
1087            assert_eq!(value.to_string(), text);
1088        }
1089        for (text, value) in [
1090            ("tcp", IngressProtocol::Tcp),
1091            ("http", IngressProtocol::Http),
1092        ] {
1093            assert_eq!(text.parse::<IngressProtocol>().unwrap(), value);
1094            assert_eq!(value.to_string(), text);
1095        }
1096        for (text, value) in [
1097            ("s", SailboxSize::Small),
1098            ("m", SailboxSize::Medium),
1099            ("l", SailboxSize::Large),
1100        ] {
1101            assert_eq!(text.parse::<SailboxSize>().unwrap(), value);
1102            assert_eq!(value.to_string(), text);
1103        }
1104        let err = "udp".parse::<IngressProtocol>().unwrap_err();
1105        assert!(matches!(err, SailError::InvalidArgument { .. }));
1106        assert!(err.to_string().contains("udp"));
1107    }
1108
1109    #[test]
1110    fn list_query_default_uses_a_valid_nonzero_limit() {
1111        // The sailbox API rejects limit=0, so the default must be the API's own
1112        // page size, not the derived zero.
1113        let query = ListSailboxesQuery::default();
1114        assert_eq!(query.limit, DEFAULT_LIST_LIMIT);
1115        assert!(query.limit > 0);
1116        assert_eq!(query.offset, 0);
1117    }
1118
1119    #[test]
1120    fn route_status_round_trips_through_serde() {
1121        // Serialize emits the friendly form (as_str), so a persisted Listener
1122        // read back must parse it rather than fall through to Other. Guards the
1123        // Serialize/Deserialize pair against drifting apart.
1124        for status in [
1125            ListenerRouteStatus::Unknown,
1126            ListenerRouteStatus::Pending,
1127            ListenerRouteStatus::Active,
1128            ListenerRouteStatus::Restoring,
1129            ListenerRouteStatus::Unavailable,
1130            ListenerRouteStatus::Other("degraded".to_string()),
1131        ] {
1132            let json = serde_json::to_string(&status).unwrap();
1133            let back: ListenerRouteStatus = serde_json::from_str(&json).unwrap();
1134            assert_eq!(
1135                status, back,
1136                "round-trip failed for {status:?} (json {json})"
1137            );
1138        }
1139        // The backend's proto spelling still parses to the same value.
1140        assert_eq!(
1141            ListenerRouteStatus::from("LISTENER_ROUTE_STATUS_ACTIVE"),
1142            ListenerRouteStatus::Active,
1143        );
1144    }
1145
1146    #[test]
1147    fn info_deserializes_a_deprecation_notice() {
1148        let value = serde_json::json!({
1149            "sailbox_id": "sb-1", "app_id": "app-1", "app_name": "a", "name": "n",
1150            "image_id": "img-1",
1151            "status": "running", "memory_mib": 2048, "vcpu_count": 4,
1152            "state_disk_size_gib": 10,
1153            "cpu_requested_vcpu": 2, "cpu_used_vcpu": 1.5,
1154            "memory_requested_bytes": 1024, "memory_used_bytes": 512,
1155            "disk_requested_bytes": 4096, "disk_used_bytes": 2048,
1156            "architecture": "amd64", "checkpoint_generation": 7,
1157            "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z",
1158            "deprecation": {
1159                "deadline": "2026-08-01",
1160                "message": "Upgrade this sailbox before 2026-08-01."
1161            }
1162        });
1163        let info: SailboxInfo = serde_json::from_value(value).unwrap();
1164        let notice = info.deprecation.unwrap();
1165        assert_eq!(notice.deadline, "2026-08-01");
1166        assert!(notice.message.starts_with("Upgrade this sailbox"));
1167    }
1168
1169    #[test]
1170    fn add_listener_reads_the_tcp_dial_target() {
1171        for body in [
1172            r#"{"guest_port":2222,"protocol":"tcp","public_host":"tcp.example.com","public_port":22003}"#,
1173            // A response that repeats the pair under an older name reads the
1174            // same, because a field this type does not name is ignored.
1175            r#"{"guest_port":2222,"protocol":"tcp","public_host":"tcp.example.com","public_port":22003,"tcp_public_host":"old.example.com","tcp_public_port":1}"#,
1176        ] {
1177            let wire: AddListenerWire = serde_json::from_str(body).unwrap();
1178            let listener = crate::worker::Listener::from(wire);
1179            assert_eq!(listener.public_host, "tcp.example.com");
1180            assert_eq!(listener.public_port, 22003);
1181        }
1182    }
1183}
1184
1185/// The wire object in both directions: `{automatic, min_seconds_before_sleep?}`.
1186/// A read carries it, so the enum deserializes from that shape rather than the
1187/// tagged one serde would derive.
1188#[derive(Deserialize)]
1189struct AutoSleepWire {
1190    #[serde(default = "crate::sailbox::types::auto_sleep_default_automatic")]
1191    automatic: bool,
1192    #[serde(default)]
1193    min_seconds_before_sleep: Option<u64>,
1194}
1195
1196fn auto_sleep_default_automatic() -> bool {
1197    true
1198}
1199
1200impl Serialize for AutoSleep {
1201    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1202        (*self).to_json().serialize(serializer)
1203    }
1204}
1205
1206impl<'de> Deserialize<'de> for AutoSleep {
1207    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<AutoSleep, D::Error> {
1208        let wire = AutoSleepWire::deserialize(deserializer)?;
1209        if !wire.automatic {
1210            return Ok(AutoSleep::Never);
1211        }
1212        Ok(match wire.min_seconds_before_sleep {
1213            Some(seconds) if seconds > 0 => AutoSleep::NotBefore(Duration::from_secs(seconds)),
1214            _ => AutoSleep::Automatic,
1215        })
1216    }
1217}
1218
1219impl AutoSleep {
1220    /// The `auto_sleep` request body: `{automatic, min_seconds_before_sleep?}`.
1221    /// `Automatic` with no wait of its own sends the object anyway, so a later
1222    /// call can move a Sailbox back to the default.
1223    pub(crate) fn to_json(self) -> serde_json::Value {
1224        match self {
1225            // A wait of zero is the default, and reads back as the default, so
1226            // it is sent as the default rather than as a minimum of nothing.
1227            AutoSleep::Automatic => serde_json::json!({"automatic": true}),
1228            AutoSleep::NotBefore(wait) if wait.is_zero() => {
1229                serde_json::json!({"automatic": true})
1230            }
1231            // The wait is carried in whole seconds, and a part-second is
1232            // rounded up rather than dropped: every choice here may only make
1233            // Sail wait longer, so truncating a caller's floor would be the
1234            // one setting that let a Sailbox sleep earlier than it asked for.
1235            AutoSleep::NotBefore(wait) => serde_json::json!({
1236                "automatic": true,
1237                "min_seconds_before_sleep": wait.as_secs()
1238                    + u64::from(wait.subsec_nanos() > 0),
1239            }),
1240            AutoSleep::Never => serde_json::json!({"automatic": false}),
1241        }
1242    }
1243}