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(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
468impl std::fmt::Debug for Response {
469 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470 match self {
471 Response::Secret { .. } => write!(f, "Response::Secret {{ value: [REDACTED] }}"),
473 Response::Pong {
475 protocol_version,
476 version,
477 } => f
478 .debug_struct("Pong")
479 .field("protocol_version", protocol_version)
480 .field("version", version)
481 .finish(),
482 Response::Exists { exists } => {
483 f.debug_struct("Exists").field("exists", exists).finish()
484 }
485 Response::Providers { providers } => f
486 .debug_struct("Providers")
487 .field("providers", providers)
488 .finish(),
489 Response::Refreshed { refreshed, provider } => f
490 .debug_struct("Refreshed")
491 .field("refreshed", refreshed)
492 .field("provider", provider)
493 .finish(),
494 Response::Models { models } => {
495 f.debug_struct("Models").field("models", models).finish()
496 }
497 Response::RunningModels { running } => f
498 .debug_struct("RunningModels")
499 .field("running", running)
500 .finish(),
501 Response::Success { success } => {
502 f.debug_struct("Success").field("success", success).finish()
503 }
504 Response::ModelRunResult { content, stats } => f
505 .debug_struct("ModelRunResult")
506 .field("content", content)
507 .field("stats", stats)
508 .finish(),
509 Response::Error { message } => {
510 f.debug_struct("Error").field("message", message).finish()
511 }
512 Response::Progress { progress } => f
513 .debug_struct("Progress")
514 .field("progress", progress)
515 .finish(),
516 Response::StreamEnd { success, error } => f
517 .debug_struct("StreamEnd")
518 .field("success", success)
519 .field("error", error)
520 .finish(),
521 Response::WatcherStatusResult { status } => f
522 .debug_struct("WatcherStatusResult")
523 .field("status", status)
524 .finish(),
525 Response::JobSubmitted { job } => {
526 f.debug_struct("JobSubmitted").field("job", job).finish()
527 }
528 Response::JobStatusResult { job } => {
529 f.debug_struct("JobStatusResult").field("job", job).finish()
530 }
531 Response::JobListResult { jobs } => {
532 f.debug_struct("JobListResult").field("jobs", jobs).finish()
533 }
534 Response::JobCancelled { cancelled, job_id } => f
535 .debug_struct("JobCancelled")
536 .field("cancelled", cancelled)
537 .field("job_id", job_id)
538 .finish(),
539 Response::JobStatsResult { stats } => f
540 .debug_struct("JobStatsResult")
541 .field("stats", stats)
542 .finish(),
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 #[test]
552 fn test_request_serialization() {
553 let ping = Request::Ping;
554 let json = serde_json::to_string(&ping).unwrap();
555 assert_eq!(json, r#"{"cmd":"PING"}"#);
556
557 let get_secret = Request::GetSecret {
558 provider: "anthropic".to_string(),
559 };
560 let json = serde_json::to_string(&get_secret).unwrap();
561 assert_eq!(json, r#"{"cmd":"GET_SECRET","provider":"anthropic"}"#);
562
563 let has_secret = Request::HasSecret {
564 provider: "openai".to_string(),
565 };
566 let json = serde_json::to_string(&has_secret).unwrap();
567 assert_eq!(json, r#"{"cmd":"HAS_SECRET","provider":"openai"}"#);
568
569 let list = Request::ListProviders;
570 let json = serde_json::to_string(&list).unwrap();
571 assert_eq!(json, r#"{"cmd":"LIST_PROVIDERS"}"#);
572 }
573
574 #[test]
575 fn test_response_deserialization() {
576 let json = r#"{"protocol_version":1,"version":"0.14.2"}"#;
578 let response: Response = serde_json::from_str(json).unwrap();
579 assert!(
580 matches!(response, Response::Pong { protocol_version, version }
581 if protocol_version == 1 && version == "0.14.2")
582 );
583
584 let json = r#"{"version":"0.9.0"}"#;
586 let response: Response = serde_json::from_str(json).unwrap();
587 assert!(
588 matches!(response, Response::Pong { protocol_version, version }
589 if protocol_version == 0 && version == "0.9.0")
590 );
591
592 let json = r#"{"value":"sk-test-123"}"#;
594 let response: Response = serde_json::from_str(json).unwrap();
595 assert!(matches!(response, Response::Secret { value } if value == "sk-test-123"));
596
597 let json = r#"{"exists":true}"#;
599 let response: Response = serde_json::from_str(json).unwrap();
600 assert!(matches!(response, Response::Exists { exists } if exists));
601
602 let json = r#"{"providers":["anthropic","openai"]}"#;
604 let response: Response = serde_json::from_str(json).unwrap();
605 assert!(
606 matches!(response, Response::Providers { providers } if providers == vec!["anthropic", "openai"])
607 );
608
609 let json = r#"{"message":"Not found"}"#;
611 let response: Response = serde_json::from_str(json).unwrap();
612 assert!(matches!(response, Response::Error { message } if message == "Not found"));
613 }
614
615 #[test]
616 fn test_model_progress_serialization() {
617 let progress = ModelProgress {
618 status: "downloading".into(),
619 completed: Some(50),
620 total: Some(100),
621 digest: Some("sha256:abc123".into()),
622 };
623
624 let json = serde_json::to_string(&progress).unwrap();
625 let parsed: ModelProgress = serde_json::from_str(&json).unwrap();
626
627 assert_eq!(parsed.status, "downloading");
628 assert_eq!(parsed.completed, Some(50));
629 assert_eq!(parsed.total, Some(100));
630 }
631
632 #[test]
633 fn test_model_progress_percentage() {
634 let progress = ModelProgress {
635 status: "downloading".into(),
636 completed: Some(75),
637 total: Some(100),
638 digest: None,
639 };
640
641 assert_eq!(progress.percentage(), Some(75.0));
642
643 let no_total = ModelProgress {
644 status: "starting".into(),
645 completed: None,
646 total: None,
647 digest: None,
648 };
649
650 assert_eq!(no_total.percentage(), None);
651 }
652
653 #[test]
654 fn test_model_progress_constructors() {
655 let indeterminate = ModelProgress::indeterminate("loading");
656 assert_eq!(indeterminate.status, "loading");
657 assert!(indeterminate.percentage().is_none());
658
659 let determinate = ModelProgress::determinate("downloading", 50, 100);
660 assert_eq!(determinate.percentage(), Some(50.0));
661 }
662
663 #[test]
664 fn test_response_progress_variant() {
665 let progress = ModelProgress::determinate("downloading", 50, 100);
666 let response = Response::Progress { progress };
667
668 let json = serde_json::to_string(&response).unwrap();
669 assert!(json.contains("downloading"));
670 }
671
672 #[test]
673 fn test_response_stream_end_variant() {
674 let success_response = Response::StreamEnd {
675 success: true,
676 error: None,
677 };
678 let json = serde_json::to_string(&success_response).unwrap();
679 assert!(json.contains("success"));
680
681 let error_response = Response::StreamEnd {
682 success: false,
683 error: Some("Connection lost".into()),
684 };
685 let json = serde_json::to_string(&error_response).unwrap();
686 assert!(json.contains("Connection lost"));
687 }
688
689 #[test]
692 fn test_job_request_serialization() {
693 let submit = Request::JobSubmit {
694 workflow: "/path/to/workflow.yaml".into(),
695 args: vec!["--verbose".into()],
696 name: Some("Test Job".into()),
697 priority: 5,
698 };
699 let json = serde_json::to_string(&submit).unwrap();
700 assert!(json.contains("JOB_SUBMIT"));
701 assert!(json.contains("workflow.yaml"));
702
703 let status = Request::JobStatus {
704 job_id: "abc12345".into(),
705 };
706 let json = serde_json::to_string(&status).unwrap();
707 assert!(json.contains("JOB_STATUS"));
708 assert!(json.contains("abc12345"));
709
710 let list = Request::JobList { state: None };
711 let json = serde_json::to_string(&list).unwrap();
712 assert!(json.contains("JOB_LIST"));
713
714 let cancel = Request::JobCancel {
715 job_id: "def67890".into(),
716 };
717 let json = serde_json::to_string(&cancel).unwrap();
718 assert!(json.contains("JOB_CANCEL"));
719
720 let stats = Request::JobStats;
721 let json = serde_json::to_string(&stats).unwrap();
722 assert!(json.contains("JOB_STATS"));
723 }
724
725 #[test]
726 fn test_ipc_job_state_serialization() {
727 assert_eq!(
728 serde_json::to_string(&IpcJobState::Pending).unwrap(),
729 r#""pending""#
730 );
731 assert_eq!(
732 serde_json::to_string(&IpcJobState::Running).unwrap(),
733 r#""running""#
734 );
735 assert_eq!(
736 serde_json::to_string(&IpcJobState::Completed).unwrap(),
737 r#""completed""#
738 );
739 assert_eq!(
740 serde_json::to_string(&IpcJobState::Failed).unwrap(),
741 r#""failed""#
742 );
743 assert_eq!(
744 serde_json::to_string(&IpcJobState::Cancelled).unwrap(),
745 r#""cancelled""#
746 );
747 }
748
749 #[test]
750 fn test_ipc_job_status_serialization() {
751 let status = IpcJobStatus {
752 id: "abc12345".into(),
753 workflow: "/path/to/test.yaml".into(),
754 state: IpcJobState::Running,
755 name: Some("Test Job".into()),
756 progress: 50,
757 error: None,
758 output: None,
759 created_at: 1710000000000,
760 started_at: Some(1710000001000),
761 ended_at: None,
762 };
763
764 let json = serde_json::to_string(&status).unwrap();
765 assert!(json.contains("abc12345"));
766 assert!(json.contains("running"));
767 assert!(json.contains("Test Job"));
768 }
769
770 #[test]
771 fn test_ipc_scheduler_stats_serialization() {
772 let stats = IpcSchedulerStats {
773 total: 10,
774 pending: 2,
775 running: 3,
776 completed: 4,
777 failed: 1,
778 cancelled: 0,
779 has_nika: true,
780 };
781
782 let json = serde_json::to_string(&stats).unwrap();
783 let parsed: IpcSchedulerStats = serde_json::from_str(&json).unwrap();
784
785 assert_eq!(parsed.total, 10);
786 assert_eq!(parsed.running, 3);
787 assert!(parsed.has_nika);
788 }
789
790 #[test]
791 fn test_job_response_variants() {
792 let status = IpcJobStatus {
794 id: "abc12345".into(),
795 workflow: "/test.yaml".into(),
796 state: IpcJobState::Pending,
797 name: None,
798 progress: 0,
799 error: None,
800 output: None,
801 created_at: 1710000000000,
802 started_at: None,
803 ended_at: None,
804 };
805 let response = Response::JobSubmitted { job: status };
806 let json = serde_json::to_string(&response).unwrap();
807 assert!(json.contains("abc12345"));
808
809 let response = Response::JobCancelled {
811 cancelled: true,
812 job_id: "def67890".into(),
813 };
814 let json = serde_json::to_string(&response).unwrap();
815 assert!(json.contains("cancelled"));
816 assert!(json.contains("def67890"));
817 }
818
819 #[test]
822 fn test_watcher_request_serialization() {
823 let request = Request::WatcherStatus;
824 let json = serde_json::to_string(&request).unwrap();
825 assert_eq!(json, r#"{"cmd":"WATCHER_STATUS"}"#);
826 }
827
828 #[test]
829 fn test_watcher_status_info_serialization() {
830 let status = WatcherStatusInfo {
831 is_running: true,
832 watched_count: 8,
833 watched_paths: vec!["~/.spn/mcp.yaml".into(), "~/.claude.json".into()],
834 debounce_ms: 500,
835 recent_projects: vec![RecentProjectInfo {
836 path: "/Users/test/project".into(),
837 last_used: "2026-03-09T12:00:00Z".into(),
838 }],
839 foreign_pending: vec![ForeignMcpInfo {
840 name: "github-copilot".into(),
841 source: "cursor".into(),
842 scope: "global".into(),
843 detected: "2026-03-09T11:00:00Z".into(),
844 }],
845 foreign_ignored: vec!["some-mcp".into()],
846 };
847
848 let json = serde_json::to_string(&status).unwrap();
849 assert!(json.contains("is_running"));
850 assert!(json.contains("watched_count"));
851 assert!(json.contains("github-copilot"));
852
853 let parsed: WatcherStatusInfo = serde_json::from_str(&json).unwrap();
855 assert!(parsed.is_running);
856 assert_eq!(parsed.watched_count, 8);
857 assert_eq!(parsed.recent_projects.len(), 1);
858 assert_eq!(parsed.foreign_pending.len(), 1);
859 }
860
861 #[test]
862 fn test_watcher_status_response_variant() {
863 let status = WatcherStatusInfo {
864 is_running: true,
865 watched_count: 5,
866 watched_paths: vec![],
867 debounce_ms: 500,
868 recent_projects: vec![],
869 foreign_pending: vec![],
870 foreign_ignored: vec![],
871 };
872 let response = Response::WatcherStatusResult { status };
873 let json = serde_json::to_string(&response).unwrap();
874 assert!(json.contains("is_running"));
875 assert!(json.contains("watched_count"));
876 }
877
878 #[test]
881 fn test_response_secret_debug_redacted() {
882 let secret = Response::Secret {
883 value: "sk-ant-api03-super-secret-key".to_string(),
884 };
885 let debug_output = format!("{:?}", secret);
886
887 assert!(
889 !debug_output.contains("sk-ant"),
890 "Secret value leaked in Debug output: {debug_output}"
891 );
892 assert!(
893 !debug_output.contains("super-secret"),
894 "Secret value leaked in Debug output: {debug_output}"
895 );
896
897 assert!(
899 debug_output.contains("[REDACTED]"),
900 "Debug output should contain [REDACTED]: {debug_output}"
901 );
902 }
903
904 #[test]
905 fn test_response_other_variants_debug_normal() {
906 let pong = Response::Pong {
908 protocol_version: 1,
909 version: "0.15.0".to_string(),
910 };
911 let debug_output = format!("{:?}", pong);
912 assert!(debug_output.contains("protocol_version"));
913 assert!(debug_output.contains("0.15.0"));
914
915 let error = Response::Error {
916 message: "test error".to_string(),
917 };
918 let debug_output = format!("{:?}", error);
919 assert!(debug_output.contains("test error"));
920 }
921}