1use serde::{Deserialize, Serialize};
35use spn_core::{LoadConfig, ModelInfo, PullProgress, RunningModel};
36
37#[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#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IpcJobStatus {
67 pub id: String,
69 pub workflow: String,
71 pub state: IpcJobState,
73 pub name: Option<String>,
75 pub progress: u8,
77 pub error: Option<String>,
79 pub output: Option<String>,
81 pub created_at: u64,
83 pub started_at: Option<u64>,
85 pub ended_at: Option<u64>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct IpcSchedulerStats {
92 pub total: usize,
94 pub pending: usize,
96 pub running: usize,
98 pub completed: usize,
100 pub failed: usize,
102 pub cancelled: usize,
104 pub has_nika: bool,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct RecentProjectInfo {
115 pub path: String,
117 pub last_used: String,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ForeignMcpInfo {
124 pub name: String,
126 pub source: String,
128 pub scope: String,
130 pub detected: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct WatcherStatusInfo {
137 pub is_running: bool,
139 pub watched_count: usize,
141 pub watched_paths: Vec<String>,
143 pub debounce_ms: u64,
145 pub recent_projects: Vec<RecentProjectInfo>,
147 pub foreign_pending: Vec<ForeignMcpInfo>,
149 pub foreign_ignored: Vec<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ModelProgress {
158 pub status: String,
160 pub completed: Option<u64>,
162 pub total: Option<u64>,
164 pub digest: Option<String>,
166}
167
168impl ModelProgress {
169 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 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 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 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, }
208 }
209}
210
211pub const PROTOCOL_VERSION: u32 = 1;
220
221fn default_protocol_version() -> u32 {
224 0
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(tag = "cmd")]
230pub enum Request {
231 #[serde(rename = "PING")]
233 Ping,
234
235 #[serde(rename = "GET_SECRET")]
237 GetSecret { provider: String },
238
239 #[serde(rename = "HAS_SECRET")]
241 HasSecret { provider: String },
242
243 #[serde(rename = "LIST_PROVIDERS")]
245 ListProviders,
246
247 #[serde(rename = "REFRESH_SECRET")]
250 RefreshSecret { provider: String },
251
252 #[serde(rename = "MODEL_LIST")]
255 ModelList,
256
257 #[serde(rename = "MODEL_PULL")]
259 ModelPull { name: String },
260
261 #[serde(rename = "MODEL_LOAD")]
263 ModelLoad {
264 name: String,
265 #[serde(default)]
266 config: Option<LoadConfig>,
267 },
268
269 #[serde(rename = "MODEL_UNLOAD")]
271 ModelUnload { name: String },
272
273 #[serde(rename = "MODEL_STATUS")]
275 ModelStatus,
276
277 #[serde(rename = "MODEL_DELETE")]
279 ModelDelete { name: String },
280
281 #[serde(rename = "MODEL_RUN")]
283 ModelRun {
284 model: String,
286 prompt: String,
288 #[serde(default)]
290 system: Option<String>,
291 #[serde(default)]
293 temperature: Option<f32>,
294 #[serde(default)]
296 stream: bool,
297 },
298
299 #[serde(rename = "JOB_SUBMIT")]
302 JobSubmit {
303 workflow: String,
305 #[serde(default)]
307 args: Vec<String>,
308 #[serde(default)]
310 name: Option<String>,
311 #[serde(default)]
313 priority: i32,
314 },
315
316 #[serde(rename = "JOB_STATUS")]
318 JobStatus {
319 job_id: String,
321 },
322
323 #[serde(rename = "JOB_LIST")]
325 JobList {
326 #[serde(default)]
328 state: Option<String>,
329 },
330
331 #[serde(rename = "JOB_CANCEL")]
333 JobCancel {
334 job_id: String,
336 },
337
338 #[serde(rename = "JOB_STATS")]
340 JobStats,
341
342 #[serde(rename = "WATCHER_STATUS")]
345 WatcherStatus,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(untagged)]
351pub enum Response {
352 Pong {
354 #[serde(default = "default_protocol_version")]
357 protocol_version: u32,
358 version: String,
360 },
361
362 Secret { value: String },
371
372 Exists { exists: bool },
374
375 Providers { providers: Vec<String> },
377
378 Refreshed {
380 refreshed: bool,
382 provider: String,
384 },
385
386 Models { models: Vec<ModelInfo> },
389
390 RunningModels { running: Vec<RunningModel> },
392
393 Success { success: bool },
395
396 ModelRunResult {
398 content: String,
400 #[serde(default)]
402 stats: Option<serde_json::Value>,
403 },
404
405 Error { message: String },
407
408 Progress {
411 progress: ModelProgress,
413 },
414
415 StreamEnd {
417 success: bool,
419 #[serde(default)]
421 error: Option<String>,
422 },
423
424 WatcherStatusResult {
430 status: WatcherStatusInfo,
432 },
433
434 JobSubmitted {
437 job: IpcJobStatus,
439 },
440
441 JobStatusResult {
443 job: Option<IpcJobStatus>,
445 },
446
447 JobListResult {
449 jobs: Vec<IpcJobStatus>,
451 },
452
453 JobCancelled {
455 cancelled: bool,
457 job_id: String,
459 },
460
461 JobStatsResult {
463 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 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 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 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 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 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 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 #[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 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 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 #[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 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}