1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
15#[serde(default)]
16pub struct CloudCreateSandboxRequest {
17 pub name: String,
19 pub image: String,
21 pub vcpus: u8,
23 pub memory_mib: u32,
25 pub env: HashMap<String, String>,
27 pub ephemeral: bool,
29
30 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub workdir: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub shell: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub entrypoint: Option<Vec<String>>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub hostname: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub user: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub log_level: Option<String>,
49 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
51 pub scripts: HashMap<String, String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub max_duration_secs: Option<u64>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub idle_timeout_secs: Option<u64>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
66#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
67pub struct CloudSandbox {
68 pub id: String,
70 pub org_id: String,
72 pub name: String,
74 pub status: CloudSandboxStatus,
76 pub config: CloudCreateSandboxRequest,
78 pub ephemeral: bool,
80 pub created_at: DateTime<Utc>,
82 #[serde(default)]
84 #[cfg_attr(feature = "ts", ts(optional = nullable))]
85 pub started_at: Option<DateTime<Utc>>,
86 #[serde(default)]
88 #[cfg_attr(feature = "ts", ts(optional = nullable))]
89 pub stopped_at: Option<DateTime<Utc>>,
90 #[serde(default)]
92 #[cfg_attr(feature = "ts", ts(optional = nullable))]
93 pub last_error: Option<String>,
94}
95
96#[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,
103 Starting,
105 Running,
107 Stopping,
109 Stopped,
111 Failed,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
118pub struct CloudPaginated<T> {
119 pub data: Vec<T>,
121 #[serde(default)]
123 #[cfg_attr(feature = "ts", ts(optional = nullable))]
124 pub next_cursor: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
130pub struct CloudMessageResponse {
131 pub message: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
138pub struct CloudErrorBody {
139 #[serde(default)]
141 #[cfg_attr(feature = "ts", ts(optional = nullable))]
142 pub code: Option<String>,
143 #[serde(default)]
145 #[cfg_attr(feature = "ts", ts(optional = nullable))]
146 pub message: Option<String>,
147 #[serde(default)]
149 #[cfg_attr(feature = "ts", ts(optional = nullable))]
150 pub error: Option<CloudErrorDetails>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
156pub struct CloudErrorDetails {
157 #[serde(default)]
159 #[cfg_attr(feature = "ts", ts(optional = nullable))]
160 pub code: Option<String>,
161 #[serde(default)]
163 #[cfg_attr(feature = "ts", ts(optional = nullable))]
164 pub message: Option<String>,
165}
166
167impl 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#[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}