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 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 SubscribeTask {
135 task_id: String,
136 },
137}
138
139impl DaemonRequest {
140 #[must_use]
145 pub fn requires_auth(&self) -> bool {
146 match self {
147 Self::Health => false,
148 Self::CreateTask { .. }
149 | Self::Run { .. }
150 | Self::CancelTask { .. }
151 | Self::UpdateTask { .. }
152 | Self::RestoreCheckpoint { .. }
153 | Self::Approve { .. }
154 | Self::Deny { .. }
155 | Self::StopProcess { .. }
156 | Self::RestartProcess { .. }
157 | Self::OpenProcess { .. }
158 | Self::PluginPreview { .. }
159 | Self::PluginInstall { .. }
160 | Self::SetPluginEnabled { .. }
161 | Self::SetSafetyMode { .. }
162 | Self::RuntimeHygieneArchive
163 | Self::Pair { .. }
164 | Self::Logs { .. }
165 | Self::SessionMessages { .. }
166 | Self::Snapshot
167 | Self::RuntimeDashboard
168 | Self::RuntimeDiagnostics
169 | Self::RuntimeHygienePreview
170 | Self::RuntimeTaskDetail { .. }
171 | Self::RuntimeApprovalDetail { .. }
172 | Self::RuntimeCheckpointDetail { .. }
173 | Self::RuntimeTasks { .. }
174 | Self::RuntimeProcesses { .. }
175 | Self::RuntimeApprovals
176 | Self::RuntimeToolRuns { .. }
177 | Self::RuntimeCheckpoints { .. }
178 | Self::RuntimePlugins
179 | Self::ModelInfo { .. }
180 | Self::SubscribeTask { .. } => true,
181 Self::Ports => false,
184 }
185 }
186
187 #[must_use]
190 pub fn to_wire(&self) -> serde_json::Value {
191 serde_json::to_value(self).expect("DaemonRequest serializes")
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
201 #[expect(
202 clippy::too_many_lines,
203 reason = "predates the lint; see .github/baselines/expect_budget.txt"
204 )]
205 fn every_variant_round_trips_the_wire_shape() {
206 let cases: Vec<(DaemonRequest, &str)> = vec![
207 (DaemonRequest::Health, r#"{"command":"health"}"#),
208 (
209 DaemonRequest::CreateTask {
210 title: "t".into(),
211 project_path: "/p".into(),
212 model_id: "m".into(),
213 },
214 r#"{"command":"create_task","title":"t","project_path":"/p","model_id":"m"}"#,
215 ),
216 (
217 DaemonRequest::SessionMessages { id: "s1".into() },
218 r#"{"command":"session_messages","id":"s1"}"#,
219 ),
220 (DaemonRequest::Snapshot, r#"{"command":"snapshot"}"#),
221 (
222 DaemonRequest::RuntimeDashboard,
223 r#"{"command":"runtime_dashboard"}"#,
224 ),
225 (
226 DaemonRequest::RuntimeDiagnostics,
227 r#"{"command":"runtime_diagnostics"}"#,
228 ),
229 (
230 DaemonRequest::RuntimeHygienePreview,
231 r#"{"command":"runtime_hygiene_preview"}"#,
232 ),
233 (
234 DaemonRequest::RuntimeHygieneArchive,
235 r#"{"command":"runtime_hygiene_archive"}"#,
236 ),
237 (
238 DaemonRequest::RuntimeTaskDetail { id: "t1".into() },
239 r#"{"command":"runtime_task_detail","id":"t1"}"#,
240 ),
241 (
242 DaemonRequest::RuntimeApprovalDetail { id: "a1".into() },
243 r#"{"command":"runtime_approval_detail","id":"a1"}"#,
244 ),
245 (
246 DaemonRequest::RuntimeCheckpointDetail { id: "c1".into() },
247 r#"{"command":"runtime_checkpoint_detail","id":"c1"}"#,
248 ),
249 (
250 DaemonRequest::RuntimeTasks { limit: Some(50) },
251 r#"{"command":"runtime_tasks","limit":50}"#,
252 ),
253 (
254 DaemonRequest::RuntimeProcesses { limit: None },
255 r#"{"command":"runtime_processes"}"#,
256 ),
257 (
258 DaemonRequest::RuntimeToolRuns { limit: Some(100) },
259 r#"{"command":"runtime_tool_runs","limit":100}"#,
260 ),
261 (
262 DaemonRequest::RuntimeCheckpoints { limit: Some(50) },
263 r#"{"command":"runtime_checkpoints","limit":50}"#,
264 ),
265 (
266 DaemonRequest::RuntimeApprovals,
267 r#"{"command":"runtime_approvals"}"#,
268 ),
269 (
270 DaemonRequest::RuntimePlugins,
271 r#"{"command":"runtime_plugins"}"#,
272 ),
273 (
274 DaemonRequest::Run {
275 prompt: "p".into(),
276 project_path: Some(String::new()),
277 model_id: None,
278 priority: Some("high".into()),
279 },
280 r#"{"command":"run","prompt":"p","project_path":"","priority":"high"}"#,
281 ),
282 (
283 DaemonRequest::CancelTask { id: "t1".into() },
284 r#"{"command":"cancel_task","id":"t1"}"#,
285 ),
286 (
287 DaemonRequest::UpdateTask {
288 id: "t1".into(),
289 status: "completed".into(),
290 final_report: Some("done".into()),
291 },
292 r#"{"command":"update_task","id":"t1","status":"completed","final_report":"done"}"#,
293 ),
294 (
295 DaemonRequest::Logs {
296 id: "p1".into(),
297 tail_bytes: Some(4096),
298 },
299 r#"{"command":"logs","id":"p1","tail_bytes":4096}"#,
300 ),
301 (
302 DaemonRequest::StopProcess { id: "p1".into() },
303 r#"{"command":"stop_process","id":"p1"}"#,
304 ),
305 (
306 DaemonRequest::RestartProcess { id: "p1".into() },
307 r#"{"command":"restart_process","id":"p1"}"#,
308 ),
309 (
310 DaemonRequest::OpenProcess { id: "p1".into() },
311 r#"{"command":"open_process","id":"p1"}"#,
312 ),
313 (DaemonRequest::Ports, r#"{"command":"ports"}"#),
314 (
315 DaemonRequest::RestoreCheckpoint { id: "c1".into() },
316 r#"{"command":"restore_checkpoint","id":"c1"}"#,
317 ),
318 (
319 DaemonRequest::Approve { id: "a1".into() },
320 r#"{"command":"approve","id":"a1"}"#,
321 ),
322 (
323 DaemonRequest::Deny { id: "a1".into() },
324 r#"{"command":"deny","id":"a1"}"#,
325 ),
326 (
327 DaemonRequest::PluginPreview { path: "/pl".into() },
328 r#"{"command":"plugin_preview","path":"/pl"}"#,
329 ),
330 (
331 DaemonRequest::PluginInstall { path: "/pl".into() },
332 r#"{"command":"plugin_install","path":"/pl"}"#,
333 ),
334 (
335 DaemonRequest::SetPluginEnabled {
336 id: "pl1".into(),
337 enabled: true,
338 },
339 r#"{"command":"set_plugin_enabled","id":"pl1","enabled":true}"#,
340 ),
341 (
342 DaemonRequest::SetSafetyMode { mode: "ask".into() },
343 r#"{"command":"set_safety_mode","mode":"ask"}"#,
344 ),
345 (
346 DaemonRequest::ModelInfo { model: "m".into() },
347 r#"{"command":"model_info","model":"m"}"#,
348 ),
349 (
350 DaemonRequest::Pair {
351 label: Some("laptop".into()),
352 ttl_days: Some(30),
353 token_hash: None,
354 },
355 r#"{"command":"pair","label":"laptop","ttl_days":30}"#,
356 ),
357 (
358 DaemonRequest::SubscribeTask {
359 task_id: "t1".into(),
360 },
361 r#"{"command":"subscribe_task","task_id":"t1"}"#,
362 ),
363 ];
364 for (req, wire) in cases {
365 assert_eq!(serde_json::to_string(&req).unwrap(), wire, "{req:?}");
366 let back: DaemonRequest = serde_json::from_str(wire).unwrap();
367 assert_eq!(back, req, "{wire}");
368 }
369 }
370
371 #[test]
372 fn runtime_snapshot_alias_still_parses() {
373 let req: DaemonRequest = serde_json::from_str(r#"{"command":"runtime_snapshot"}"#).unwrap();
374 assert_eq!(req, DaemonRequest::Snapshot);
375 }
376
377 #[test]
378 fn requires_auth_matches_the_historical_matrix() {
379 assert!(!DaemonRequest::Health.requires_auth());
380 assert!(!DaemonRequest::Ports.requires_auth());
381 assert!(DaemonRequest::ModelInfo { model: "m".into() }.requires_auth());
382 for gated in [
383 DaemonRequest::Run {
384 prompt: "p".into(),
385 project_path: None,
386 model_id: None,
387 priority: None,
388 },
389 DaemonRequest::Snapshot,
390 DaemonRequest::SessionMessages { id: "s".into() },
391 DaemonRequest::Logs {
392 id: "p".into(),
393 tail_bytes: None,
394 },
395 DaemonRequest::Pair {
396 label: None,
397 ttl_days: None,
398 token_hash: None,
399 },
400 DaemonRequest::SubscribeTask {
401 task_id: "t".into(),
402 },
403 ] {
404 assert!(gated.requires_auth(), "{gated:?}");
405 }
406 }
407
408 #[test]
409 fn unknown_command_is_a_parse_error() {
410 assert!(serde_json::from_str::<DaemonRequest>(r#"{"command":"nope"}"#).is_err());
411 }
412}