1use serde::{Deserialize, Serialize};
16
17#[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 #[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 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 SubscribeTask {
142 task_id: String,
143 },
144}
145
146impl DaemonRequest {
147 #[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 Self::Ports => false,
192 }
193 }
194
195 #[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 #[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}