Skip to main content

agent_procs/
protocol.rs

1//! Wire protocol for daemon–CLI communication.
2//!
3//! All messages are serialized as single-line JSON (newline-delimited).
4//! The client sends a [`Request`] and reads back one or more [`Response`]s
5//! (streaming commands like `Logs --follow` produce multiple responses).
6//!
7//! Both types use `#[serde(tag = "type")]` so the JSON includes a `"type"`
8//! discriminant field for easy interop with non-Rust clients.
9//!
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Maximum allowed size (in bytes) for a single JSON message line.
14/// Provides defense-in-depth against runaway reads on the Unix socket.
15pub const MAX_MESSAGE_SIZE: usize = 1024 * 1024; // 1 MiB
16
17/// A client-to-daemon request, serialized as tagged JSON.
18///
19/// # Examples
20///
21/// ```
22/// use agent_procs::protocol::Request;
23///
24/// let req = Request::Status;
25/// let json = serde_json::to_string(&req).unwrap();
26/// assert!(json.contains(r#""type":"Status""#));
27///
28/// let parsed: Request = serde_json::from_str(&json).unwrap();
29/// assert_eq!(parsed, req);
30/// ```
31#[must_use]
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(tag = "type")]
34pub enum Request {
35    Run {
36        command: String,
37        name: Option<String>,
38        cwd: Option<String>,
39        #[serde(default)]
40        env: Option<HashMap<String, String>>,
41        #[serde(default)]
42        port: Option<u16>,
43    },
44    Stop {
45        target: String,
46    },
47    StopAll,
48    Restart {
49        target: String,
50    },
51    Status,
52    Logs {
53        target: Option<String>,
54        tail: usize,
55        follow: bool,
56        stderr: bool,
57        all: bool,
58        timeout_secs: Option<u64>,
59        #[serde(default)]
60        lines: Option<usize>,
61    },
62    Wait {
63        target: String,
64        until: Option<String>,
65        regex: bool,
66        exit: bool,
67        timeout_secs: Option<u64>,
68    },
69    Shutdown,
70    EnableProxy {
71        #[serde(default)]
72        proxy_port: Option<u16>,
73    },
74}
75
76/// A daemon-to-client response, serialized as tagged JSON.
77///
78/// # Examples
79///
80/// ```
81/// use agent_procs::protocol::Response;
82///
83/// let resp = Response::Ok { message: "done".into() };
84/// let json = serde_json::to_string(&resp).unwrap();
85/// let parsed: Response = serde_json::from_str(&json).unwrap();
86/// assert_eq!(parsed, resp);
87/// ```
88#[must_use]
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "type")]
91pub enum Response {
92    Ok {
93        message: String,
94    },
95    RunOk {
96        name: String,
97        id: String,
98        pid: u32,
99        #[serde(default)]
100        port: Option<u16>,
101        #[serde(default)]
102        url: Option<String>,
103    },
104    Status {
105        processes: Vec<ProcessInfo>,
106    },
107    LogLine {
108        process: String,
109        stream: Stream,
110        line: String,
111    },
112    LogEnd,
113    WaitMatch {
114        line: String,
115    },
116    WaitExited {
117        exit_code: Option<i32>,
118    },
119    WaitTimeout,
120    Error {
121        code: i32,
122        message: String,
123    },
124}
125
126#[must_use]
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct ProcessInfo {
129    pub name: String,
130    pub id: String,
131    pub pid: u32,
132    pub state: ProcessState,
133    pub exit_code: Option<i32>,
134    pub uptime_secs: Option<u64>,
135    pub command: String,
136    #[serde(default)]
137    pub port: Option<u16>,
138    #[serde(default)]
139    pub url: Option<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum ProcessState {
145    Running,
146    Exited,
147}
148
149#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum Stream {
152    Stdout,
153    Stderr,
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_request_serde_roundtrip() {
162        let requests = vec![
163            Request::Run {
164                command: "echo hi".into(),
165                name: Some("test".into()),
166                cwd: None,
167                env: None,
168                port: None,
169            },
170            Request::Stop {
171                target: "test".into(),
172            },
173            Request::StopAll,
174            Request::Restart {
175                target: "test".into(),
176            },
177            Request::Status,
178            Request::Logs {
179                target: Some("test".into()),
180                tail: 10,
181                follow: true,
182                stderr: false,
183                all: false,
184                timeout_secs: Some(30),
185                lines: None,
186            },
187            Request::Wait {
188                target: "test".into(),
189                until: Some("ready".into()),
190                regex: false,
191                exit: false,
192                timeout_secs: Some(60),
193            },
194            Request::Shutdown,
195            Request::EnableProxy {
196                proxy_port: Some(8080),
197            },
198        ];
199
200        for req in &requests {
201            let json = serde_json::to_string(req).unwrap();
202            let parsed: Request = serde_json::from_str(&json).unwrap();
203            assert_eq!(&parsed, req);
204        }
205    }
206
207    #[test]
208    fn test_response_serde_roundtrip() {
209        let responses = vec![
210            Response::Ok {
211                message: "done".into(),
212            },
213            Response::RunOk {
214                name: "web".into(),
215                id: "p1".into(),
216                pid: 1234,
217                port: Some(3000),
218                url: Some("http://127.0.0.1:3000".into()),
219            },
220            Response::Status { processes: vec![] },
221            Response::LogLine {
222                process: "web".into(),
223                stream: Stream::Stdout,
224                line: "hello".into(),
225            },
226            Response::LogEnd,
227            Response::WaitMatch {
228                line: "ready".into(),
229            },
230            Response::WaitExited { exit_code: Some(0) },
231            Response::WaitTimeout,
232            Response::Error {
233                code: 1,
234                message: "oops".into(),
235            },
236        ];
237
238        for resp in &responses {
239            let json = serde_json::to_string(resp).unwrap();
240            let parsed: Response = serde_json::from_str(&json).unwrap();
241            assert_eq!(&parsed, resp);
242        }
243    }
244
245    #[test]
246    fn test_process_info_serde_roundtrip() {
247        let info = ProcessInfo {
248            name: "api".into(),
249            id: "p1".into(),
250            pid: 42,
251            state: ProcessState::Running,
252            exit_code: None,
253            uptime_secs: Some(120),
254            command: "cargo run".into(),
255            port: Some(8080),
256            url: Some("http://127.0.0.1:8080".into()),
257        };
258        let json = serde_json::to_string(&info).unwrap();
259        let parsed: ProcessInfo = serde_json::from_str(&json).unwrap();
260        assert_eq!(parsed, info);
261    }
262
263    #[test]
264    fn test_request_json_format() {
265        let run = Request::Run {
266            command: "ls".into(),
267            name: None,
268            cwd: None,
269            env: None,
270            port: None,
271        };
272        let json = serde_json::to_string(&run).unwrap();
273        assert!(json.contains("\"type\":\"Run\""));
274
275        let stop = Request::Stop { target: "x".into() };
276        let json = serde_json::to_string(&stop).unwrap();
277        assert!(json.contains("\"type\":\"Stop\""));
278
279        let status = Request::Status;
280        let json = serde_json::to_string(&status).unwrap();
281        assert!(json.contains("\"type\":\"Status\""));
282
283        let shutdown = Request::Shutdown;
284        let json = serde_json::to_string(&shutdown).unwrap();
285        assert!(json.contains("\"type\":\"Shutdown\""));
286
287        let stop_all = Request::StopAll;
288        let json = serde_json::to_string(&stop_all).unwrap();
289        assert!(json.contains("\"type\":\"StopAll\""));
290
291        let restart = Request::Restart { target: "x".into() };
292        let json = serde_json::to_string(&restart).unwrap();
293        assert!(json.contains("\"type\":\"Restart\""));
294
295        let enable_proxy = Request::EnableProxy { proxy_port: None };
296        let json = serde_json::to_string(&enable_proxy).unwrap();
297        assert!(json.contains("\"type\":\"EnableProxy\""));
298    }
299}