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//!
8//! ## Subscriptions (streaming replies)
9//!
10//! Most ops are strictly request→one-reply. A **subscription** op is the one
11//! exception (#1267): when a service recognises the op as streaming (via
12//! [`DaemonService::subscribe`](super::service::DaemonService::subscribe)) the
13//! server switches that connection to push mode and emits **many**
14//! [`DaemonReply`] lines on the same connection — an initial snapshot, then a
15//! fresh snapshot each time the service's state changes (coalesced, and diffed
16//! so two identical frames are never sent in a row). Each pushed line is an
17//! ordinary `DaemonReply::ok(payload)`; there is **no** new wire type, so a
18//! reader distinguishes a subscription only by continuing to read lines instead
19//! of stopping after one. The stream ends when the client sends any further line
20//! (an explicit cancel), disconnects, or the daemon shuts down.
21//!
22//! The only subscription today is `worktrees` / `subscribe`, whose payload is
23//! the `{ "repos": [...] }` tree snapshot. Back-compat is total: an older client
24//! never sends a subscription op, so it only ever sees the classic one-reply
25//! exchange.
26
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use super::service::ServiceStatus;
31
32/// The reserved service name for the daemon's own built-in operations
33/// (`ping`, `status`, `shutdown`). A `None` `service` targets the same.
34pub const DAEMON_SERVICE: &str = "daemon";
35
36/// Maximum length, in bytes, of a single NDJSON line on the control socket.
37///
38/// Applies to both requests and replies, capping the per-connection read buffer
39/// so a peer that never sends a newline can't exhaust memory. 1 MiB is far above
40/// any real envelope.
41pub const MAX_LINE_BYTES: usize = 1024 * 1024;
42
43/// A request sent to the daemon over the control socket.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct DaemonEnvelope {
46    /// Target service [`name`](super::service::DaemonService::name). `None`
47    /// (or `"daemon"`) routes to the built-in daemon ops.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub service: Option<String>,
50    /// Operation name, interpreted by the target service (or the daemon).
51    pub op: String,
52    /// Operation payload; `null` when the op takes no arguments.
53    #[serde(default, skip_serializing_if = "Value::is_null")]
54    pub payload: Value,
55    /// Originating client's request-log `invocation_id`, threaded across the
56    /// socket so daemon-side HTTP records correlate to the CLI/MCP invocation
57    /// that triggered them rather than the daemon's own (#1198). Non-secret.
58    /// Absent from older clients; the daemon simply skips the correlation then.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub origin_invocation_id: Option<String>,
61}
62
63impl DaemonEnvelope {
64    /// Builds an envelope targeting a named service.
65    pub fn service(name: impl Into<String>, op: impl Into<String>, payload: Value) -> Self {
66        Self {
67            service: Some(name.into()),
68            op: op.into(),
69            payload,
70            origin_invocation_id: None,
71        }
72    }
73
74    /// Builds an envelope targeting the built-in daemon ops.
75    pub fn builtin(op: impl Into<String>) -> Self {
76        Self {
77            service: None,
78            op: op.into(),
79            payload: Value::Null,
80            origin_invocation_id: None,
81        }
82    }
83
84    /// Stamps the originating client's request-log `invocation_id` on the
85    /// envelope so the daemon can correlate the requests it serves back to the
86    /// caller's invocation (#1198).
87    #[must_use]
88    pub fn with_origin(mut self, invocation_id: impl Into<String>) -> Self {
89        self.origin_invocation_id = Some(invocation_id.into());
90        self
91    }
92}
93
94/// A response returned by the daemon over the control socket.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct DaemonReply {
97    /// Whether the operation succeeded.
98    pub ok: bool,
99    /// Success payload; `null` for ops that return nothing.
100    #[serde(default, skip_serializing_if = "Value::is_null")]
101    pub payload: Value,
102    /// Error message when [`ok`](Self::ok) is `false`.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub error: Option<String>,
105}
106
107impl DaemonReply {
108    /// Builds a successful reply carrying `payload`.
109    pub fn ok(payload: Value) -> Self {
110        Self {
111            ok: true,
112            payload,
113            error: None,
114        }
115    }
116
117    /// Builds a failure reply carrying an error message.
118    pub fn err(message: impl Into<String>) -> Self {
119        Self {
120            ok: false,
121            payload: Value::Null,
122            error: Some(message.into()),
123        }
124    }
125}
126
127/// The payload of a built-in `status` reply: per-service status snapshots.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct StatusReport {
130    /// One entry per registered service, in registration order.
131    pub services: Vec<ServiceStatus>,
132}
133
134#[cfg(test)]
135#[allow(clippy::unwrap_used, clippy::expect_used)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn envelope_omits_origin_when_absent() {
141        // Without an origin the field is skipped, keeping the wire byte-identical
142        // to what older clients/servers exchange.
143        let line = serde_json::to_string(&DaemonEnvelope::service(
144            "snowflake",
145            "query",
146            serde_json::json!({ "sql": "SELECT 1" }),
147        ))
148        .unwrap();
149        assert!(!line.contains("origin_invocation_id"), "{line}");
150    }
151
152    #[test]
153    fn envelope_round_trips_origin() {
154        let env = DaemonEnvelope::service("snowflake", "query", Value::Null).with_origin("cli-42");
155        let line = serde_json::to_string(&env).unwrap();
156        assert!(line.contains("origin_invocation_id"), "{line}");
157        let back: DaemonEnvelope = serde_json::from_str(&line).unwrap();
158        assert_eq!(back.origin_invocation_id.as_deref(), Some("cli-42"));
159    }
160
161    #[test]
162    fn envelope_from_older_client_defaults_origin_to_none() {
163        // A line written before #1198 has no origin field; it must decode fine.
164        let back: DaemonEnvelope =
165            serde_json::from_str(r#"{"service":"snowflake","op":"query"}"#).unwrap();
166        assert!(back.origin_invocation_id.is_none());
167    }
168}