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/// Current protocol version.  Bumped when breaking changes are introduced.
18pub const PROTOCOL_VERSION: u32 = 1;
19
20/// Typed error codes for [`Response::Error`].
21///
22/// Wire-compatible with the previous `i32` representation: serializes as
23/// `1` (`General`) or `2` (`NotFound`).  Unknown codes from future versions
24/// map to `General`.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(into = "i32", from = "i32")]
27pub enum ErrorCode {
28    General = 1,
29    NotFound = 2,
30}
31
32impl ErrorCode {
33    pub fn exit_code(self) -> i32 {
34        self as i32
35    }
36}
37
38impl From<i32> for ErrorCode {
39    fn from(v: i32) -> Self {
40        match v {
41            2 => Self::NotFound,
42            _ => Self::General,
43        }
44    }
45}
46
47impl From<ErrorCode> for i32 {
48    fn from(c: ErrorCode) -> i32 {
49        c as i32
50    }
51}
52
53/// Build the canonical URL for a managed process.
54///
55/// When `proxy_port` is `Some`, returns the subdomain-based proxy URL;
56/// otherwise returns a direct `127.0.0.1` URL.
57pub fn process_url(name: &str, port: u16, proxy_port: Option<u16>) -> String {
58    match proxy_port {
59        Some(pp) => format!("http://{}.localhost:{}", name, pp),
60        None => format!("http://127.0.0.1:{}", port),
61    }
62}
63
64/// A client-to-daemon request, serialized as tagged JSON.
65///
66/// # Examples
67///
68/// ```
69/// use agent_procs::protocol::Request;
70///
71/// let req = Request::Status;
72/// let json = serde_json::to_string(&req).unwrap();
73/// assert!(json.contains(r#""type":"Status""#));
74///
75/// let parsed: Request = serde_json::from_str(&json).unwrap();
76/// assert_eq!(parsed, req);
77/// ```
78#[must_use]
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[serde(tag = "type")]
81pub enum Request {
82    Run {
83        command: String,
84        name: Option<String>,
85        cwd: Option<String>,
86        #[serde(default)]
87        env: Option<HashMap<String, String>>,
88        #[serde(default)]
89        port: Option<u16>,
90    },
91    Stop {
92        target: String,
93    },
94    StopAll,
95    Restart {
96        target: String,
97    },
98    Status,
99    Logs {
100        target: Option<String>,
101        tail: usize,
102        follow: bool,
103        stderr: bool,
104        all: bool,
105        timeout_secs: Option<u64>,
106        #[serde(default)]
107        lines: Option<usize>,
108    },
109    Wait {
110        target: String,
111        until: Option<String>,
112        regex: bool,
113        exit: bool,
114        timeout_secs: Option<u64>,
115    },
116    Shutdown,
117    EnableProxy {
118        #[serde(default)]
119        proxy_port: Option<u16>,
120    },
121    Hello {
122        version: u32,
123    },
124    /// Catch-all for unknown request types from future protocol versions.
125    #[serde(other)]
126    Unknown,
127}
128
129/// A daemon-to-client response, serialized as tagged JSON.
130///
131/// # Examples
132///
133/// ```
134/// use agent_procs::protocol::Response;
135///
136/// let resp = Response::Ok { message: "done".into() };
137/// let json = serde_json::to_string(&resp).unwrap();
138/// let parsed: Response = serde_json::from_str(&json).unwrap();
139/// assert_eq!(parsed, resp);
140/// ```
141#[must_use]
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143#[serde(tag = "type")]
144pub enum Response {
145    Ok {
146        message: String,
147    },
148    RunOk {
149        name: String,
150        id: String,
151        pid: u32,
152        #[serde(default)]
153        port: Option<u16>,
154        #[serde(default)]
155        url: Option<String>,
156    },
157    Status {
158        processes: Vec<ProcessInfo>,
159    },
160    LogLine {
161        process: String,
162        stream: Stream,
163        line: String,
164    },
165    LogEnd,
166    WaitMatch {
167        line: String,
168    },
169    WaitExited {
170        exit_code: Option<i32>,
171    },
172    WaitTimeout,
173    Error {
174        code: ErrorCode,
175        message: String,
176    },
177    Hello {
178        version: u32,
179    },
180    /// Catch-all for unknown response types from future protocol versions.
181    #[serde(other)]
182    Unknown,
183}
184
185#[must_use]
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct ProcessInfo {
188    pub name: String,
189    pub id: String,
190    pub pid: u32,
191    pub state: ProcessState,
192    pub exit_code: Option<i32>,
193    pub uptime_secs: Option<u64>,
194    pub command: String,
195    #[serde(default)]
196    pub port: Option<u16>,
197    #[serde(default)]
198    pub url: Option<String>,
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "lowercase")]
203pub enum ProcessState {
204    Running,
205    Exited,
206}
207
208#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum Stream {
211    Stdout,
212    Stderr,
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_request_serde_roundtrip() {
221        let requests = vec![
222            Request::Run {
223                command: "echo hi".into(),
224                name: Some("test".into()),
225                cwd: None,
226                env: None,
227                port: None,
228            },
229            Request::Stop {
230                target: "test".into(),
231            },
232            Request::StopAll,
233            Request::Restart {
234                target: "test".into(),
235            },
236            Request::Status,
237            Request::Logs {
238                target: Some("test".into()),
239                tail: 10,
240                follow: true,
241                stderr: false,
242                all: false,
243                timeout_secs: Some(30),
244                lines: None,
245            },
246            Request::Wait {
247                target: "test".into(),
248                until: Some("ready".into()),
249                regex: false,
250                exit: false,
251                timeout_secs: Some(60),
252            },
253            Request::Shutdown,
254            Request::EnableProxy {
255                proxy_port: Some(8080),
256            },
257            Request::Hello {
258                version: PROTOCOL_VERSION,
259            },
260            Request::Unknown,
261        ];
262
263        for req in &requests {
264            let json = serde_json::to_string(req).unwrap();
265            let parsed: Request = serde_json::from_str(&json).unwrap();
266            assert_eq!(&parsed, req);
267        }
268    }
269
270    #[test]
271    fn test_response_serde_roundtrip() {
272        let responses = vec![
273            Response::Ok {
274                message: "done".into(),
275            },
276            Response::RunOk {
277                name: "web".into(),
278                id: "p1".into(),
279                pid: 1234,
280                port: Some(3000),
281                url: Some("http://127.0.0.1:3000".into()),
282            },
283            Response::Status { processes: vec![] },
284            Response::LogLine {
285                process: "web".into(),
286                stream: Stream::Stdout,
287                line: "hello".into(),
288            },
289            Response::LogEnd,
290            Response::WaitMatch {
291                line: "ready".into(),
292            },
293            Response::WaitExited { exit_code: Some(0) },
294            Response::WaitTimeout,
295            Response::Error {
296                code: ErrorCode::General,
297                message: "oops".into(),
298            },
299            Response::Hello {
300                version: PROTOCOL_VERSION,
301            },
302            Response::Unknown,
303        ];
304
305        for resp in &responses {
306            let json = serde_json::to_string(resp).unwrap();
307            let parsed: Response = serde_json::from_str(&json).unwrap();
308            assert_eq!(&parsed, resp);
309        }
310    }
311
312    #[test]
313    fn test_process_info_serde_roundtrip() {
314        let info = ProcessInfo {
315            name: "api".into(),
316            id: "p1".into(),
317            pid: 42,
318            state: ProcessState::Running,
319            exit_code: None,
320            uptime_secs: Some(120),
321            command: "cargo run".into(),
322            port: Some(8080),
323            url: Some("http://127.0.0.1:8080".into()),
324        };
325        let json = serde_json::to_string(&info).unwrap();
326        let parsed: ProcessInfo = serde_json::from_str(&json).unwrap();
327        assert_eq!(parsed, info);
328    }
329
330    #[test]
331    fn test_request_json_format() {
332        let run = Request::Run {
333            command: "ls".into(),
334            name: None,
335            cwd: None,
336            env: None,
337            port: None,
338        };
339        let json = serde_json::to_string(&run).unwrap();
340        assert!(json.contains("\"type\":\"Run\""));
341
342        let stop = Request::Stop { target: "x".into() };
343        let json = serde_json::to_string(&stop).unwrap();
344        assert!(json.contains("\"type\":\"Stop\""));
345
346        let status = Request::Status;
347        let json = serde_json::to_string(&status).unwrap();
348        assert!(json.contains("\"type\":\"Status\""));
349
350        let shutdown = Request::Shutdown;
351        let json = serde_json::to_string(&shutdown).unwrap();
352        assert!(json.contains("\"type\":\"Shutdown\""));
353
354        let stop_all = Request::StopAll;
355        let json = serde_json::to_string(&stop_all).unwrap();
356        assert!(json.contains("\"type\":\"StopAll\""));
357
358        let restart = Request::Restart { target: "x".into() };
359        let json = serde_json::to_string(&restart).unwrap();
360        assert!(json.contains("\"type\":\"Restart\""));
361
362        let enable_proxy = Request::EnableProxy { proxy_port: None };
363        let json = serde_json::to_string(&enable_proxy).unwrap();
364        assert!(json.contains("\"type\":\"EnableProxy\""));
365
366        let hello = Request::Hello { version: 1 };
367        let json = serde_json::to_string(&hello).unwrap();
368        assert!(json.contains("\"type\":\"Hello\""));
369    }
370
371    #[test]
372    fn test_unknown_request_deserialization() {
373        // Unknown types should deserialize to Unknown
374        let json = r#"{"type":"FutureRequestType","data":"something"}"#;
375        let parsed: Request = serde_json::from_str(json).unwrap();
376        assert_eq!(parsed, Request::Unknown);
377    }
378
379    #[test]
380    fn test_unknown_response_deserialization() {
381        let json = r#"{"type":"FutureResponseType","data":"something"}"#;
382        let parsed: Response = serde_json::from_str(json).unwrap();
383        assert_eq!(parsed, Response::Unknown);
384    }
385
386    #[test]
387    fn test_error_code_wire_format() {
388        let resp = Response::Error {
389            code: ErrorCode::NotFound,
390            message: "not found".into(),
391        };
392        let json = serde_json::to_string(&resp).unwrap();
393        assert!(json.contains("\"code\":2"));
394
395        // i32 codes from old clients deserialize correctly
396        let json = r#"{"type":"Error","code":2,"message":"not found"}"#;
397        let parsed: Response = serde_json::from_str(json).unwrap();
398        assert_eq!(
399            parsed,
400            Response::Error {
401                code: ErrorCode::NotFound,
402                message: "not found".into(),
403            }
404        );
405
406        // Unknown codes map to General
407        let json = r#"{"type":"Error","code":99,"message":"future error"}"#;
408        let parsed: Response = serde_json::from_str(json).unwrap();
409        if let Response::Error { code, .. } = parsed {
410            assert_eq!(code, ErrorCode::General);
411        } else {
412            panic!("expected Error");
413        }
414    }
415}