Skip to main content

mermaid_cli/runtime_client/
protocol.rs

1//! Typed daemon control protocol.
2//!
3//! Every JSON command a `mermaidd` socket client can send, as one enum —
4//! the wire shape (`{"command": "...", ...fields}`) is BYTE-IDENTICAL to
5//! the previous stringly dispatch; this is a protocol contract being made
6//! exhaustive, not a compat shim. The daemon parses requests into this
7//! enum (a typo becomes a serde error naming the field instead of a silent
8//! `unknown command`), and the client constructs requests from it (a
9//! misspelled literal becomes a compile error).
10//!
11//! Deliberately NO typed response enum: wire responses are heterogeneous
12//! per command and the client already owns typed response structs — the
13//! exhaustiveness win lives on the request side.
14
15use serde::{Deserialize, Serialize};
16
17/// One daemon control request. `auth.token` rides OUTSIDE this shape (the
18/// daemon reads it before the typed parse), so no variant carries it.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20#[serde(tag = "command", rename_all = "snake_case")]
21pub enum DaemonRequest {
22    Health,
23    CreateTask {
24        title: String,
25        project_path: String,
26        model_id: String,
27    },
28    SessionMessages {
29        id: String,
30    },
31    #[serde(alias = "runtime_snapshot")]
32    Snapshot,
33    RuntimeDashboard,
34    RuntimeDiagnostics,
35    RuntimeHygienePreview,
36    RuntimeHygieneArchive,
37    RuntimeTaskDetail {
38        id: String,
39    },
40    RuntimeApprovalDetail {
41        id: String,
42    },
43    RuntimeCheckpointDetail {
44        id: String,
45    },
46    RuntimeTasks {
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        limit: Option<u64>,
49    },
50    RuntimeProcesses {
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        limit: Option<u64>,
53    },
54    RuntimeToolRuns {
55        #[serde(default, skip_serializing_if = "Option::is_none")]
56        limit: Option<u64>,
57    },
58    RuntimeCheckpoints {
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        limit: Option<u64>,
61    },
62    RuntimeApprovals,
63    RuntimePlugins,
64    Run {
65        prompt: String,
66        /// Empty string means unset (kept for exact wire behavior).
67        #[serde(default, skip_serializing_if = "Option::is_none")]
68        project_path: Option<String>,
69        #[serde(default, skip_serializing_if = "Option::is_none")]
70        model_id: Option<String>,
71        #[serde(default, skip_serializing_if = "Option::is_none")]
72        priority: Option<String>,
73    },
74    CancelTask {
75        id: String,
76    },
77    /// Put a prompt into a task that is already running. Reaches the run's
78    /// `EngineHandle` mailbox, so the message lands in the same reducer, on
79    /// the same queue, as one the run produced itself.
80    SendToTask {
81        id: String,
82        text: String,
83    },
84    UpdateTask {
85        id: String,
86        status: String,
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        final_report: Option<String>,
89    },
90    Logs {
91        id: String,
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        tail_bytes: Option<u64>,
94    },
95    StopProcess {
96        id: String,
97    },
98    RestartProcess {
99        id: String,
100    },
101    OpenProcess {
102        id: String,
103    },
104    Ports,
105    RestoreCheckpoint {
106        id: String,
107    },
108    Approve {
109        id: String,
110    },
111    Deny {
112        id: String,
113    },
114    PluginPreview {
115        path: String,
116    },
117    PluginInstall {
118        path: String,
119    },
120    SetPluginEnabled {
121        id: String,
122        enabled: bool,
123    },
124    SetSafetyMode {
125        mode: String,
126    },
127    ModelInfo {
128        model: String,
129    },
130    Pair {
131        #[serde(default, skip_serializing_if = "Option::is_none")]
132        label: Option<String>,
133        #[serde(default, skip_serializing_if = "Option::is_none")]
134        ttl_days: Option<i64>,
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        token_hash: Option<String>,
137    },
138    /// Attach to a task's live `RunEvent` stream: ack line, then NDJSON
139    /// events until the terminal `result`. Streaming — handled outside the
140    /// one-shot request/response path.
141    SubscribeTask {
142        task_id: String,
143    },
144}
145
146impl DaemonRequest {
147    /// Whether the request needs the pairing token on the LOCAL socket
148    /// (TCP requires auth for everything). Exhaustive — adding a variant
149    /// forces a decision here. Session content flows through
150    /// `SubscribeTask`, so it is gated like `session_messages`.
151    #[must_use]
152    pub fn requires_auth(&self) -> bool {
153        match self {
154            Self::Health => false,
155            Self::CreateTask { .. }
156            | Self::Run { .. }
157            | Self::CancelTask { .. }
158            | Self::SendToTask { .. }
159            | Self::UpdateTask { .. }
160            | Self::RestoreCheckpoint { .. }
161            | Self::Approve { .. }
162            | Self::Deny { .. }
163            | Self::StopProcess { .. }
164            | Self::RestartProcess { .. }
165            | Self::OpenProcess { .. }
166            | Self::PluginPreview { .. }
167            | Self::PluginInstall { .. }
168            | Self::SetPluginEnabled { .. }
169            | Self::SetSafetyMode { .. }
170            | Self::RuntimeHygieneArchive
171            | Self::Pair { .. }
172            | Self::Logs { .. }
173            | Self::SessionMessages { .. }
174            | Self::Snapshot
175            | Self::RuntimeDashboard
176            | Self::RuntimeDiagnostics
177            | Self::RuntimeHygienePreview
178            | Self::RuntimeTaskDetail { .. }
179            | Self::RuntimeApprovalDetail { .. }
180            | Self::RuntimeCheckpointDetail { .. }
181            | Self::RuntimeTasks { .. }
182            | Self::RuntimeProcesses { .. }
183            | Self::RuntimeApprovals
184            | Self::RuntimeToolRuns { .. }
185            | Self::RuntimeCheckpoints { .. }
186            | Self::RuntimePlugins
187            | Self::ModelInfo { .. }
188            | Self::SubscribeTask { .. } => true,
189            // Liveness/discovery stay unauthenticated on the local socket:
190            // no project or credential content, and used before pairing.
191            Self::Ports => false,
192        }
193    }
194
195    /// Serialize to the wire `Value` (the shape `request_daemon` injects
196    /// `auth` into).
197    #[must_use]
198    pub fn to_wire(&self) -> serde_json::Value {
199        serde_json::to_value(self).expect("DaemonRequest serializes")
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    /// Golden-style: every variant round-trips through its EXACT wire form.
208    #[test]
209    #[expect(
210        clippy::too_many_lines,
211        reason = "a golden test: one (variant, exact wire JSON) pair per DaemonRequest variant, \
212         and the list grows with the protocol by design; the cases are the test"
213    )]
214    fn every_variant_round_trips_the_wire_shape() {
215        let cases: Vec<(DaemonRequest, &str)> = vec![
216            (DaemonRequest::Health, r#"{"command":"health"}"#),
217            (
218                DaemonRequest::CreateTask {
219                    title: "t".into(),
220                    project_path: "/p".into(),
221                    model_id: "m".into(),
222                },
223                r#"{"command":"create_task","title":"t","project_path":"/p","model_id":"m"}"#,
224            ),
225            (
226                DaemonRequest::SessionMessages { id: "s1".into() },
227                r#"{"command":"session_messages","id":"s1"}"#,
228            ),
229            (DaemonRequest::Snapshot, r#"{"command":"snapshot"}"#),
230            (
231                DaemonRequest::RuntimeDashboard,
232                r#"{"command":"runtime_dashboard"}"#,
233            ),
234            (
235                DaemonRequest::RuntimeDiagnostics,
236                r#"{"command":"runtime_diagnostics"}"#,
237            ),
238            (
239                DaemonRequest::RuntimeHygienePreview,
240                r#"{"command":"runtime_hygiene_preview"}"#,
241            ),
242            (
243                DaemonRequest::RuntimeHygieneArchive,
244                r#"{"command":"runtime_hygiene_archive"}"#,
245            ),
246            (
247                DaemonRequest::RuntimeTaskDetail { id: "t1".into() },
248                r#"{"command":"runtime_task_detail","id":"t1"}"#,
249            ),
250            (
251                DaemonRequest::RuntimeApprovalDetail { id: "a1".into() },
252                r#"{"command":"runtime_approval_detail","id":"a1"}"#,
253            ),
254            (
255                DaemonRequest::RuntimeCheckpointDetail { id: "c1".into() },
256                r#"{"command":"runtime_checkpoint_detail","id":"c1"}"#,
257            ),
258            (
259                DaemonRequest::RuntimeTasks { limit: Some(50) },
260                r#"{"command":"runtime_tasks","limit":50}"#,
261            ),
262            (
263                DaemonRequest::RuntimeProcesses { limit: None },
264                r#"{"command":"runtime_processes"}"#,
265            ),
266            (
267                DaemonRequest::RuntimeToolRuns { limit: Some(100) },
268                r#"{"command":"runtime_tool_runs","limit":100}"#,
269            ),
270            (
271                DaemonRequest::RuntimeCheckpoints { limit: Some(50) },
272                r#"{"command":"runtime_checkpoints","limit":50}"#,
273            ),
274            (
275                DaemonRequest::RuntimeApprovals,
276                r#"{"command":"runtime_approvals"}"#,
277            ),
278            (
279                DaemonRequest::RuntimePlugins,
280                r#"{"command":"runtime_plugins"}"#,
281            ),
282            (
283                DaemonRequest::Run {
284                    prompt: "p".into(),
285                    project_path: Some(String::new()),
286                    model_id: None,
287                    priority: Some("high".into()),
288                },
289                r#"{"command":"run","prompt":"p","project_path":"","priority":"high"}"#,
290            ),
291            (
292                DaemonRequest::CancelTask { id: "t1".into() },
293                r#"{"command":"cancel_task","id":"t1"}"#,
294            ),
295            (
296                DaemonRequest::UpdateTask {
297                    id: "t1".into(),
298                    status: "completed".into(),
299                    final_report: Some("done".into()),
300                },
301                r#"{"command":"update_task","id":"t1","status":"completed","final_report":"done"}"#,
302            ),
303            (
304                DaemonRequest::Logs {
305                    id: "p1".into(),
306                    tail_bytes: Some(4096),
307                },
308                r#"{"command":"logs","id":"p1","tail_bytes":4096}"#,
309            ),
310            (
311                DaemonRequest::StopProcess { id: "p1".into() },
312                r#"{"command":"stop_process","id":"p1"}"#,
313            ),
314            (
315                DaemonRequest::RestartProcess { id: "p1".into() },
316                r#"{"command":"restart_process","id":"p1"}"#,
317            ),
318            (
319                DaemonRequest::OpenProcess { id: "p1".into() },
320                r#"{"command":"open_process","id":"p1"}"#,
321            ),
322            (DaemonRequest::Ports, r#"{"command":"ports"}"#),
323            (
324                DaemonRequest::RestoreCheckpoint { id: "c1".into() },
325                r#"{"command":"restore_checkpoint","id":"c1"}"#,
326            ),
327            (
328                DaemonRequest::Approve { id: "a1".into() },
329                r#"{"command":"approve","id":"a1"}"#,
330            ),
331            (
332                DaemonRequest::Deny { id: "a1".into() },
333                r#"{"command":"deny","id":"a1"}"#,
334            ),
335            (
336                DaemonRequest::PluginPreview { path: "/pl".into() },
337                r#"{"command":"plugin_preview","path":"/pl"}"#,
338            ),
339            (
340                DaemonRequest::PluginInstall { path: "/pl".into() },
341                r#"{"command":"plugin_install","path":"/pl"}"#,
342            ),
343            (
344                DaemonRequest::SetPluginEnabled {
345                    id: "pl1".into(),
346                    enabled: true,
347                },
348                r#"{"command":"set_plugin_enabled","id":"pl1","enabled":true}"#,
349            ),
350            (
351                DaemonRequest::SetSafetyMode { mode: "ask".into() },
352                r#"{"command":"set_safety_mode","mode":"ask"}"#,
353            ),
354            (
355                DaemonRequest::ModelInfo { model: "m".into() },
356                r#"{"command":"model_info","model":"m"}"#,
357            ),
358            (
359                DaemonRequest::Pair {
360                    label: Some("laptop".into()),
361                    ttl_days: Some(30),
362                    token_hash: None,
363                },
364                r#"{"command":"pair","label":"laptop","ttl_days":30}"#,
365            ),
366            (
367                DaemonRequest::SubscribeTask {
368                    task_id: "t1".into(),
369                },
370                r#"{"command":"subscribe_task","task_id":"t1"}"#,
371            ),
372        ];
373        for (req, wire) in cases {
374            assert_eq!(serde_json::to_string(&req).unwrap(), wire, "{req:?}");
375            let back: DaemonRequest = serde_json::from_str(wire).unwrap();
376            assert_eq!(back, req, "{wire}");
377        }
378    }
379
380    #[test]
381    fn runtime_snapshot_alias_still_parses() {
382        let req: DaemonRequest = serde_json::from_str(r#"{"command":"runtime_snapshot"}"#).unwrap();
383        assert_eq!(req, DaemonRequest::Snapshot);
384    }
385
386    #[test]
387    fn requires_auth_matches_the_historical_matrix() {
388        assert!(!DaemonRequest::Health.requires_auth());
389        assert!(!DaemonRequest::Ports.requires_auth());
390        assert!(DaemonRequest::ModelInfo { model: "m".into() }.requires_auth());
391        for gated in [
392            DaemonRequest::Run {
393                prompt: "p".into(),
394                project_path: None,
395                model_id: None,
396                priority: None,
397            },
398            DaemonRequest::Snapshot,
399            DaemonRequest::SessionMessages { id: "s".into() },
400            DaemonRequest::Logs {
401                id: "p".into(),
402                tail_bytes: None,
403            },
404            DaemonRequest::Pair {
405                label: None,
406                ttl_days: None,
407                token_hash: None,
408            },
409            DaemonRequest::SubscribeTask {
410                task_id: "t".into(),
411            },
412        ] {
413            assert!(gated.requires_auth(), "{gated:?}");
414        }
415    }
416
417    #[test]
418    fn unknown_command_is_a_parse_error() {
419        assert!(serde_json::from_str::<DaemonRequest>(r#"{"command":"nope"}"#).is_err());
420    }
421}