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/// Restart behavior for supervised processes.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct RestartPolicy {
56    pub mode: RestartMode,
57    pub max_restarts: Option<u32>,
58    pub restart_delay_ms: u64,
59}
60
61/// When a process should be automatically restarted.
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum RestartMode {
65    Always,
66    OnFailure,
67    Never,
68}
69
70impl RestartMode {
71    /// Parse a mode string (from CLI or config). Unknown values map to `Never`.
72    pub fn parse(s: &str) -> Self {
73        match s {
74            "always" => Self::Always,
75            "on-failure" => Self::OnFailure,
76            _ => Self::Never,
77        }
78    }
79
80    /// Whether this mode should trigger a restart given the exit code.
81    pub fn should_restart(self, exit_code: Option<i32>) -> bool {
82        match self {
83            Self::Never => false,
84            Self::Always => true,
85            Self::OnFailure => exit_code != Some(0),
86        }
87    }
88}
89
90impl RestartPolicy {
91    /// Build from CLI/config string arguments.
92    pub fn from_args(mode: &str, max_restarts: Option<u32>, restart_delay: Option<u64>) -> Self {
93        Self {
94            mode: RestartMode::parse(mode),
95            max_restarts,
96            restart_delay_ms: restart_delay.unwrap_or(1000),
97        }
98    }
99}
100
101impl WatchConfig {
102    /// Build from CLI/config path and ignore lists. Returns `None` if paths is empty.
103    pub fn from_args(paths: Vec<String>, ignore: Vec<String>) -> Option<Self> {
104        if paths.is_empty() {
105            None
106        } else {
107            Some(Self {
108                paths,
109                ignore: if ignore.is_empty() {
110                    None
111                } else {
112                    Some(ignore)
113                },
114            })
115        }
116    }
117}
118
119/// File-watch configuration for auto-restart on changes.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct WatchConfig {
122    pub paths: Vec<String>,
123    #[serde(default)]
124    pub ignore: Option<Vec<String>>,
125}
126
127/// Build the canonical URL for a managed process.
128///
129/// When `proxy_port` is `Some`, returns the subdomain-based proxy URL;
130/// otherwise returns a direct `127.0.0.1` URL.
131pub fn process_url(name: &str, port: u16, proxy_port: Option<u16>) -> String {
132    match proxy_port {
133        Some(pp) => format!("http://{}.localhost:{}", name, pp),
134        None => format!("http://127.0.0.1:{}", port),
135    }
136}
137
138/// A client-to-daemon request, serialized as tagged JSON.
139///
140/// # Examples
141///
142/// ```
143/// use agent_procs::protocol::Request;
144///
145/// let req = Request::Status;
146/// let json = serde_json::to_string(&req).unwrap();
147/// assert!(json.contains(r#""type":"Status""#));
148///
149/// let parsed: Request = serde_json::from_str(&json).unwrap();
150/// assert_eq!(parsed, req);
151/// ```
152#[must_use]
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(tag = "type")]
155pub enum Request {
156    Run {
157        command: String,
158        name: Option<String>,
159        cwd: Option<String>,
160        #[serde(default)]
161        env: Option<HashMap<String, String>>,
162        #[serde(default)]
163        port: Option<u16>,
164        #[serde(default)]
165        restart: Option<RestartPolicy>,
166        #[serde(default)]
167        watch: Option<WatchConfig>,
168    },
169    Stop {
170        target: String,
171    },
172    StopAll,
173    Restart {
174        target: String,
175    },
176    Status,
177    Logs {
178        target: Option<String>,
179        tail: usize,
180        follow: bool,
181        stderr: bool,
182        all: bool,
183        timeout_secs: Option<u64>,
184        #[serde(default)]
185        lines: Option<usize>,
186    },
187    Wait {
188        target: String,
189        until: Option<String>,
190        regex: bool,
191        exit: bool,
192        timeout_secs: Option<u64>,
193    },
194    Shutdown,
195    EnableProxy {
196        #[serde(default)]
197        proxy_port: Option<u16>,
198    },
199    Hello {
200        version: u32,
201    },
202    /// Catch-all for unknown request types from future protocol versions.
203    #[serde(other)]
204    Unknown,
205}
206
207/// A daemon-to-client response, serialized as tagged JSON.
208///
209/// # Examples
210///
211/// ```
212/// use agent_procs::protocol::Response;
213///
214/// let resp = Response::Ok { message: "done".into() };
215/// let json = serde_json::to_string(&resp).unwrap();
216/// let parsed: Response = serde_json::from_str(&json).unwrap();
217/// assert_eq!(parsed, resp);
218/// ```
219#[must_use]
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(tag = "type")]
222pub enum Response {
223    Ok {
224        message: String,
225    },
226    RunOk {
227        name: String,
228        id: String,
229        pid: u32,
230        #[serde(default)]
231        port: Option<u16>,
232        #[serde(default)]
233        url: Option<String>,
234    },
235    Status {
236        processes: Vec<ProcessInfo>,
237    },
238    LogLine {
239        process: String,
240        stream: Stream,
241        line: String,
242    },
243    LogEnd,
244    WaitMatch {
245        line: String,
246    },
247    WaitExited {
248        exit_code: Option<i32>,
249    },
250    WaitTimeout,
251    Error {
252        code: ErrorCode,
253        message: String,
254    },
255    Hello {
256        version: u32,
257    },
258    /// Catch-all for unknown response types from future protocol versions.
259    #[serde(other)]
260    Unknown,
261}
262
263#[must_use]
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct ProcessInfo {
266    pub name: String,
267    pub id: String,
268    pub pid: u32,
269    pub state: ProcessState,
270    pub exit_code: Option<i32>,
271    pub uptime_secs: Option<u64>,
272    pub command: String,
273    #[serde(default)]
274    pub port: Option<u16>,
275    #[serde(default)]
276    pub url: Option<String>,
277    #[serde(default)]
278    pub restart_count: Option<u32>,
279    #[serde(default)]
280    pub max_restarts: Option<u32>,
281    #[serde(default)]
282    pub restart_policy: Option<String>,
283    #[serde(default)]
284    pub watched: Option<bool>,
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize)]
288#[serde(rename_all = "lowercase")]
289pub enum ProcessState {
290    Running,
291    Exited,
292    Failed,
293    Unknown,
294}
295
296impl std::fmt::Display for ProcessState {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            Self::Running => write!(f, "running"),
300            Self::Exited => write!(f, "exited"),
301            Self::Failed => write!(f, "FAILED"),
302            Self::Unknown => write!(f, "unknown"),
303        }
304    }
305}
306
307impl<'de> serde::Deserialize<'de> for ProcessState {
308    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
309    where
310        D: serde::Deserializer<'de>,
311    {
312        let s = String::deserialize(deserializer)?;
313        Ok(match s.as_str() {
314            "running" => Self::Running,
315            "exited" => Self::Exited,
316            "failed" => Self::Failed,
317            _ => Self::Unknown,
318        })
319    }
320}
321
322#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "lowercase")]
324pub enum Stream {
325    Stdout,
326    Stderr,
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_request_serde_roundtrip() {
335        let requests = vec![
336            Request::Run {
337                command: "echo hi".into(),
338                name: Some("test".into()),
339                cwd: None,
340                env: None,
341                port: None,
342                restart: None,
343                watch: None,
344            },
345            Request::Stop {
346                target: "test".into(),
347            },
348            Request::StopAll,
349            Request::Restart {
350                target: "test".into(),
351            },
352            Request::Status,
353            Request::Logs {
354                target: Some("test".into()),
355                tail: 10,
356                follow: true,
357                stderr: false,
358                all: false,
359                timeout_secs: Some(30),
360                lines: None,
361            },
362            Request::Wait {
363                target: "test".into(),
364                until: Some("ready".into()),
365                regex: false,
366                exit: false,
367                timeout_secs: Some(60),
368            },
369            Request::Shutdown,
370            Request::EnableProxy {
371                proxy_port: Some(8080),
372            },
373            Request::Hello {
374                version: PROTOCOL_VERSION,
375            },
376            Request::Unknown,
377        ];
378
379        for req in &requests {
380            let json = serde_json::to_string(req).unwrap();
381            let parsed: Request = serde_json::from_str(&json).unwrap();
382            assert_eq!(&parsed, req);
383        }
384    }
385
386    #[test]
387    fn test_response_serde_roundtrip() {
388        let responses = vec![
389            Response::Ok {
390                message: "done".into(),
391            },
392            Response::RunOk {
393                name: "web".into(),
394                id: "p1".into(),
395                pid: 1234,
396                port: Some(3000),
397                url: Some("http://127.0.0.1:3000".into()),
398            },
399            Response::Status { processes: vec![] },
400            Response::LogLine {
401                process: "web".into(),
402                stream: Stream::Stdout,
403                line: "hello".into(),
404            },
405            Response::LogEnd,
406            Response::WaitMatch {
407                line: "ready".into(),
408            },
409            Response::WaitExited { exit_code: Some(0) },
410            Response::WaitTimeout,
411            Response::Error {
412                code: ErrorCode::General,
413                message: "oops".into(),
414            },
415            Response::Hello {
416                version: PROTOCOL_VERSION,
417            },
418            Response::Unknown,
419        ];
420
421        for resp in &responses {
422            let json = serde_json::to_string(resp).unwrap();
423            let parsed: Response = serde_json::from_str(&json).unwrap();
424            assert_eq!(&parsed, resp);
425        }
426    }
427
428    #[test]
429    fn test_process_info_serde_roundtrip() {
430        let info = ProcessInfo {
431            name: "api".into(),
432            id: "p1".into(),
433            pid: 42,
434            state: ProcessState::Running,
435            exit_code: None,
436            uptime_secs: Some(120),
437            command: "cargo run".into(),
438            port: Some(8080),
439            url: Some("http://127.0.0.1:8080".into()),
440            restart_count: None,
441            max_restarts: None,
442            restart_policy: None,
443            watched: None,
444        };
445        let json = serde_json::to_string(&info).unwrap();
446        let parsed: ProcessInfo = serde_json::from_str(&json).unwrap();
447        assert_eq!(parsed, info);
448    }
449
450    #[test]
451    fn test_request_json_format() {
452        let run = Request::Run {
453            command: "ls".into(),
454            name: None,
455            cwd: None,
456            env: None,
457            port: None,
458            restart: None,
459            watch: None,
460        };
461        let json = serde_json::to_string(&run).unwrap();
462        assert!(json.contains("\"type\":\"Run\""));
463
464        let stop = Request::Stop { target: "x".into() };
465        let json = serde_json::to_string(&stop).unwrap();
466        assert!(json.contains("\"type\":\"Stop\""));
467
468        let status = Request::Status;
469        let json = serde_json::to_string(&status).unwrap();
470        assert!(json.contains("\"type\":\"Status\""));
471
472        let shutdown = Request::Shutdown;
473        let json = serde_json::to_string(&shutdown).unwrap();
474        assert!(json.contains("\"type\":\"Shutdown\""));
475
476        let stop_all = Request::StopAll;
477        let json = serde_json::to_string(&stop_all).unwrap();
478        assert!(json.contains("\"type\":\"StopAll\""));
479
480        let restart = Request::Restart { target: "x".into() };
481        let json = serde_json::to_string(&restart).unwrap();
482        assert!(json.contains("\"type\":\"Restart\""));
483
484        let enable_proxy = Request::EnableProxy { proxy_port: None };
485        let json = serde_json::to_string(&enable_proxy).unwrap();
486        assert!(json.contains("\"type\":\"EnableProxy\""));
487
488        let hello = Request::Hello { version: 1 };
489        let json = serde_json::to_string(&hello).unwrap();
490        assert!(json.contains("\"type\":\"Hello\""));
491    }
492
493    #[test]
494    fn test_unknown_request_deserialization() {
495        // Unknown types should deserialize to Unknown
496        let json = r#"{"type":"FutureRequestType","data":"something"}"#;
497        let parsed: Request = serde_json::from_str(json).unwrap();
498        assert_eq!(parsed, Request::Unknown);
499    }
500
501    #[test]
502    fn test_unknown_response_deserialization() {
503        let json = r#"{"type":"FutureResponseType","data":"something"}"#;
504        let parsed: Response = serde_json::from_str(json).unwrap();
505        assert_eq!(parsed, Response::Unknown);
506    }
507
508    #[test]
509    fn test_error_code_wire_format() {
510        let resp = Response::Error {
511            code: ErrorCode::NotFound,
512            message: "not found".into(),
513        };
514        let json = serde_json::to_string(&resp).unwrap();
515        assert!(json.contains("\"code\":2"));
516
517        // i32 codes from old clients deserialize correctly
518        let json = r#"{"type":"Error","code":2,"message":"not found"}"#;
519        let parsed: Response = serde_json::from_str(json).unwrap();
520        assert_eq!(
521            parsed,
522            Response::Error {
523                code: ErrorCode::NotFound,
524                message: "not found".into(),
525            }
526        );
527
528        // Unknown codes map to General
529        let json = r#"{"type":"Error","code":99,"message":"future error"}"#;
530        let parsed: Response = serde_json::from_str(json).unwrap();
531        if let Response::Error { code, .. } = parsed {
532            assert_eq!(code, ErrorCode::General);
533        } else {
534            panic!("expected Error");
535        }
536    }
537}