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 = "predates the lint; see .github/baselines/expect_budget.txt"
212 )]
213 fn every_variant_round_trips_the_wire_shape() {
214 let cases: Vec<(DaemonRequest, &str)> = vec![
215 (DaemonRequest::Health, r#"{"command":"health"}"#),
216 (
217 DaemonRequest::CreateTask {
218 title: "t".into(),
219 project_path: "/p".into(),
220 model_id: "m".into(),
221 },
222 r#"{"command":"create_task","title":"t","project_path":"/p","model_id":"m"}"#,
223 ),
224 (
225 DaemonRequest::SessionMessages { id: "s1".into() },
226 r#"{"command":"session_messages","id":"s1"}"#,
227 ),
228 (DaemonRequest::Snapshot, r#"{"command":"snapshot"}"#),
229 (
230 DaemonRequest::RuntimeDashboard,
231 r#"{"command":"runtime_dashboard"}"#,
232 ),
233 (
234 DaemonRequest::RuntimeDiagnostics,
235 r#"{"command":"runtime_diagnostics"}"#,
236 ),
237 (
238 DaemonRequest::RuntimeHygienePreview,
239 r#"{"command":"runtime_hygiene_preview"}"#,
240 ),
241 (
242 DaemonRequest::RuntimeHygieneArchive,
243 r#"{"command":"runtime_hygiene_archive"}"#,
244 ),
245 (
246 DaemonRequest::RuntimeTaskDetail { id: "t1".into() },
247 r#"{"command":"runtime_task_detail","id":"t1"}"#,
248 ),
249 (
250 DaemonRequest::RuntimeApprovalDetail { id: "a1".into() },
251 r#"{"command":"runtime_approval_detail","id":"a1"}"#,
252 ),
253 (
254 DaemonRequest::RuntimeCheckpointDetail { id: "c1".into() },
255 r#"{"command":"runtime_checkpoint_detail","id":"c1"}"#,
256 ),
257 (
258 DaemonRequest::RuntimeTasks { limit: Some(50) },
259 r#"{"command":"runtime_tasks","limit":50}"#,
260 ),
261 (
262 DaemonRequest::RuntimeProcesses { limit: None },
263 r#"{"command":"runtime_processes"}"#,
264 ),
265 (
266 DaemonRequest::RuntimeToolRuns { limit: Some(100) },
267 r#"{"command":"runtime_tool_runs","limit":100}"#,
268 ),
269 (
270 DaemonRequest::RuntimeCheckpoints { limit: Some(50) },
271 r#"{"command":"runtime_checkpoints","limit":50}"#,
272 ),
273 (
274 DaemonRequest::RuntimeApprovals,
275 r#"{"command":"runtime_approvals"}"#,
276 ),
277 (
278 DaemonRequest::RuntimePlugins,
279 r#"{"command":"runtime_plugins"}"#,
280 ),
281 (
282 DaemonRequest::Run {
283 prompt: "p".into(),
284 project_path: Some(String::new()),
285 model_id: None,
286 priority: Some("high".into()),
287 },
288 r#"{"command":"run","prompt":"p","project_path":"","priority":"high"}"#,
289 ),
290 (
291 DaemonRequest::CancelTask { id: "t1".into() },
292 r#"{"command":"cancel_task","id":"t1"}"#,
293 ),
294 (
295 DaemonRequest::UpdateTask {
296 id: "t1".into(),
297 status: "completed".into(),
298 final_report: Some("done".into()),
299 },
300 r#"{"command":"update_task","id":"t1","status":"completed","final_report":"done"}"#,
301 ),
302 (
303 DaemonRequest::Logs {
304 id: "p1".into(),
305 tail_bytes: Some(4096),
306 },
307 r#"{"command":"logs","id":"p1","tail_bytes":4096}"#,
308 ),
309 (
310 DaemonRequest::StopProcess { id: "p1".into() },
311 r#"{"command":"stop_process","id":"p1"}"#,
312 ),
313 (
314 DaemonRequest::RestartProcess { id: "p1".into() },
315 r#"{"command":"restart_process","id":"p1"}"#,
316 ),
317 (
318 DaemonRequest::OpenProcess { id: "p1".into() },
319 r#"{"command":"open_process","id":"p1"}"#,
320 ),
321 (DaemonRequest::Ports, r#"{"command":"ports"}"#),
322 (
323 DaemonRequest::RestoreCheckpoint { id: "c1".into() },
324 r#"{"command":"restore_checkpoint","id":"c1"}"#,
325 ),
326 (
327 DaemonRequest::Approve { id: "a1".into() },
328 r#"{"command":"approve","id":"a1"}"#,
329 ),
330 (
331 DaemonRequest::Deny { id: "a1".into() },
332 r#"{"command":"deny","id":"a1"}"#,
333 ),
334 (
335 DaemonRequest::PluginPreview { path: "/pl".into() },
336 r#"{"command":"plugin_preview","path":"/pl"}"#,
337 ),
338 (
339 DaemonRequest::PluginInstall { path: "/pl".into() },
340 r#"{"command":"plugin_install","path":"/pl"}"#,
341 ),
342 (
343 DaemonRequest::SetPluginEnabled {
344 id: "pl1".into(),
345 enabled: true,
346 },
347 r#"{"command":"set_plugin_enabled","id":"pl1","enabled":true}"#,
348 ),
349 (
350 DaemonRequest::SetSafetyMode { mode: "ask".into() },
351 r#"{"command":"set_safety_mode","mode":"ask"}"#,
352 ),
353 (
354 DaemonRequest::ModelInfo { model: "m".into() },
355 r#"{"command":"model_info","model":"m"}"#,
356 ),
357 (
358 DaemonRequest::Pair {
359 label: Some("laptop".into()),
360 ttl_days: Some(30),
361 token_hash: None,
362 },
363 r#"{"command":"pair","label":"laptop","ttl_days":30}"#,
364 ),
365 (
366 DaemonRequest::SubscribeTask {
367 task_id: "t1".into(),
368 },
369 r#"{"command":"subscribe_task","task_id":"t1"}"#,
370 ),
371 ];
372 for (req, wire) in cases {
373 assert_eq!(serde_json::to_string(&req).unwrap(), wire, "{req:?}");
374 let back: DaemonRequest = serde_json::from_str(wire).unwrap();
375 assert_eq!(back, req, "{wire}");
376 }
377 }
378
379 #[test]
380 fn runtime_snapshot_alias_still_parses() {
381 let req: DaemonRequest = serde_json::from_str(r#"{"command":"runtime_snapshot"}"#).unwrap();
382 assert_eq!(req, DaemonRequest::Snapshot);
383 }
384
385 #[test]
386 fn requires_auth_matches_the_historical_matrix() {
387 assert!(!DaemonRequest::Health.requires_auth());
388 assert!(!DaemonRequest::Ports.requires_auth());
389 assert!(DaemonRequest::ModelInfo { model: "m".into() }.requires_auth());
390 for gated in [
391 DaemonRequest::Run {
392 prompt: "p".into(),
393 project_path: None,
394 model_id: None,
395 priority: None,
396 },
397 DaemonRequest::Snapshot,
398 DaemonRequest::SessionMessages { id: "s".into() },
399 DaemonRequest::Logs {
400 id: "p".into(),
401 tail_bytes: None,
402 },
403 DaemonRequest::Pair {
404 label: None,
405 ttl_days: None,
406 token_hash: None,
407 },
408 DaemonRequest::SubscribeTask {
409 task_id: "t".into(),
410 },
411 ] {
412 assert!(gated.requires_auth(), "{gated:?}");
413 }
414 }
415
416 #[test]
417 fn unknown_command_is_a_parse_error() {
418 assert!(serde_json::from_str::<DaemonRequest>(r#"{"command":"nope"}"#).is_err());
419 }
420}