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    /// Target deployment the agent should converge toward.
26    /// None means no changes needed.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub target: Option<TargetDeployment>,
29    /// Public URL for the commands API (e.g. `https://manager.example.com/v1`).
30    /// Cloud-deployed functions use this to poll for pending commands.
31    /// When absent, the agent falls back to its sync URL.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub commands_url: Option<String>,
34}
35
36/// Target deployment state for the agent to converge toward.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct TargetDeployment {
40    /// Release information (ID, version, stack definition).
41    pub release_info: ReleaseInfo,
42    /// Full deployment configuration (settings, env vars, etc.).
43    pub config: DeploymentConfig,
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    #[test]
51    fn test_sync_request_serialization() {
52        let req = SyncRequest {
53            deployment_id: "ag_abc123".to_string(),
54            current_state: None,
55        };
56
57        let json = serde_json::to_value(&req).unwrap();
58        assert_eq!(json["deploymentId"], "ag_abc123");
59        // current_state is None → should be omitted
60        assert!(json.get("currentState").is_none());
61    }
62
63    #[test]
64    fn test_sync_request_deserialization() {
65        let json = r#"{"deploymentId": "ag_xyz"}"#;
66        let req: SyncRequest = serde_json::from_str(json).unwrap();
67        assert_eq!(req.deployment_id, "ag_xyz");
68        assert!(req.current_state.is_none());
69    }
70
71    #[test]
72    fn test_sync_response_empty() {
73        let resp = SyncResponse {
74            target: None,
75            commands_url: None,
76        };
77        let json = serde_json::to_value(&resp).unwrap();
78        // target is None → should be omitted
79        assert!(json.get("target").is_none());
80    }
81
82    #[test]
83    fn test_sync_response_roundtrip_no_target() {
84        let resp = SyncResponse {
85            target: None,
86            commands_url: None,
87        };
88        let serialized = serde_json::to_string(&resp).unwrap();
89        let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
90        assert!(deserialized.target.is_none());
91    }
92
93    #[test]
94    fn test_sync_request_with_camel_case() {
95        // Verify camelCase renaming works correctly
96        let json = r#"{"deploymentId": "ag_1", "currentState": null}"#;
97        let req: SyncRequest = serde_json::from_str(json).unwrap();
98        assert_eq!(req.deployment_id, "ag_1");
99        assert!(req.current_state.is_none());
100
101        // snake_case should NOT work
102        let json = r#"{"deployment_id": "ag_1"}"#;
103        assert!(serde_json::from_str::<SyncRequest>(json).is_err());
104    }
105}