use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct CloudCreateSandboxRequest {
pub name: String,
pub image: String,
pub vcpus: u8,
pub memory_mib: u32,
pub env: HashMap<String, String>,
pub ephemeral: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entrypoint: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_level: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub scripts: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_duration_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idle_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudSandbox {
pub id: String,
pub org_id: String,
pub name: String,
pub status: CloudSandboxStatus,
pub config: CloudCreateSandboxRequest,
pub ephemeral: bool,
pub created_at: DateTime<Utc>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub started_at: Option<DateTime<Utc>>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub stopped_at: Option<DateTime<Utc>>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CloudSandboxStatus {
Created,
Starting,
Running,
Stopping,
Stopped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudPaginated<T> {
pub data: Vec<T>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudMessageResponse {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudErrorBody {
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub code: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub message: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub error: Option<CloudErrorDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudErrorDetails {
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub code: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(optional = nullable))]
pub message: Option<String>,
}
impl Default for CloudCreateSandboxRequest {
fn default() -> Self {
Self {
name: String::new(),
image: String::new(),
vcpus: 1,
memory_mib: 512,
env: HashMap::new(),
ephemeral: true,
workdir: None,
shell: None,
entrypoint: None,
hostname: None,
user: None,
log_level: None,
scripts: HashMap::new(),
max_duration_secs: None,
idle_timeout_secs: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_request_serialises_minimal() {
let req = CloudCreateSandboxRequest {
name: "agent-1".into(),
image: "python:3.12".into(),
..Default::default()
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["name"], "agent-1");
assert_eq!(json["image"], "python:3.12");
assert_eq!(json["vcpus"], 1);
assert_eq!(json["memory_mib"], 512);
assert_eq!(json["ephemeral"], true);
assert!(json.get("workdir").is_none());
assert!(json.get("entrypoint").is_none());
assert!(json.get("max_duration_secs").is_none());
}
#[test]
fn create_request_serialises_full_d13() {
let mut req = CloudCreateSandboxRequest {
name: "agent-1".into(),
image: "python:3.12".into(),
workdir: Some("/app".into()),
shell: Some("/bin/bash".into()),
entrypoint: Some(vec!["python".into(), "-u".into()]),
hostname: Some("worker".into()),
user: Some("appuser".into()),
log_level: Some("info".into()),
max_duration_secs: Some(3600),
idle_timeout_secs: Some(600),
..Default::default()
};
req.scripts.insert("setup".into(), "echo hi".into());
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["workdir"], "/app");
assert_eq!(json["shell"], "/bin/bash");
assert_eq!(json["entrypoint"], serde_json::json!(["python", "-u"]));
assert_eq!(json["max_duration_secs"], 3600);
assert_eq!(json["scripts"]["setup"], "echo hi");
}
#[test]
fn sandbox_status_round_trips() {
for status in [
CloudSandboxStatus::Created,
CloudSandboxStatus::Starting,
CloudSandboxStatus::Running,
CloudSandboxStatus::Stopping,
CloudSandboxStatus::Stopped,
CloudSandboxStatus::Failed,
] {
let s = serde_json::to_string(&status).unwrap();
let parsed: CloudSandboxStatus = serde_json::from_str(&s).unwrap();
assert_eq!(status, parsed);
}
}
#[test]
fn sandbox_status_serialises_snake_case() {
let s = serde_json::to_string(&CloudSandboxStatus::Starting).unwrap();
assert_eq!(s, "\"starting\"");
}
#[test]
fn sandbox_response_parses_typical() {
let json = r#"{
"id": "00000000-0000-0000-0000-000000000002",
"org_id": "00000000-0000-0000-0000-000000000001",
"name": "agent-1",
"status": "created",
"config": { "name": "agent-1", "image": "python:3.12" },
"ephemeral": true,
"created_at": "2026-05-17T12:00:00Z"
}"#;
let sb: CloudSandbox = serde_json::from_str(json).unwrap();
assert_eq!(sb.name, "agent-1");
assert_eq!(sb.status, CloudSandboxStatus::Created);
assert_eq!(sb.config.image, "python:3.12");
assert!(sb.started_at.is_none());
}
}