Skip to main content

alien_core/
sync.rs

1//! Sync protocol types for agent ↔ manager communication.
2//!
3//! The agent periodically calls `POST /v1/sync` with a `SyncRequest` and
4//! receives a `SyncResponse` containing the target deployment state.
5
6use serde::{Deserialize, Serialize};
7
8use crate::{DeploymentConfig, DeploymentState, ReleaseInfo};
9
10/// Request sent by the agent to the manager during periodic sync.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct SyncRequest {
14    /// The deployment ID this agent is managing.
15    pub deployment_id: String,
16    /// Current deployment state as seen by the agent.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub current_state: Option<DeploymentState>,
19}
20
21/// Response from the manager to the agent sync request.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SyncResponse {
25    /// Authoritative deployment state from the manager.
26    ///
27    /// Pull agents use this to hydrate local state when attaching to an
28    /// already-imported deployment. Absent means the agent's local state is
29    /// already authoritative or no state has been established yet.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub current_state: Option<DeploymentState>,
32    /// Target deployment the agent should converge toward.
33    /// None means no changes needed.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub target: Option<TargetDeployment>,
36    /// Public URL for the commands API (e.g. `https://manager.example.com/v1`).
37    /// Cloud-deployed workers use this to poll for pending commands.
38    /// When absent, the agent falls back to its sync URL.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub commands_url: Option<String>,
41}
42
43/// Target deployment state for the agent to converge toward.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct TargetDeployment {
47    /// Release information (ID, version, stack definition).
48    pub release_info: ReleaseInfo,
49    /// Full deployment configuration (settings, env vars, etc.).
50    pub config: DeploymentConfig,
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn test_sync_request_serialization() {
59        let req = SyncRequest {
60            deployment_id: "dep_abc123".to_string(),
61            current_state: None,
62        };
63
64        let json = serde_json::to_value(&req).unwrap();
65        assert_eq!(json["deploymentId"], "dep_abc123");
66        // current_state is None → should be omitted
67        assert!(json.get("currentState").is_none());
68    }
69
70    #[test]
71    fn test_sync_request_deserialization() {
72        let json = r#"{"deploymentId": "dep_xyz"}"#;
73        let req: SyncRequest = serde_json::from_str(json).unwrap();
74        assert_eq!(req.deployment_id, "dep_xyz");
75        assert!(req.current_state.is_none());
76    }
77
78    #[test]
79    fn test_sync_response_empty() {
80        let resp = SyncResponse {
81            current_state: None,
82            target: None,
83            commands_url: None,
84        };
85        let json = serde_json::to_value(&resp).unwrap();
86        // target is None → should be omitted
87        assert!(json.get("target").is_none());
88        assert!(json.get("currentState").is_none());
89    }
90
91    #[test]
92    fn test_sync_response_roundtrip_no_target() {
93        let resp = SyncResponse {
94            current_state: None,
95            target: None,
96            commands_url: None,
97        };
98        let serialized = serde_json::to_string(&resp).unwrap();
99        let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
100        assert!(deserialized.target.is_none());
101        assert!(deserialized.current_state.is_none());
102    }
103
104    #[test]
105    fn test_sync_request_with_camel_case() {
106        // Verify camelCase renaming works correctly
107        let json = r#"{"deploymentId": "dep_1", "currentState": null}"#;
108        let req: SyncRequest = serde_json::from_str(json).unwrap();
109        assert_eq!(req.deployment_id, "dep_1");
110        assert!(req.current_state.is_none());
111
112        // snake_case should NOT work
113        let json = r#"{"deployment_id": "dep_1"}"#;
114        assert!(serde_json::from_str::<SyncRequest>(json).is_err());
115    }
116}