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": [...], "show_closed": <bool> }` tree snapshot. Back-compat is
24//! total: an older client never sends a subscription op, so it only ever sees the
25//! classic one-reply exchange.
26
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use super::service::ServiceStatus;
31use crate::build_info::Provenance;
32
33/// The reserved service name for the daemon's own built-in operations
34/// (`ping`, `status`, `shutdown`). A `None` `service` targets the same.
35pub const DAEMON_SERVICE: &str = "daemon";
36
37/// Maximum length, in bytes, of a single NDJSON line on the control socket.
38///
39/// Applies to both requests and replies, capping the per-connection read buffer
40/// so a peer that never sends a newline can't exhaust memory. 1 MiB is far above
41/// any real envelope.
42pub const MAX_LINE_BYTES: usize = 1024 * 1024;
43
44/// A request sent to the daemon over the control socket.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct DaemonEnvelope {
47 /// Target service [`name`](super::service::DaemonService::name). `None`
48 /// (or `"daemon"`) routes to the built-in daemon ops.
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub service: Option<String>,
51 /// Operation name, interpreted by the target service (or the daemon).
52 pub op: String,
53 /// Operation payload; `null` when the op takes no arguments.
54 #[serde(default, skip_serializing_if = "Value::is_null")]
55 pub payload: Value,
56 /// Originating client's request-log `invocation_id`, threaded across the
57 /// socket so daemon-side HTTP records correlate to the CLI/MCP invocation
58 /// that triggered them rather than the daemon's own (#1198). Non-secret.
59 /// Absent from older clients; the daemon simply skips the correlation then.
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub origin_invocation_id: Option<String>,
62}
63
64impl DaemonEnvelope {
65 /// Builds an envelope targeting a named service.
66 pub fn service(name: impl Into<String>, op: impl Into<String>, payload: Value) -> Self {
67 Self {
68 service: Some(name.into()),
69 op: op.into(),
70 payload,
71 origin_invocation_id: None,
72 }
73 }
74
75 /// Builds an envelope targeting the built-in daemon ops.
76 pub fn builtin(op: impl Into<String>) -> Self {
77 Self {
78 service: None,
79 op: op.into(),
80 payload: Value::Null,
81 origin_invocation_id: None,
82 }
83 }
84
85 /// Stamps the originating client's request-log `invocation_id` on the
86 /// envelope so the daemon can correlate the requests it serves back to the
87 /// caller's invocation (#1198).
88 #[must_use]
89 pub fn with_origin(mut self, invocation_id: impl Into<String>) -> Self {
90 self.origin_invocation_id = Some(invocation_id.into());
91 self
92 }
93}
94
95/// A response returned by the daemon over the control socket.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct DaemonReply {
98 /// Whether the operation succeeded.
99 pub ok: bool,
100 /// Success payload; `null` for ops that return nothing.
101 #[serde(default, skip_serializing_if = "Value::is_null")]
102 pub payload: Value,
103 /// Error message when [`ok`](Self::ok) is `false`.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub error: Option<String>,
106}
107
108impl DaemonReply {
109 /// Builds a successful reply carrying `payload`.
110 pub fn ok(payload: Value) -> Self {
111 Self {
112 ok: true,
113 payload,
114 error: None,
115 }
116 }
117
118 /// Builds a failure reply carrying an error message.
119 pub fn err(message: impl Into<String>) -> Self {
120 Self {
121 ok: false,
122 payload: Value::Null,
123 error: Some(message.into()),
124 }
125 }
126}
127
128/// The payload of a built-in `status` reply: per-service status snapshots.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct StatusReport {
131 /// One entry per registered service, in registration order.
132 pub services: Vec<ServiceStatus>,
133 /// The daemon binary's crate version (`CARGO_PKG_VERSION`), so a client can
134 /// detect it is driving a stale resident daemon after a binary upgrade
135 /// (#1113). Absent from a pre-#1113 daemon; a client shows "unknown" then.
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub version: Option<String>,
138 /// The daemon's latest GitHub API rate-limit reading (#1375), when its
139 /// monitor has polled at least once. Absent before the first poll, when no
140 /// `gh` is available, or on a pre-#1375 daemon — a client shows nothing then.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub github_rate_limit: Option<crate::github_rate_limit::RateLimitSnapshot>,
143 /// Git provenance (commit SHA, dirty flag, dates) of the daemon binary,
144 /// flattened as sibling fields of [`version`](Self::version) so two builds
145 /// of the same crate version can be told apart (#1374). Every field is
146 /// omitted when absent, so a daemon built without git metadata stays
147 /// byte-identical on the wire to a pre-#1374 one.
148 #[serde(flatten)]
149 pub provenance: Provenance,
150}
151
152impl StatusReport {
153 /// Builds a status report stamped with this daemon binary's own version and
154 /// git provenance (from [`crate::build_info`]). Runtime-only fields (e.g. the
155 /// GitHub rate-limit reading) default to absent and are injected by the
156 /// caller after construction.
157 #[must_use]
158 pub fn current(services: Vec<ServiceStatus>) -> Self {
159 Self {
160 services,
161 version: Some(crate::VERSION.to_string()),
162 provenance: crate::build_info::provenance(),
163 ..Self::default()
164 }
165 }
166}
167
168#[cfg(test)]
169#[allow(clippy::unwrap_used, clippy::expect_used)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn envelope_omits_origin_when_absent() {
175 // Without an origin the field is skipped, keeping the wire byte-identical
176 // to what older clients/servers exchange.
177 let line = serde_json::to_string(&DaemonEnvelope::service(
178 "snowflake",
179 "query",
180 serde_json::json!({ "sql": "SELECT 1" }),
181 ))
182 .unwrap();
183 assert!(!line.contains("origin_invocation_id"), "{line}");
184 }
185
186 #[test]
187 fn envelope_round_trips_origin() {
188 let env = DaemonEnvelope::service("snowflake", "query", Value::Null).with_origin("cli-42");
189 let line = serde_json::to_string(&env).unwrap();
190 assert!(line.contains("origin_invocation_id"), "{line}");
191 let back: DaemonEnvelope = serde_json::from_str(&line).unwrap();
192 assert_eq!(back.origin_invocation_id.as_deref(), Some("cli-42"));
193 }
194
195 #[test]
196 fn envelope_from_older_client_defaults_origin_to_none() {
197 // A line written before #1198 has no origin field; it must decode fine.
198 let back: DaemonEnvelope =
199 serde_json::from_str(r#"{"service":"snowflake","op":"query"}"#).unwrap();
200 assert!(back.origin_invocation_id.is_none());
201 }
202
203 #[test]
204 fn status_report_from_older_daemon_defaults_version_to_none() {
205 // A pre-#1113 daemon's status payload has no `version`; a newer client
206 // must still decode it, seeing `None` (rendered as "unknown").
207 let back: StatusReport = serde_json::from_str(r#"{"services":[]}"#).unwrap();
208 assert!(back.version.is_none());
209 }
210
211 #[test]
212 fn status_report_round_trips_and_omits_absent_version() {
213 // With no version and no provenance both are skipped, keeping the wire
214 // byte-identical to what an older client/daemon exchanges.
215 let line = serde_json::to_string(&StatusReport {
216 services: vec![],
217 version: None,
218 ..StatusReport::default()
219 })
220 .unwrap();
221 assert_eq!(line, r#"{"services":[]}"#, "{line}");
222
223 // With a version it serializes and round-trips.
224 let line = serde_json::to_string(&StatusReport {
225 services: vec![],
226 version: Some("1.2.3".to_string()),
227 ..StatusReport::default()
228 })
229 .unwrap();
230 assert!(line.contains("\"version\":\"1.2.3\""), "{line}");
231 let back: StatusReport = serde_json::from_str(&line).unwrap();
232 assert_eq!(back.version.as_deref(), Some("1.2.3"));
233 }
234
235 #[test]
236 fn status_report_omits_and_round_trips_github_rate_limit() {
237 use crate::github_rate_limit::{RateLimitResource, RateLimitSnapshot};
238
239 // Absent by default: byte-identical to a pre-#1375 daemon's payload.
240 let line = serde_json::to_string(&StatusReport {
241 services: vec![],
242 version: Some("1.2.3".to_string()),
243 github_rate_limit: None,
244 ..StatusReport::default()
245 })
246 .unwrap();
247 assert!(!line.contains("github_rate_limit"), "{line}");
248
249 // Present: serializes and decodes back, and a pre-#1375 client (which sees
250 // an unknown field) ignores it — serde does that automatically.
251 let snap = RateLimitSnapshot {
252 graphql: Some(RateLimitResource {
253 used: 4100,
254 limit: 5000,
255 remaining: 900,
256 percent: 82.0,
257 reset: 1_700_000_000,
258 }),
259 core: None,
260 search: None,
261 };
262 let line = serde_json::to_string(&StatusReport {
263 services: vec![],
264 version: None,
265 github_rate_limit: Some(snap),
266 ..StatusReport::default()
267 })
268 .unwrap();
269 assert!(line.contains("github_rate_limit"), "{line}");
270 let back: StatusReport = serde_json::from_str(&line).unwrap();
271 assert_eq!(back.github_rate_limit, Some(snap));
272 }
273
274 #[test]
275 fn status_report_flattens_provenance_as_siblings_and_round_trips() {
276 // Provenance fields render as siblings of `version`, not nested, and a
277 // newer client decodes them back into the flattened struct.
278 let report = StatusReport {
279 services: vec![],
280 version: Some("1.2.3".to_string()),
281 provenance: Provenance {
282 commit: Some("a6d304fd".to_string()),
283 dirty: Some(true),
284 ..Provenance::default()
285 },
286 ..StatusReport::default()
287 };
288 let line = serde_json::to_string(&report).unwrap();
289 assert!(line.contains("\"commit\":\"a6d304fd\""), "{line}");
290 assert!(line.contains("\"dirty\":true"), "{line}");
291 let back: StatusReport = serde_json::from_str(&line).unwrap();
292 assert_eq!(back.provenance.commit.as_deref(), Some("a6d304fd"));
293 assert_eq!(back.provenance.dirty, Some(true));
294 // Absent provenance fields decode to `None`, not an error.
295 assert!(back.provenance.commit_long.is_none());
296 }
297}