Skip to main content

microsandbox_types/
cloud.rs

1//! Wire types for the cloud backend's HTTP calls.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8//--------------------------------------------------------------------------------------------------
9// Types: Request
10//--------------------------------------------------------------------------------------------------
11
12/// Wire shape of a cloud sandbox create request body.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
15#[serde(default)]
16pub struct CloudCreateSandboxRequest {
17    /// User-facing sandbox name.
18    pub name: String,
19    /// OCI image reference to run.
20    pub image: String,
21    /// Virtual CPU count.
22    pub vcpus: u8,
23    /// Guest memory in MiB.
24    pub memory_mib: u32,
25    /// Environment variables injected into the sandbox.
26    pub env: HashMap<String, String>,
27    /// Whether the sandbox should be removed when its allocation terminates.
28    pub ephemeral: bool,
29
30    // Optional config fields.
31    /// Working directory inside the guest.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub workdir: Option<String>,
34    /// Default shell inside the guest.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub shell: Option<String>,
37    /// OCI entrypoint override.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub entrypoint: Option<Vec<String>>,
40    /// Guest hostname override.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub hostname: Option<String>,
43    /// Guest user identity.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub user: Option<String>,
46    /// Runtime log verbosity.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub log_level: Option<String>,
49    /// Named scripts mounted into the guest.
50    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
51    pub scripts: HashMap<String, String>,
52    /// Hard sandbox lifetime cap in seconds.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub max_duration_secs: Option<u64>,
55    /// Idle timeout in seconds.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub idle_timeout_secs: Option<u64>,
58}
59
60//--------------------------------------------------------------------------------------------------
61// Types: Response
62//--------------------------------------------------------------------------------------------------
63
64/// Wire shape of the cloud sandbox response returned by sandbox endpoints.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
67pub struct CloudSandbox {
68    /// Server-side UUID.
69    pub id: String,
70    /// Owning org's UUID.
71    pub org_id: String,
72    /// User-facing sandbox name.
73    pub name: String,
74    /// Current lifecycle status.
75    pub status: CloudSandboxStatus,
76    /// Create request stored by the cloud control plane.
77    pub config: CloudCreateSandboxRequest,
78    /// Whether the sandbox should be removed when its allocation terminates.
79    pub ephemeral: bool,
80    /// Creation timestamp.
81    pub created_at: DateTime<Utc>,
82    /// Last start timestamp, when known.
83    #[serde(default)]
84    #[cfg_attr(feature = "ts", ts(optional = nullable))]
85    pub started_at: Option<DateTime<Utc>>,
86    /// Last stop timestamp, when known.
87    #[serde(default)]
88    #[cfg_attr(feature = "ts", ts(optional = nullable))]
89    pub stopped_at: Option<DateTime<Utc>>,
90    /// Last failure reason, when any.
91    #[serde(default)]
92    #[cfg_attr(feature = "ts", ts(optional = nullable))]
93    pub last_error: Option<String>,
94}
95
96/// Sandbox lifecycle status returned by the cloud control plane.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
99#[serde(rename_all = "snake_case")]
100pub enum CloudSandboxStatus {
101    /// Created in the database but not yet started.
102    Created,
103    /// Start request has been submitted.
104    Starting,
105    /// Sandbox is running.
106    Running,
107    /// Stop request has been submitted.
108    Stopping,
109    /// Sandbox is stopped.
110    Stopped,
111    /// Sandbox failed.
112    Failed,
113}
114
115/// Wire shape of paginated list responses.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
118pub struct CloudPaginated<T> {
119    /// Page of response items.
120    pub data: Vec<T>,
121    /// Cursor for the next page, when one exists.
122    #[serde(default)]
123    #[cfg_attr(feature = "ts", ts(optional = nullable))]
124    pub next_cursor: Option<String>,
125}
126
127/// Wire shape of the message response returned by mutation endpoints.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
130pub struct CloudMessageResponse {
131    /// Human-readable response message.
132    pub message: String,
133}
134
135/// Wire shape of the typed error body returned by cloud APIs on 4xx/5xx responses.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
138pub struct CloudErrorBody {
139    /// Flat machine-readable error code, when returned in this shape.
140    #[serde(default)]
141    #[cfg_attr(feature = "ts", ts(optional = nullable))]
142    pub code: Option<String>,
143    /// Flat human-readable error message, when returned in this shape.
144    #[serde(default)]
145    #[cfg_attr(feature = "ts", ts(optional = nullable))]
146    pub message: Option<String>,
147    /// Nested error object returned by the API error responder.
148    #[serde(default)]
149    #[cfg_attr(feature = "ts", ts(optional = nullable))]
150    pub error: Option<CloudErrorDetails>,
151}
152
153/// Nested cloud API error details.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
156pub struct CloudErrorDetails {
157    /// Machine-readable error code.
158    #[serde(default)]
159    #[cfg_attr(feature = "ts", ts(optional = nullable))]
160    pub code: Option<String>,
161    /// Human-readable error message.
162    #[serde(default)]
163    #[cfg_attr(feature = "ts", ts(optional = nullable))]
164    pub message: Option<String>,
165}
166
167//--------------------------------------------------------------------------------------------------
168// Trait Implementations
169//--------------------------------------------------------------------------------------------------
170
171impl Default for CloudCreateSandboxRequest {
172    fn default() -> Self {
173        Self {
174            name: String::new(),
175            image: String::new(),
176            vcpus: 1,
177            memory_mib: 512,
178            env: HashMap::new(),
179            ephemeral: true,
180            workdir: None,
181            shell: None,
182            entrypoint: None,
183            hostname: None,
184            user: None,
185            log_level: None,
186            scripts: HashMap::new(),
187            max_duration_secs: None,
188            idle_timeout_secs: None,
189        }
190    }
191}
192
193//--------------------------------------------------------------------------------------------------
194// Tests
195//--------------------------------------------------------------------------------------------------
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn create_request_serialises_minimal() {
203        let req = CloudCreateSandboxRequest {
204            name: "agent-1".into(),
205            image: "python:3.12".into(),
206            ..Default::default()
207        };
208        let json = serde_json::to_value(&req).unwrap();
209        assert_eq!(json["name"], "agent-1");
210        assert_eq!(json["image"], "python:3.12");
211        assert_eq!(json["vcpus"], 1);
212        assert_eq!(json["memory_mib"], 512);
213        assert_eq!(json["ephemeral"], true);
214        assert!(json.get("workdir").is_none());
215        assert!(json.get("entrypoint").is_none());
216        assert!(json.get("max_duration_secs").is_none());
217    }
218
219    #[test]
220    fn create_request_serialises_full_d13() {
221        let mut req = CloudCreateSandboxRequest {
222            name: "agent-1".into(),
223            image: "python:3.12".into(),
224            workdir: Some("/app".into()),
225            shell: Some("/bin/bash".into()),
226            entrypoint: Some(vec!["python".into(), "-u".into()]),
227            hostname: Some("worker".into()),
228            user: Some("appuser".into()),
229            log_level: Some("info".into()),
230            max_duration_secs: Some(3600),
231            idle_timeout_secs: Some(600),
232            ..Default::default()
233        };
234        req.scripts.insert("setup".into(), "echo hi".into());
235        let json = serde_json::to_value(&req).unwrap();
236        assert_eq!(json["workdir"], "/app");
237        assert_eq!(json["shell"], "/bin/bash");
238        assert_eq!(json["entrypoint"], serde_json::json!(["python", "-u"]));
239        assert_eq!(json["max_duration_secs"], 3600);
240        assert_eq!(json["scripts"]["setup"], "echo hi");
241    }
242
243    #[test]
244    fn sandbox_status_round_trips() {
245        for status in [
246            CloudSandboxStatus::Created,
247            CloudSandboxStatus::Starting,
248            CloudSandboxStatus::Running,
249            CloudSandboxStatus::Stopping,
250            CloudSandboxStatus::Stopped,
251            CloudSandboxStatus::Failed,
252        ] {
253            let s = serde_json::to_string(&status).unwrap();
254            let parsed: CloudSandboxStatus = serde_json::from_str(&s).unwrap();
255            assert_eq!(status, parsed);
256        }
257    }
258
259    #[test]
260    fn sandbox_status_serialises_snake_case() {
261        let s = serde_json::to_string(&CloudSandboxStatus::Starting).unwrap();
262        assert_eq!(s, "\"starting\"");
263    }
264
265    #[test]
266    fn sandbox_response_parses_typical() {
267        let json = r#"{
268            "id": "00000000-0000-0000-0000-000000000002",
269            "org_id": "00000000-0000-0000-0000-000000000001",
270            "name": "agent-1",
271            "status": "created",
272            "config": { "name": "agent-1", "image": "python:3.12" },
273            "ephemeral": true,
274            "created_at": "2026-05-17T12:00:00Z"
275        }"#;
276        let sb: CloudSandbox = serde_json::from_str(json).unwrap();
277        assert_eq!(sb.name, "agent-1");
278        assert_eq!(sb.status, CloudSandboxStatus::Created);
279        assert_eq!(sb.config.image, "python:3.12");
280        assert!(sb.started_at.is_none());
281    }
282}