Skip to main content

omni_dev/daemon/
protocol.rs

1//! Wire types for the daemon's Unix-domain control socket.
2//!
3//! The control plane speaks newline-delimited JSON (NDJSON): one
4//! [`DaemonEnvelope`] request per line, one [`DaemonReply`] response per line.
5//! New optional fields use `#[serde(default, skip_serializing_if = ...)]` so
6//! older peers stay byte-compatible on the wire.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use super::service::ServiceStatus;
12
13/// The reserved service name for the daemon's own built-in operations
14/// (`ping`, `status`, `shutdown`). A `None` `service` targets the same.
15pub const DAEMON_SERVICE: &str = "daemon";
16
17/// Maximum length, in bytes, of a single NDJSON line on the control socket.
18///
19/// Applies to both requests and replies, capping the per-connection read buffer
20/// so a peer that never sends a newline can't exhaust memory. 1 MiB is far above
21/// any real envelope.
22pub const MAX_LINE_BYTES: usize = 1024 * 1024;
23
24/// A request sent to the daemon over the control socket.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DaemonEnvelope {
27    /// Target service [`name`](super::service::DaemonService::name). `None`
28    /// (or `"daemon"`) routes to the built-in daemon ops.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub service: Option<String>,
31    /// Operation name, interpreted by the target service (or the daemon).
32    pub op: String,
33    /// Operation payload; `null` when the op takes no arguments.
34    #[serde(default, skip_serializing_if = "Value::is_null")]
35    pub payload: Value,
36    /// Originating client's request-log `invocation_id`, threaded across the
37    /// socket so daemon-side HTTP records correlate to the CLI/MCP invocation
38    /// that triggered them rather than the daemon's own (#1198). Non-secret.
39    /// Absent from older clients; the daemon simply skips the correlation then.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub origin_invocation_id: Option<String>,
42}
43
44impl DaemonEnvelope {
45    /// Builds an envelope targeting a named service.
46    pub fn service(name: impl Into<String>, op: impl Into<String>, payload: Value) -> Self {
47        Self {
48            service: Some(name.into()),
49            op: op.into(),
50            payload,
51            origin_invocation_id: None,
52        }
53    }
54
55    /// Builds an envelope targeting the built-in daemon ops.
56    pub fn builtin(op: impl Into<String>) -> Self {
57        Self {
58            service: None,
59            op: op.into(),
60            payload: Value::Null,
61            origin_invocation_id: None,
62        }
63    }
64
65    /// Stamps the originating client's request-log `invocation_id` on the
66    /// envelope so the daemon can correlate the requests it serves back to the
67    /// caller's invocation (#1198).
68    #[must_use]
69    pub fn with_origin(mut self, invocation_id: impl Into<String>) -> Self {
70        self.origin_invocation_id = Some(invocation_id.into());
71        self
72    }
73}
74
75/// A response returned by the daemon over the control socket.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct DaemonReply {
78    /// Whether the operation succeeded.
79    pub ok: bool,
80    /// Success payload; `null` for ops that return nothing.
81    #[serde(default, skip_serializing_if = "Value::is_null")]
82    pub payload: Value,
83    /// Error message when [`ok`](Self::ok) is `false`.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub error: Option<String>,
86}
87
88impl DaemonReply {
89    /// Builds a successful reply carrying `payload`.
90    pub fn ok(payload: Value) -> Self {
91        Self {
92            ok: true,
93            payload,
94            error: None,
95        }
96    }
97
98    /// Builds a failure reply carrying an error message.
99    pub fn err(message: impl Into<String>) -> Self {
100        Self {
101            ok: false,
102            payload: Value::Null,
103            error: Some(message.into()),
104        }
105    }
106}
107
108/// The payload of a built-in `status` reply: per-service status snapshots.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct StatusReport {
111    /// One entry per registered service, in registration order.
112    pub services: Vec<ServiceStatus>,
113}
114
115#[cfg(test)]
116#[allow(clippy::unwrap_used, clippy::expect_used)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn envelope_omits_origin_when_absent() {
122        // Without an origin the field is skipped, keeping the wire byte-identical
123        // to what older clients/servers exchange.
124        let line = serde_json::to_string(&DaemonEnvelope::service(
125            "snowflake",
126            "query",
127            serde_json::json!({ "sql": "SELECT 1" }),
128        ))
129        .unwrap();
130        assert!(!line.contains("origin_invocation_id"), "{line}");
131    }
132
133    #[test]
134    fn envelope_round_trips_origin() {
135        let env = DaemonEnvelope::service("snowflake", "query", Value::Null).with_origin("cli-42");
136        let line = serde_json::to_string(&env).unwrap();
137        assert!(line.contains("origin_invocation_id"), "{line}");
138        let back: DaemonEnvelope = serde_json::from_str(&line).unwrap();
139        assert_eq!(back.origin_invocation_id.as_deref(), Some("cli-42"));
140    }
141
142    #[test]
143    fn envelope_from_older_client_defaults_origin_to_none() {
144        // A line written before #1198 has no origin field; it must decode fine.
145        let back: DaemonEnvelope =
146            serde_json::from_str(r#"{"service":"snowflake","op":"query"}"#).unwrap();
147        assert!(back.origin_invocation_id.is_none());
148    }
149}