Skip to main content

spn_client/
protocol.rs

1//! Protocol types for daemon communication.
2//!
3//! The protocol uses length-prefixed JSON over Unix sockets.
4//!
5//! ## Wire Format
6//!
7//! ```text
8//! [4 bytes: message length (big-endian u32)][JSON payload]
9//! ```
10//!
11//! ## Protocol Versioning
12//!
13//! The protocol version is exchanged during the initial PING/PONG handshake.
14//! This allows clients and daemons to detect incompatible versions early.
15//!
16//! - `protocol_version`: Integer version for wire protocol changes
17//! - `version`: CLI version string for display purposes
18//!
19//! When the protocol version doesn't match, clients should warn and may
20//! fall back to environment variables.
21//!
22//! ## Example
23//!
24//! Request:
25//! ```json
26//! { "cmd": "GET_SECRET", "provider": "anthropic" }
27//! ```
28//!
29//! Response:
30//! ```json
31//! { "ok": true, "secret": "sk-ant-..." }
32//! ```
33
34use serde::{Deserialize, Serialize};
35use spn_core::{LoadConfig, ModelInfo, PullProgress, RunningModel};
36
37// ============================================================================
38// JOB TYPES (IPC-friendly versions)
39// ============================================================================
40
41/// Job state in the scheduler (IPC version).
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum IpcJobState {
45    Pending,
46    Running,
47    Completed,
48    Failed,
49    Cancelled,
50}
51
52impl std::fmt::Display for IpcJobState {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            IpcJobState::Pending => write!(f, "pending"),
56            IpcJobState::Running => write!(f, "running"),
57            IpcJobState::Completed => write!(f, "completed"),
58            IpcJobState::Failed => write!(f, "failed"),
59            IpcJobState::Cancelled => write!(f, "cancelled"),
60        }
61    }
62}
63
64/// Job status for IPC responses.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IpcJobStatus {
67    /// Job ID (8-char UUID prefix)
68    pub id: String,
69    /// Workflow path
70    pub workflow: String,
71    /// Current state
72    pub state: IpcJobState,
73    /// Optional job name
74    pub name: Option<String>,
75    /// Progress percentage (0-100)
76    pub progress: u8,
77    /// Error message (if failed)
78    pub error: Option<String>,
79    /// Output from the workflow (if completed)
80    pub output: Option<String>,
81    /// Creation timestamp (Unix epoch millis)
82    pub created_at: u64,
83    /// Start timestamp (Unix epoch millis, if started)
84    pub started_at: Option<u64>,
85    /// End timestamp (Unix epoch millis, if finished)
86    pub ended_at: Option<u64>,
87}
88
89/// Scheduler statistics for IPC responses.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct IpcSchedulerStats {
92    /// Total jobs (all states)
93    pub total: usize,
94    /// Pending jobs
95    pub pending: usize,
96    /// Currently running jobs
97    pub running: usize,
98    /// Completed jobs
99    pub completed: usize,
100    /// Failed jobs
101    pub failed: usize,
102    /// Cancelled jobs
103    pub cancelled: usize,
104    /// Whether nika binary is available
105    pub has_nika: bool,
106}
107
108// ============================================================================
109// WATCHER TYPES (IPC-friendly versions)
110// ============================================================================
111
112/// Recent project info for watcher status.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct RecentProjectInfo {
115    /// Absolute path to project root
116    pub path: String,
117    /// Last used timestamp (ISO 8601)
118    pub last_used: String,
119}
120
121/// Foreign MCP info for watcher status.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ForeignMcpInfo {
124    /// Server name (e.g., "github-copilot")
125    pub name: String,
126    /// Source editor: "claude_code", "cursor", "windsurf"
127    pub source: String,
128    /// Scope: "global" or "project:/path/to/project"
129    pub scope: String,
130    /// Detection timestamp (ISO 8601)
131    pub detected: String,
132}
133
134/// Watcher status for IPC responses.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct WatcherStatusInfo {
137    /// Whether the watcher is running
138    pub is_running: bool,
139    /// Number of paths being watched
140    pub watched_count: usize,
141    /// Paths currently being watched
142    pub watched_paths: Vec<String>,
143    /// Debounce duration in milliseconds
144    pub debounce_ms: u64,
145    /// Recently used projects
146    pub recent_projects: Vec<RecentProjectInfo>,
147    /// Foreign MCPs pending adoption
148    pub foreign_pending: Vec<ForeignMcpInfo>,
149    /// Foreign MCPs explicitly ignored
150    pub foreign_ignored: Vec<String>,
151}
152
153/// Progress update for model operations (pull, load, delete).
154///
155/// Used for streaming progress from daemon to CLI during long-running operations.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ModelProgress {
158    /// Current status message (e.g., "downloading", "verifying", "extracting")
159    pub status: String,
160    /// Bytes/units completed (optional for indeterminate operations)
161    pub completed: Option<u64>,
162    /// Total bytes/units (optional for indeterminate operations)
163    pub total: Option<u64>,
164    /// Model digest (for pull operations)
165    pub digest: Option<String>,
166}
167
168impl ModelProgress {
169    /// Calculate completion percentage (0.0 - 100.0).
170    /// Returns None if total is unknown or zero.
171    pub fn percentage(&self) -> Option<f64> {
172        match (self.completed, self.total) {
173            (Some(completed), Some(total)) if total > 0 => {
174                Some((completed as f64 / total as f64) * 100.0)
175            }
176            _ => None,
177        }
178    }
179
180    /// Create a new indeterminate progress (spinner mode).
181    pub fn indeterminate(status: impl Into<String>) -> Self {
182        Self {
183            status: status.into(),
184            completed: None,
185            total: None,
186            digest: None,
187        }
188    }
189
190    /// Create a determinate progress (progress bar mode).
191    pub fn determinate(status: impl Into<String>, completed: u64, total: u64) -> Self {
192        Self {
193            status: status.into(),
194            completed: Some(completed),
195            total: Some(total),
196            digest: None,
197        }
198    }
199
200    /// Create from PullProgress (from spn_core/spn_ollama).
201    pub fn from_pull_progress(p: &PullProgress) -> Self {
202        Self {
203            status: p.status.clone(),
204            completed: Some(p.completed),
205            total: Some(p.total),
206            digest: None, // PullProgress doesn't have digest field
207        }
208    }
209}
210
211/// Current protocol version.
212/// - Adding required fields to requests/responses
213/// - Changing the serialization format
214/// - Removing commands or response variants
215///
216/// Do NOT increment for:
217/// - Adding new optional fields
218/// - Adding new commands (backwards compatible)
219pub const PROTOCOL_VERSION: u32 = 1;
220
221/// Default protocol version for backwards compatibility.
222/// Old daemons that don't send protocol_version are assumed to be v0.
223fn default_protocol_version() -> u32 {
224    0
225}
226
227/// Request sent to the daemon.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(tag = "cmd")]
230pub enum Request {
231    /// Ping the daemon to check it's alive.
232    #[serde(rename = "PING")]
233    Ping,
234
235    /// Get a secret for a provider.
236    #[serde(rename = "GET_SECRET")]
237    GetSecret { provider: String },
238
239    /// Check if a secret exists.
240    #[serde(rename = "HAS_SECRET")]
241    HasSecret { provider: String },
242
243    /// List all available providers.
244    #[serde(rename = "LIST_PROVIDERS")]
245    ListProviders,
246
247    /// Refresh/reload a secret from keychain into daemon cache.
248    /// Used after `spn provider set` to invalidate stale cached values.
249    #[serde(rename = "REFRESH_SECRET")]
250    RefreshSecret { provider: String },
251
252    // ==================== Model Commands ====================
253    /// List all installed models.
254    #[serde(rename = "MODEL_LIST")]
255    ModelList,
256
257    /// Pull/download a model.
258    #[serde(rename = "MODEL_PULL")]
259    ModelPull { name: String },
260
261    /// Load a model into memory.
262    #[serde(rename = "MODEL_LOAD")]
263    ModelLoad {
264        name: String,
265        #[serde(default)]
266        config: Option<LoadConfig>,
267    },
268
269    /// Unload a model from memory.
270    #[serde(rename = "MODEL_UNLOAD")]
271    ModelUnload { name: String },
272
273    /// Get status of running models.
274    #[serde(rename = "MODEL_STATUS")]
275    ModelStatus,
276
277    /// Delete a model.
278    #[serde(rename = "MODEL_DELETE")]
279    ModelDelete { name: String },
280
281    /// Run inference on a model.
282    #[serde(rename = "MODEL_RUN")]
283    ModelRun {
284        /// Model name (e.g., llama3.2)
285        model: String,
286        /// User prompt
287        prompt: String,
288        /// System prompt (optional)
289        #[serde(default)]
290        system: Option<String>,
291        /// Temperature (0.0 - 2.0)
292        #[serde(default)]
293        temperature: Option<f32>,
294        /// Enable streaming (not yet supported via IPC)
295        #[serde(default)]
296        stream: bool,
297    },
298
299    // ==================== Job Commands ====================
300    /// Submit a workflow job for background execution.
301    #[serde(rename = "JOB_SUBMIT")]
302    JobSubmit {
303        /// Path to workflow file
304        workflow: String,
305        /// Optional workflow arguments
306        #[serde(default)]
307        args: Vec<String>,
308        /// Optional job name for display
309        #[serde(default)]
310        name: Option<String>,
311        /// Job priority (higher = more urgent)
312        #[serde(default)]
313        priority: i32,
314    },
315
316    /// Get status of a specific job.
317    #[serde(rename = "JOB_STATUS")]
318    JobStatus {
319        /// Job ID (8-character short UUID)
320        job_id: String,
321    },
322
323    /// List all jobs (optionally filtered by state).
324    #[serde(rename = "JOB_LIST")]
325    JobList {
326        /// Filter by state (pending, running, completed, failed, cancelled)
327        #[serde(default)]
328        state: Option<String>,
329    },
330
331    /// Cancel a running or pending job.
332    #[serde(rename = "JOB_CANCEL")]
333    JobCancel {
334        /// Job ID to cancel
335        job_id: String,
336    },
337
338    /// Get scheduler statistics.
339    #[serde(rename = "JOB_STATS")]
340    JobStats,
341
342    // ==================== Watcher Commands ====================
343    /// Get watcher status (watched paths, foreign MCPs, recent projects).
344    #[serde(rename = "WATCHER_STATUS")]
345    WatcherStatus,
346}
347
348/// Response from the daemon.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(untagged)]
351pub enum Response {
352    /// Successful ping response with version info.
353    Pong {
354        /// Protocol version for compatibility checking.
355        /// Clients should verify this matches PROTOCOL_VERSION.
356        #[serde(default = "default_protocol_version")]
357        protocol_version: u32,
358        /// CLI version string for display.
359        version: String,
360    },
361
362    /// Secret value response.
363    ///
364    /// # Security Note
365    ///
366    /// The secret is transmitted as plain JSON over the Unix socket. This is secure because:
367    /// - Unix socket requires peer credential verification (same UID only)
368    /// - Socket permissions are 0600 (owner-only)
369    /// - Connection is local-only (no network exposure)
370    Secret { value: String },
371
372    /// Secret existence check response.
373    Exists { exists: bool },
374
375    /// Provider list response.
376    Providers { providers: Vec<String> },
377
378    /// Secret refresh response.
379    Refreshed {
380        /// Whether the secret was found and reloaded
381        refreshed: bool,
382        /// The provider that was refreshed
383        provider: String,
384    },
385
386    // ==================== Model Responses ====================
387    /// List of installed models.
388    Models { models: Vec<ModelInfo> },
389
390    /// List of currently running/loaded models.
391    RunningModels { running: Vec<RunningModel> },
392
393    /// Generic success response.
394    Success { success: bool },
395
396    /// Model run result with generated content.
397    ModelRunResult {
398        /// Generated content from the model.
399        content: String,
400        /// Optional stats (tokens_per_second, etc.)
401        #[serde(default)]
402        stats: Option<serde_json::Value>,
403    },
404
405    /// Error response.
406    Error { message: String },
407
408    // ==================== Streaming Responses ====================
409    /// Progress update for model operations (streaming).
410    Progress {
411        /// Progress details
412        progress: ModelProgress,
413    },
414
415    /// End of stream marker.
416    StreamEnd {
417        /// Whether the operation succeeded
418        success: bool,
419        /// Error message if failed
420        #[serde(default)]
421        error: Option<String>,
422    },
423
424    // ==================== Watcher Responses ====================
425    // NOTE: WatcherStatusResult MUST come before JobStatusResult because
426    // JobStatusResult has Option<...> which matches any JSON with missing fields.
427    // With untagged enums, serde tries variants in order.
428    /// Watcher status response.
429    WatcherStatusResult {
430        /// Watcher status info
431        status: WatcherStatusInfo,
432    },
433
434    // ==================== Job Responses ====================
435    /// Job submitted response with initial status.
436    JobSubmitted {
437        /// The job status
438        job: IpcJobStatus,
439    },
440
441    /// Single job status response.
442    JobStatusResult {
443        /// The job status (None if job not found)
444        job: Option<IpcJobStatus>,
445    },
446
447    /// Job list response.
448    JobListResult {
449        /// List of jobs
450        jobs: Vec<IpcJobStatus>,
451    },
452
453    /// Job cancelled response.
454    JobCancelled {
455        /// Whether cancellation succeeded
456        cancelled: bool,
457        /// Job ID that was cancelled
458        job_id: String,
459    },
460
461    /// Scheduler statistics response.
462    JobStatsResult {
463        /// Scheduler stats
464        stats: IpcSchedulerStats,
465    },
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_request_serialization() {
474        let ping = Request::Ping;
475        let json = serde_json::to_string(&ping).unwrap();
476        assert_eq!(json, r#"{"cmd":"PING"}"#);
477
478        let get_secret = Request::GetSecret {
479            provider: "anthropic".to_string(),
480        };
481        let json = serde_json::to_string(&get_secret).unwrap();
482        assert_eq!(json, r#"{"cmd":"GET_SECRET","provider":"anthropic"}"#);
483
484        let has_secret = Request::HasSecret {
485            provider: "openai".to_string(),
486        };
487        let json = serde_json::to_string(&has_secret).unwrap();
488        assert_eq!(json, r#"{"cmd":"HAS_SECRET","provider":"openai"}"#);
489
490        let list = Request::ListProviders;
491        let json = serde_json::to_string(&list).unwrap();
492        assert_eq!(json, r#"{"cmd":"LIST_PROVIDERS"}"#);
493    }
494
495    #[test]
496    fn test_response_deserialization() {
497        // Pong with protocol version
498        let json = r#"{"protocol_version":1,"version":"0.14.2"}"#;
499        let response: Response = serde_json::from_str(json).unwrap();
500        assert!(
501            matches!(response, Response::Pong { protocol_version, version }
502                if protocol_version == 1 && version == "0.14.2")
503        );
504
505        // Pong without protocol version (backwards compatibility)
506        let json = r#"{"version":"0.9.0"}"#;
507        let response: Response = serde_json::from_str(json).unwrap();
508        assert!(
509            matches!(response, Response::Pong { protocol_version, version }
510                if protocol_version == 0 && version == "0.9.0")
511        );
512
513        // Secret
514        let json = r#"{"value":"sk-test-123"}"#;
515        let response: Response = serde_json::from_str(json).unwrap();
516        assert!(matches!(response, Response::Secret { value } if value == "sk-test-123"));
517
518        // Exists
519        let json = r#"{"exists":true}"#;
520        let response: Response = serde_json::from_str(json).unwrap();
521        assert!(matches!(response, Response::Exists { exists } if exists));
522
523        // Providers
524        let json = r#"{"providers":["anthropic","openai"]}"#;
525        let response: Response = serde_json::from_str(json).unwrap();
526        assert!(
527            matches!(response, Response::Providers { providers } if providers == vec!["anthropic", "openai"])
528        );
529
530        // Error
531        let json = r#"{"message":"Not found"}"#;
532        let response: Response = serde_json::from_str(json).unwrap();
533        assert!(matches!(response, Response::Error { message } if message == "Not found"));
534    }
535
536    #[test]
537    fn test_model_progress_serialization() {
538        let progress = ModelProgress {
539            status: "downloading".into(),
540            completed: Some(50),
541            total: Some(100),
542            digest: Some("sha256:abc123".into()),
543        };
544
545        let json = serde_json::to_string(&progress).unwrap();
546        let parsed: ModelProgress = serde_json::from_str(&json).unwrap();
547
548        assert_eq!(parsed.status, "downloading");
549        assert_eq!(parsed.completed, Some(50));
550        assert_eq!(parsed.total, Some(100));
551    }
552
553    #[test]
554    fn test_model_progress_percentage() {
555        let progress = ModelProgress {
556            status: "downloading".into(),
557            completed: Some(75),
558            total: Some(100),
559            digest: None,
560        };
561
562        assert_eq!(progress.percentage(), Some(75.0));
563
564        let no_total = ModelProgress {
565            status: "starting".into(),
566            completed: None,
567            total: None,
568            digest: None,
569        };
570
571        assert_eq!(no_total.percentage(), None);
572    }
573
574    #[test]
575    fn test_model_progress_constructors() {
576        let indeterminate = ModelProgress::indeterminate("loading");
577        assert_eq!(indeterminate.status, "loading");
578        assert!(indeterminate.percentage().is_none());
579
580        let determinate = ModelProgress::determinate("downloading", 50, 100);
581        assert_eq!(determinate.percentage(), Some(50.0));
582    }
583
584    #[test]
585    fn test_response_progress_variant() {
586        let progress = ModelProgress::determinate("downloading", 50, 100);
587        let response = Response::Progress { progress };
588
589        let json = serde_json::to_string(&response).unwrap();
590        assert!(json.contains("downloading"));
591    }
592
593    #[test]
594    fn test_response_stream_end_variant() {
595        let success_response = Response::StreamEnd {
596            success: true,
597            error: None,
598        };
599        let json = serde_json::to_string(&success_response).unwrap();
600        assert!(json.contains("success"));
601
602        let error_response = Response::StreamEnd {
603            success: false,
604            error: Some("Connection lost".into()),
605        };
606        let json = serde_json::to_string(&error_response).unwrap();
607        assert!(json.contains("Connection lost"));
608    }
609
610    // ==================== Job Protocol Tests ====================
611
612    #[test]
613    fn test_job_request_serialization() {
614        let submit = Request::JobSubmit {
615            workflow: "/path/to/workflow.yaml".into(),
616            args: vec!["--verbose".into()],
617            name: Some("Test Job".into()),
618            priority: 5,
619        };
620        let json = serde_json::to_string(&submit).unwrap();
621        assert!(json.contains("JOB_SUBMIT"));
622        assert!(json.contains("workflow.yaml"));
623
624        let status = Request::JobStatus {
625            job_id: "abc12345".into(),
626        };
627        let json = serde_json::to_string(&status).unwrap();
628        assert!(json.contains("JOB_STATUS"));
629        assert!(json.contains("abc12345"));
630
631        let list = Request::JobList { state: None };
632        let json = serde_json::to_string(&list).unwrap();
633        assert!(json.contains("JOB_LIST"));
634
635        let cancel = Request::JobCancel {
636            job_id: "def67890".into(),
637        };
638        let json = serde_json::to_string(&cancel).unwrap();
639        assert!(json.contains("JOB_CANCEL"));
640
641        let stats = Request::JobStats;
642        let json = serde_json::to_string(&stats).unwrap();
643        assert!(json.contains("JOB_STATS"));
644    }
645
646    #[test]
647    fn test_ipc_job_state_serialization() {
648        assert_eq!(
649            serde_json::to_string(&IpcJobState::Pending).unwrap(),
650            r#""pending""#
651        );
652        assert_eq!(
653            serde_json::to_string(&IpcJobState::Running).unwrap(),
654            r#""running""#
655        );
656        assert_eq!(
657            serde_json::to_string(&IpcJobState::Completed).unwrap(),
658            r#""completed""#
659        );
660        assert_eq!(
661            serde_json::to_string(&IpcJobState::Failed).unwrap(),
662            r#""failed""#
663        );
664        assert_eq!(
665            serde_json::to_string(&IpcJobState::Cancelled).unwrap(),
666            r#""cancelled""#
667        );
668    }
669
670    #[test]
671    fn test_ipc_job_status_serialization() {
672        let status = IpcJobStatus {
673            id: "abc12345".into(),
674            workflow: "/path/to/test.yaml".into(),
675            state: IpcJobState::Running,
676            name: Some("Test Job".into()),
677            progress: 50,
678            error: None,
679            output: None,
680            created_at: 1710000000000,
681            started_at: Some(1710000001000),
682            ended_at: None,
683        };
684
685        let json = serde_json::to_string(&status).unwrap();
686        assert!(json.contains("abc12345"));
687        assert!(json.contains("running"));
688        assert!(json.contains("Test Job"));
689    }
690
691    #[test]
692    fn test_ipc_scheduler_stats_serialization() {
693        let stats = IpcSchedulerStats {
694            total: 10,
695            pending: 2,
696            running: 3,
697            completed: 4,
698            failed: 1,
699            cancelled: 0,
700            has_nika: true,
701        };
702
703        let json = serde_json::to_string(&stats).unwrap();
704        let parsed: IpcSchedulerStats = serde_json::from_str(&json).unwrap();
705
706        assert_eq!(parsed.total, 10);
707        assert_eq!(parsed.running, 3);
708        assert!(parsed.has_nika);
709    }
710
711    #[test]
712    fn test_job_response_variants() {
713        // JobSubmitted
714        let status = IpcJobStatus {
715            id: "abc12345".into(),
716            workflow: "/test.yaml".into(),
717            state: IpcJobState::Pending,
718            name: None,
719            progress: 0,
720            error: None,
721            output: None,
722            created_at: 1710000000000,
723            started_at: None,
724            ended_at: None,
725        };
726        let response = Response::JobSubmitted { job: status };
727        let json = serde_json::to_string(&response).unwrap();
728        assert!(json.contains("abc12345"));
729
730        // JobCancelled
731        let response = Response::JobCancelled {
732            cancelled: true,
733            job_id: "def67890".into(),
734        };
735        let json = serde_json::to_string(&response).unwrap();
736        assert!(json.contains("cancelled"));
737        assert!(json.contains("def67890"));
738    }
739
740    // ==================== Watcher Protocol Tests ====================
741
742    #[test]
743    fn test_watcher_request_serialization() {
744        let request = Request::WatcherStatus;
745        let json = serde_json::to_string(&request).unwrap();
746        assert_eq!(json, r#"{"cmd":"WATCHER_STATUS"}"#);
747    }
748
749    #[test]
750    fn test_watcher_status_info_serialization() {
751        let status = WatcherStatusInfo {
752            is_running: true,
753            watched_count: 8,
754            watched_paths: vec!["~/.spn/mcp.yaml".into(), "~/.claude.json".into()],
755            debounce_ms: 500,
756            recent_projects: vec![RecentProjectInfo {
757                path: "/Users/test/project".into(),
758                last_used: "2026-03-09T12:00:00Z".into(),
759            }],
760            foreign_pending: vec![ForeignMcpInfo {
761                name: "github-copilot".into(),
762                source: "cursor".into(),
763                scope: "global".into(),
764                detected: "2026-03-09T11:00:00Z".into(),
765            }],
766            foreign_ignored: vec!["some-mcp".into()],
767        };
768
769        let json = serde_json::to_string(&status).unwrap();
770        assert!(json.contains("is_running"));
771        assert!(json.contains("watched_count"));
772        assert!(json.contains("github-copilot"));
773
774        // Verify round-trip
775        let parsed: WatcherStatusInfo = serde_json::from_str(&json).unwrap();
776        assert!(parsed.is_running);
777        assert_eq!(parsed.watched_count, 8);
778        assert_eq!(parsed.recent_projects.len(), 1);
779        assert_eq!(parsed.foreign_pending.len(), 1);
780    }
781
782    #[test]
783    fn test_watcher_status_response_variant() {
784        let status = WatcherStatusInfo {
785            is_running: true,
786            watched_count: 5,
787            watched_paths: vec![],
788            debounce_ms: 500,
789            recent_projects: vec![],
790            foreign_pending: vec![],
791            foreign_ignored: vec![],
792        };
793        let response = Response::WatcherStatusResult { status };
794        let json = serde_json::to_string(&response).unwrap();
795        assert!(json.contains("is_running"));
796        assert!(json.contains("watched_count"));
797    }
798}