Skip to main content

mermaid_cli/runtime/
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    UpdateTask {
78        id: String,
79        status: String,
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        final_report: Option<String>,
82    },
83    Logs {
84        id: String,
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        tail_bytes: Option<u64>,
87    },
88    StopProcess {
89        id: String,
90    },
91    RestartProcess {
92        id: String,
93    },
94    OpenProcess {
95        id: String,
96    },
97    Ports,
98    RestoreCheckpoint {
99        id: String,
100    },
101    Approve {
102        id: String,
103    },
104    Deny {
105        id: String,
106    },
107    PluginPreview {
108        path: String,
109    },
110    PluginInstall {
111        path: String,
112    },
113    SetPluginEnabled {
114        id: String,
115        enabled: bool,
116    },
117    SetSafetyMode {
118        mode: String,
119    },
120    ModelInfo {
121        model: String,
122    },
123    Pair {
124        #[serde(default, skip_serializing_if = "Option::is_none")]
125        label: Option<String>,
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        ttl_days: Option<i64>,
128        #[serde(default, skip_serializing_if = "Option::is_none")]
129        token_hash: Option<String>,
130    },
131    /// Attach to a task's live `RunEvent` stream: ack line, then NDJSON
132    /// events until the terminal `result`. Streaming — handled outside the
133    /// one-shot request/response path.
134    SubscribeTask {
135        task_id: String,
136    },
137}
138
139impl DaemonRequest {
140    /// Whether the request needs the pairing token on the LOCAL socket
141    /// (TCP requires auth for everything). Exhaustive — adding a variant
142    /// forces a decision here. Session content flows through
143    /// `SubscribeTask`, so it is gated like `session_messages`.
144    pub fn requires_auth(&self) -> bool {
145        match self {
146            DaemonRequest::Health => false,
147            DaemonRequest::CreateTask { .. }
148            | DaemonRequest::Run { .. }
149            | DaemonRequest::CancelTask { .. }
150            | DaemonRequest::UpdateTask { .. }
151            | DaemonRequest::RestoreCheckpoint { .. }
152            | DaemonRequest::Approve { .. }
153            | DaemonRequest::Deny { .. }
154            | DaemonRequest::StopProcess { .. }
155            | DaemonRequest::RestartProcess { .. }
156            | DaemonRequest::OpenProcess { .. }
157            | DaemonRequest::PluginPreview { .. }
158            | DaemonRequest::PluginInstall { .. }
159            | DaemonRequest::SetPluginEnabled { .. }
160            | DaemonRequest::SetSafetyMode { .. }
161            | DaemonRequest::RuntimeHygieneArchive
162            | DaemonRequest::Pair { .. }
163            | DaemonRequest::Logs { .. }
164            | DaemonRequest::SessionMessages { .. }
165            | DaemonRequest::Snapshot
166            | DaemonRequest::RuntimeDashboard
167            | DaemonRequest::RuntimeDiagnostics
168            | DaemonRequest::RuntimeHygienePreview
169            | DaemonRequest::RuntimeTaskDetail { .. }
170            | DaemonRequest::RuntimeApprovalDetail { .. }
171            | DaemonRequest::RuntimeCheckpointDetail { .. }
172            | DaemonRequest::RuntimeTasks { .. }
173            | DaemonRequest::RuntimeProcesses { .. }
174            | DaemonRequest::RuntimeApprovals
175            | DaemonRequest::RuntimeToolRuns { .. }
176            | DaemonRequest::RuntimeCheckpoints { .. }
177            | DaemonRequest::RuntimePlugins
178            | DaemonRequest::ModelInfo { .. }
179            | DaemonRequest::SubscribeTask { .. } => true,
180            // Liveness/discovery stay unauthenticated on the local socket:
181            // no project or credential content, and used before pairing.
182            DaemonRequest::Ports => false,
183        }
184    }
185
186    /// Serialize to the wire `Value` (the shape `request_daemon` injects
187    /// `auth` into).
188    pub fn to_wire(&self) -> serde_json::Value {
189        serde_json::to_value(self).expect("DaemonRequest serializes")
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    /// Golden-style: every variant round-trips through its EXACT wire form.
198    #[test]
199    fn every_variant_round_trips_the_wire_shape() {
200        let cases: Vec<(DaemonRequest, &str)> = vec![
201            (DaemonRequest::Health, r#"{"command":"health"}"#),
202            (
203                DaemonRequest::CreateTask {
204                    title: "t".into(),
205                    project_path: "/p".into(),
206                    model_id: "m".into(),
207                },
208                r#"{"command":"create_task","title":"t","project_path":"/p","model_id":"m"}"#,
209            ),
210            (
211                DaemonRequest::SessionMessages { id: "s1".into() },
212                r#"{"command":"session_messages","id":"s1"}"#,
213            ),
214            (DaemonRequest::Snapshot, r#"{"command":"snapshot"}"#),
215            (
216                DaemonRequest::RuntimeDashboard,
217                r#"{"command":"runtime_dashboard"}"#,
218            ),
219            (
220                DaemonRequest::RuntimeDiagnostics,
221                r#"{"command":"runtime_diagnostics"}"#,
222            ),
223            (
224                DaemonRequest::RuntimeHygienePreview,
225                r#"{"command":"runtime_hygiene_preview"}"#,
226            ),
227            (
228                DaemonRequest::RuntimeHygieneArchive,
229                r#"{"command":"runtime_hygiene_archive"}"#,
230            ),
231            (
232                DaemonRequest::RuntimeTaskDetail { id: "t1".into() },
233                r#"{"command":"runtime_task_detail","id":"t1"}"#,
234            ),
235            (
236                DaemonRequest::RuntimeApprovalDetail { id: "a1".into() },
237                r#"{"command":"runtime_approval_detail","id":"a1"}"#,
238            ),
239            (
240                DaemonRequest::RuntimeCheckpointDetail { id: "c1".into() },
241                r#"{"command":"runtime_checkpoint_detail","id":"c1"}"#,
242            ),
243            (
244                DaemonRequest::RuntimeTasks { limit: Some(50) },
245                r#"{"command":"runtime_tasks","limit":50}"#,
246            ),
247            (
248                DaemonRequest::RuntimeProcesses { limit: None },
249                r#"{"command":"runtime_processes"}"#,
250            ),
251            (
252                DaemonRequest::RuntimeToolRuns { limit: Some(100) },
253                r#"{"command":"runtime_tool_runs","limit":100}"#,
254            ),
255            (
256                DaemonRequest::RuntimeCheckpoints { limit: Some(50) },
257                r#"{"command":"runtime_checkpoints","limit":50}"#,
258            ),
259            (
260                DaemonRequest::RuntimeApprovals,
261                r#"{"command":"runtime_approvals"}"#,
262            ),
263            (
264                DaemonRequest::RuntimePlugins,
265                r#"{"command":"runtime_plugins"}"#,
266            ),
267            (
268                DaemonRequest::Run {
269                    prompt: "p".into(),
270                    project_path: Some(String::new()),
271                    model_id: None,
272                    priority: Some("high".into()),
273                },
274                r#"{"command":"run","prompt":"p","project_path":"","priority":"high"}"#,
275            ),
276            (
277                DaemonRequest::CancelTask { id: "t1".into() },
278                r#"{"command":"cancel_task","id":"t1"}"#,
279            ),
280            (
281                DaemonRequest::UpdateTask {
282                    id: "t1".into(),
283                    status: "completed".into(),
284                    final_report: Some("done".into()),
285                },
286                r#"{"command":"update_task","id":"t1","status":"completed","final_report":"done"}"#,
287            ),
288            (
289                DaemonRequest::Logs {
290                    id: "p1".into(),
291                    tail_bytes: Some(4096),
292                },
293                r#"{"command":"logs","id":"p1","tail_bytes":4096}"#,
294            ),
295            (
296                DaemonRequest::StopProcess { id: "p1".into() },
297                r#"{"command":"stop_process","id":"p1"}"#,
298            ),
299            (
300                DaemonRequest::RestartProcess { id: "p1".into() },
301                r#"{"command":"restart_process","id":"p1"}"#,
302            ),
303            (
304                DaemonRequest::OpenProcess { id: "p1".into() },
305                r#"{"command":"open_process","id":"p1"}"#,
306            ),
307            (DaemonRequest::Ports, r#"{"command":"ports"}"#),
308            (
309                DaemonRequest::RestoreCheckpoint { id: "c1".into() },
310                r#"{"command":"restore_checkpoint","id":"c1"}"#,
311            ),
312            (
313                DaemonRequest::Approve { id: "a1".into() },
314                r#"{"command":"approve","id":"a1"}"#,
315            ),
316            (
317                DaemonRequest::Deny { id: "a1".into() },
318                r#"{"command":"deny","id":"a1"}"#,
319            ),
320            (
321                DaemonRequest::PluginPreview { path: "/pl".into() },
322                r#"{"command":"plugin_preview","path":"/pl"}"#,
323            ),
324            (
325                DaemonRequest::PluginInstall { path: "/pl".into() },
326                r#"{"command":"plugin_install","path":"/pl"}"#,
327            ),
328            (
329                DaemonRequest::SetPluginEnabled {
330                    id: "pl1".into(),
331                    enabled: true,
332                },
333                r#"{"command":"set_plugin_enabled","id":"pl1","enabled":true}"#,
334            ),
335            (
336                DaemonRequest::SetSafetyMode { mode: "ask".into() },
337                r#"{"command":"set_safety_mode","mode":"ask"}"#,
338            ),
339            (
340                DaemonRequest::ModelInfo { model: "m".into() },
341                r#"{"command":"model_info","model":"m"}"#,
342            ),
343            (
344                DaemonRequest::Pair {
345                    label: Some("laptop".into()),
346                    ttl_days: Some(30),
347                    token_hash: None,
348                },
349                r#"{"command":"pair","label":"laptop","ttl_days":30}"#,
350            ),
351            (
352                DaemonRequest::SubscribeTask {
353                    task_id: "t1".into(),
354                },
355                r#"{"command":"subscribe_task","task_id":"t1"}"#,
356            ),
357        ];
358        for (req, wire) in cases {
359            assert_eq!(serde_json::to_string(&req).unwrap(), wire, "{req:?}");
360            let back: DaemonRequest = serde_json::from_str(wire).unwrap();
361            assert_eq!(back, req, "{wire}");
362        }
363    }
364
365    #[test]
366    fn runtime_snapshot_alias_still_parses() {
367        let req: DaemonRequest = serde_json::from_str(r#"{"command":"runtime_snapshot"}"#).unwrap();
368        assert_eq!(req, DaemonRequest::Snapshot);
369    }
370
371    #[test]
372    fn requires_auth_matches_the_historical_matrix() {
373        assert!(!DaemonRequest::Health.requires_auth());
374        assert!(!DaemonRequest::Ports.requires_auth());
375        assert!(DaemonRequest::ModelInfo { model: "m".into() }.requires_auth());
376        for gated in [
377            DaemonRequest::Run {
378                prompt: "p".into(),
379                project_path: None,
380                model_id: None,
381                priority: None,
382            },
383            DaemonRequest::Snapshot,
384            DaemonRequest::SessionMessages { id: "s".into() },
385            DaemonRequest::Logs {
386                id: "p".into(),
387                tail_bytes: None,
388            },
389            DaemonRequest::Pair {
390                label: None,
391                ttl_days: None,
392                token_hash: None,
393            },
394            DaemonRequest::SubscribeTask {
395                task_id: "t".into(),
396            },
397        ] {
398            assert!(gated.requires_auth(), "{gated:?}");
399        }
400    }
401
402    #[test]
403    fn unknown_command_is_a_parse_error() {
404        assert!(serde_json::from_str::<DaemonRequest>(r#"{"command":"nope"}"#).is_err());
405    }
406}