1use std::collections::HashMap;
19
20use chrono::{DateTime, Utc};
21use dashmap::DashMap;
22use serde::{Deserialize, Serialize};
23use tracing::debug;
24
25use crate::capability::{AgentCapabilities, IpcScope};
26use crate::container::PortMapping;
27use crate::process::Pid;
28use crate::supervisor::SpawnRequest;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AppManifest {
38 pub name: String,
40
41 pub version: String,
43
44 #[serde(default)]
46 pub description: String,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub author: Option<String>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub license: Option<String>,
55
56 #[serde(default)]
58 pub agents: Vec<AgentSpec>,
59
60 #[serde(default)]
62 pub tools: Vec<ToolSpec>,
63
64 #[serde(default)]
66 pub services: Vec<ServiceSpec>,
67
68 #[serde(default)]
70 pub capabilities: AppCapabilities,
71
72 #[serde(default)]
74 pub hooks: AppHooks,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct AgentSpec {
80 pub id: String,
82
83 #[serde(default)]
85 pub role: String,
86
87 #[serde(default)]
89 pub capabilities: AgentCapabilities,
90
91 #[serde(default)]
93 pub auto_start: bool,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ToolSpec {
99 pub name: String,
101
102 pub source: ToolSource,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub schema: Option<serde_json::Value>,
108}
109
110#[non_exhaustive]
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub enum ToolSource {
114 Wasm(String),
116 Native(String),
118 Skill(String),
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ServiceSpec {
125 pub name: String,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub image: Option<String>,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub command: Option<String>,
135
136 #[serde(default)]
138 pub ports: Vec<PortMapping>,
139
140 #[serde(default)]
142 pub env: HashMap<String, String>,
143
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub health_endpoint: Option<String>,
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
151pub struct AppCapabilities {
152 #[serde(default)]
154 pub network: bool,
155
156 #[serde(default)]
158 pub filesystem: Vec<String>,
159
160 #[serde(default)]
162 pub shell: bool,
163
164 #[serde(default)]
166 pub ipc: IpcScope,
167}
168
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
171pub struct AppHooks {
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub on_install: Option<String>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub on_start: Option<String>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub on_stop: Option<String>,
183
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub on_remove: Option<String>,
187}
188
189#[non_exhaustive]
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194pub enum AppState {
195 Installed,
197 Starting,
199 Running,
201 Stopping,
203 Stopped,
205 Failed(String),
207}
208
209impl std::fmt::Display for AppState {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 match self {
212 AppState::Installed => write!(f, "installed"),
213 AppState::Starting => write!(f, "starting"),
214 AppState::Running => write!(f, "running"),
215 AppState::Stopping => write!(f, "stopping"),
216 AppState::Stopped => write!(f, "stopped"),
217 AppState::Failed(reason) => write!(f, "failed: {reason}"),
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct InstalledApp {
225 pub manifest: AppManifest,
227
228 pub state: AppState,
230
231 pub installed_at: DateTime<Utc>,
233
234 #[serde(default)]
236 pub agent_pids: Vec<Pid>,
237
238 #[serde(default)]
240 pub service_names: Vec<String>,
241}
242
243#[non_exhaustive]
247#[derive(Debug, thiserror::Error)]
248pub enum AppError {
249 #[error("manifest not found at '{path}'")]
251 ManifestNotFound {
252 path: String,
254 },
255
256 #[error("invalid manifest: {reason}")]
258 ManifestInvalid {
259 reason: String,
261 },
262
263 #[error("app already installed: '{name}'")]
265 AlreadyInstalled {
266 name: String,
268 },
269
270 #[error("app not found: '{name}'")]
272 NotFound {
273 name: String,
275 },
276
277 #[error("invalid state for app '{name}': expected {expected}, got {actual}")]
279 InvalidState {
280 name: String,
282 expected: String,
284 actual: String,
286 },
287
288 #[error("failed to spawn agent '{agent_id}' for app '{app_name}': {reason}")]
290 SpawnFailed {
291 app_name: String,
293 agent_id: String,
295 reason: String,
297 },
298
299 #[error("hook '{hook}' failed for app '{app_name}': {reason}")]
301 HookFailed {
302 app_name: String,
304 hook: String,
306 reason: String,
308 },
309}
310
311pub fn validate_manifest(manifest: &AppManifest) -> Result<(), AppError> {
323 if manifest.name.is_empty() {
325 return Err(AppError::ManifestInvalid {
326 reason: "app name must not be empty".into(),
327 });
328 }
329
330 if !manifest
331 .name
332 .chars()
333 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
334 {
335 return Err(AppError::ManifestInvalid {
336 reason: format!(
337 "app name '{}' contains invalid characters (use alphanumeric, - or _)",
338 manifest.name
339 ),
340 });
341 }
342
343 if manifest.version.is_empty() {
345 return Err(AppError::ManifestInvalid {
346 reason: "version must not be empty".into(),
347 });
348 }
349
350 let mut agent_ids = std::collections::HashSet::new();
352 for agent in &manifest.agents {
353 if agent.id.is_empty() {
354 return Err(AppError::ManifestInvalid {
355 reason: "agent id must not be empty".into(),
356 });
357 }
358 if !agent_ids.insert(&agent.id) {
359 return Err(AppError::ManifestInvalid {
360 reason: format!("duplicate agent id: '{}'", agent.id),
361 });
362 }
363 }
364
365 let mut tool_names = std::collections::HashSet::new();
367 for tool in &manifest.tools {
368 if tool.name.is_empty() {
369 return Err(AppError::ManifestInvalid {
370 reason: "tool name must not be empty".into(),
371 });
372 }
373 if !tool_names.insert(&tool.name) {
374 return Err(AppError::ManifestInvalid {
375 reason: format!("duplicate tool name: '{}'", tool.name),
376 });
377 }
378 }
379
380 let mut service_names = std::collections::HashSet::new();
382 for service in &manifest.services {
383 if service.name.is_empty() {
384 return Err(AppError::ManifestInvalid {
385 reason: "service name must not be empty".into(),
386 });
387 }
388 if !service_names.insert(&service.name) {
389 return Err(AppError::ManifestInvalid {
390 reason: format!("duplicate service name: '{}'", service.name),
391 });
392 }
393 }
394
395 Ok(())
396}
397
398pub struct AppManager {
407 apps: DashMap<String, InstalledApp>,
408}
409
410impl AppManager {
411 pub fn new() -> Self {
413 Self {
414 apps: DashMap::new(),
415 }
416 }
417
418 pub fn install(&self, manifest: AppManifest) -> Result<String, AppError> {
428 validate_manifest(&manifest)?;
429
430 let name = manifest.name.clone();
431
432 if self.apps.contains_key(&name) {
433 return Err(AppError::AlreadyInstalled { name: name.clone() });
434 }
435
436 debug!(app = %name, version = %manifest.version, "installing application");
437
438 self.apps.insert(
439 name.clone(),
440 InstalledApp {
441 manifest,
442 state: AppState::Installed,
443 installed_at: Utc::now(),
444 agent_pids: Vec::new(),
445 service_names: Vec::new(),
446 },
447 );
448
449 Ok(name)
450 }
451
452 pub fn transition_to(&self, name: &str, new_state: AppState) -> Result<(), AppError> {
465 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
466 name: name.to_owned(),
467 })?;
468
469 let valid = matches!(
470 (&entry.state, &new_state),
471 (AppState::Installed, AppState::Starting)
472 | (AppState::Starting, AppState::Running)
473 | (AppState::Starting, AppState::Failed(_))
474 | (AppState::Running, AppState::Stopping)
475 | (AppState::Stopping, AppState::Stopped)
476 | (AppState::Stopping, AppState::Failed(_))
477 | (AppState::Stopped, AppState::Starting)
478 );
479
480 if !valid {
481 return Err(AppError::InvalidState {
482 name: name.to_owned(),
483 expected: format!("valid transition from {}", entry.state),
484 actual: format!("{} -> {new_state}", entry.state),
485 });
486 }
487
488 debug!(app = name, from = %entry.state, to = %new_state, "state transition");
489 entry.state = new_state;
490 Ok(())
491 }
492
493 pub fn add_agent_pid(&self, name: &str, pid: Pid) -> Result<(), AppError> {
495 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
496 name: name.to_owned(),
497 })?;
498 entry.agent_pids.push(pid);
499 Ok(())
500 }
501
502 pub fn add_service_name(&self, name: &str, service_name: String) -> Result<(), AppError> {
504 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
505 name: name.to_owned(),
506 })?;
507 entry.service_names.push(service_name);
508 Ok(())
509 }
510
511 pub fn remove(&self, name: &str) -> Result<AppManifest, AppError> {
515 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
516 name: name.to_owned(),
517 })?;
518
519 let removable = matches!(
520 entry.state,
521 AppState::Installed | AppState::Stopped | AppState::Failed(_)
522 );
523
524 if !removable {
525 return Err(AppError::InvalidState {
526 name: name.to_owned(),
527 expected: "Installed, Stopped, or Failed".into(),
528 actual: entry.state.to_string(),
529 });
530 }
531
532 drop(entry); let (_, app) = self.apps.remove(name).ok_or_else(|| AppError::NotFound {
534 name: name.to_owned(),
535 })?;
536
537 debug!(app = name, "removed application");
538 Ok(app.manifest)
539 }
540
541 pub fn list(&self) -> Vec<(String, AppState, String)> {
543 self.apps
544 .iter()
545 .map(|entry| {
546 (
547 entry.key().clone(),
548 entry.state.clone(),
549 entry.manifest.version.clone(),
550 )
551 })
552 .collect()
553 }
554
555 pub fn inspect(&self, name: &str) -> Result<InstalledApp, AppError> {
557 self.apps
558 .get(name)
559 .map(|e| e.value().clone())
560 .ok_or_else(|| AppError::NotFound {
561 name: name.to_owned(),
562 })
563 }
564
565 pub fn len(&self) -> usize {
567 self.apps.len()
568 }
569
570 pub fn is_empty(&self) -> bool {
572 self.apps.is_empty()
573 }
574
575 pub fn namespaced_agent_ids(manifest: &AppManifest) -> Vec<String> {
579 manifest
580 .agents
581 .iter()
582 .map(|a| format!("{}/{}", manifest.name, a.id))
583 .collect()
584 }
585
586 pub fn namespaced_tool_names(manifest: &AppManifest) -> Vec<String> {
590 manifest
591 .tools
592 .iter()
593 .map(|t| format!("{}/{}", manifest.name, t.name))
594 .collect()
595 }
596
597 pub fn start(&self, name: &str) -> Result<Vec<SpawnRequest>, AppError> {
613 {
615 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
616 name: name.to_owned(),
617 })?;
618 let startable = matches!(entry.state, AppState::Installed | AppState::Stopped);
619 if !startable {
620 return Err(AppError::InvalidState {
621 name: name.to_owned(),
622 expected: "Installed or Stopped".into(),
623 actual: entry.state.to_string(),
624 });
625 }
626 }
627
628 self.transition_to(name, AppState::Starting)?;
630
631 let spawn_requests = {
633 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
634 name: name.to_owned(),
635 })?;
636 Self::build_spawn_requests(&entry.manifest)
637 };
638
639 self.transition_to(name, AppState::Running)?;
641
642 debug!(
643 app = name,
644 agents = spawn_requests.len(),
645 "application started"
646 );
647
648 Ok(spawn_requests)
649 }
650
651 pub fn stop(&self, name: &str) -> Result<(), AppError> {
660 {
661 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
662 name: name.to_owned(),
663 })?;
664 if entry.state != AppState::Running {
665 return Err(AppError::InvalidState {
666 name: name.to_owned(),
667 expected: "Running".into(),
668 actual: entry.state.to_string(),
669 });
670 }
671 }
672
673 self.transition_to(name, AppState::Stopping)?;
674
675 {
677 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
678 name: name.to_owned(),
679 })?;
680 entry.agent_pids.clear();
681 entry.service_names.clear();
682 }
683
684 self.transition_to(name, AppState::Stopped)?;
685
686 debug!(app = name, "application stopped");
687 Ok(())
688 }
689
690 pub fn build_spawn_requests(manifest: &AppManifest) -> Vec<SpawnRequest> {
695 manifest
696 .agents
697 .iter()
698 .map(|agent| SpawnRequest {
699 agent_id: format!("{}/{}", manifest.name, agent.id),
700 capabilities: Some(agent.capabilities.clone()),
701 parent_pid: None,
702 env: HashMap::new(),
703 backend: None,
704 })
705 .collect()
706 }
707}
708
709impl Default for AppManager {
710 fn default() -> Self {
711 Self::new()
712 }
713}
714
715impl AppManifest {
718 pub fn from_json_str(json: &str) -> Result<Self, AppError> {
724 let manifest: AppManifest =
725 serde_json::from_str(json).map_err(|e| AppError::ManifestInvalid {
726 reason: format!("JSON parse error: {e}"),
727 })?;
728 validate_manifest(&manifest)?;
729 Ok(manifest)
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 fn sample_manifest() -> AppManifest {
738 AppManifest {
739 name: "code-reviewer".into(),
740 version: "1.0.0".into(),
741 description: "Automated code review app".into(),
742 author: Some("WeftOS Team".into()),
743 license: Some("MIT".into()),
744 agents: vec![
745 AgentSpec {
746 id: "reviewer".into(),
747 role: "code-review".into(),
748 capabilities: AgentCapabilities::default(),
749 auto_start: true,
750 },
751 AgentSpec {
752 id: "reporter".into(),
753 role: "report-generator".into(),
754 capabilities: AgentCapabilities {
755 can_network: false,
756 ..Default::default()
757 },
758 auto_start: true,
759 },
760 ],
761 tools: vec![ToolSpec {
762 name: "diff-analyzer".into(),
763 source: ToolSource::Wasm("tools/diff-analyzer.wasm".into()),
764 schema: None,
765 }],
766 services: vec![ServiceSpec {
767 name: "review-db".into(),
768 image: Some("redis:7-alpine".into()),
769 command: None,
770 ports: vec![PortMapping {
771 host_port: 6380,
772 container_port: 6379,
773 protocol: "tcp".into(),
774 }],
775 env: HashMap::new(),
776 health_endpoint: Some("redis://localhost:6380".into()),
777 }],
778 capabilities: AppCapabilities {
779 network: true,
780 filesystem: vec!["/workspace".into()],
781 shell: false,
782 ipc: IpcScope::All,
783 },
784 hooks: AppHooks {
785 on_install: Some("scripts/setup.sh".into()),
786 on_start: Some("scripts/migrate.sh".into()),
787 on_stop: None,
788 on_remove: None,
789 },
790 }
791 }
792
793 #[test]
794 fn manifest_serde_roundtrip() {
795 let manifest = sample_manifest();
796 let json = serde_json::to_string_pretty(&manifest).unwrap();
797 let restored: AppManifest = serde_json::from_str(&json).unwrap();
798 assert_eq!(restored.name, "code-reviewer");
799 assert_eq!(restored.version, "1.0.0");
800 assert_eq!(restored.agents.len(), 2);
801 assert_eq!(restored.tools.len(), 1);
802 assert_eq!(restored.services.len(), 1);
803 }
804
805 #[test]
806 fn manifest_minimal_serde() {
807 let json = r#"{"name":"my-app","version":"0.1.0"}"#;
808 let manifest: AppManifest = serde_json::from_str(json).unwrap();
809 assert_eq!(manifest.name, "my-app");
810 assert!(manifest.agents.is_empty());
811 assert!(manifest.tools.is_empty());
812 assert!(manifest.services.is_empty());
813 assert!(!manifest.capabilities.network);
814 }
815
816 #[test]
817 fn validate_manifest_ok() {
818 let manifest = sample_manifest();
819 assert!(validate_manifest(&manifest).is_ok());
820 }
821
822 #[test]
823 fn validate_manifest_empty_name() {
824 let mut manifest = sample_manifest();
825 manifest.name = String::new();
826 let err = validate_manifest(&manifest).unwrap_err();
827 assert!(err.to_string().contains("empty"));
828 }
829
830 #[test]
831 fn validate_manifest_invalid_name_chars() {
832 let mut manifest = sample_manifest();
833 manifest.name = "my app!".into();
834 let err = validate_manifest(&manifest).unwrap_err();
835 assert!(err.to_string().contains("invalid characters"));
836 }
837
838 #[test]
839 fn validate_manifest_empty_version() {
840 let mut manifest = sample_manifest();
841 manifest.version = String::new();
842 let err = validate_manifest(&manifest).unwrap_err();
843 assert!(err.to_string().contains("version"));
844 }
845
846 #[test]
847 fn validate_manifest_duplicate_agent_ids() {
848 let mut manifest = sample_manifest();
849 manifest.agents.push(AgentSpec {
850 id: "reviewer".into(), role: "other".into(),
852 capabilities: AgentCapabilities::default(),
853 auto_start: false,
854 });
855 let err = validate_manifest(&manifest).unwrap_err();
856 assert!(err.to_string().contains("duplicate agent"));
857 }
858
859 #[test]
860 fn validate_manifest_duplicate_tool_names() {
861 let mut manifest = sample_manifest();
862 manifest.tools.push(ToolSpec {
863 name: "diff-analyzer".into(), source: ToolSource::Native("builtin".into()),
865 schema: None,
866 });
867 let err = validate_manifest(&manifest).unwrap_err();
868 assert!(err.to_string().contains("duplicate tool"));
869 }
870
871 #[test]
872 fn validate_manifest_duplicate_service_names() {
873 let mut manifest = sample_manifest();
874 manifest.services.push(ServiceSpec {
875 name: "review-db".into(), image: None,
877 command: Some("redis-server".into()),
878 ports: Vec::new(),
879 env: HashMap::new(),
880 health_endpoint: None,
881 });
882 let err = validate_manifest(&manifest).unwrap_err();
883 assert!(err.to_string().contains("duplicate service"));
884 }
885
886 #[test]
887 fn app_state_display() {
888 assert_eq!(AppState::Installed.to_string(), "installed");
889 assert_eq!(AppState::Running.to_string(), "running");
890 assert_eq!(AppState::Stopped.to_string(), "stopped");
891 assert_eq!(
892 AppState::Failed("timeout".into()).to_string(),
893 "failed: timeout"
894 );
895 }
896
897 #[test]
898 fn install_and_list() {
899 let manager = AppManager::new();
900 let name = manager.install(sample_manifest()).unwrap();
901 assert_eq!(name, "code-reviewer");
902
903 let list = manager.list();
904 assert_eq!(list.len(), 1);
905 assert_eq!(list[0].0, "code-reviewer");
906 assert_eq!(list[0].1, AppState::Installed);
907 }
908
909 #[test]
910 fn install_duplicate_fails() {
911 let manager = AppManager::new();
912 manager.install(sample_manifest()).unwrap();
913 let err = manager.install(sample_manifest()).unwrap_err();
914 assert!(matches!(err, AppError::AlreadyInstalled { .. }));
915 }
916
917 #[test]
918 fn inspect_installed_app() {
919 let manager = AppManager::new();
920 manager.install(sample_manifest()).unwrap();
921 let app = manager.inspect("code-reviewer").unwrap();
922 assert_eq!(app.state, AppState::Installed);
923 assert_eq!(app.manifest.agents.len(), 2);
924 }
925
926 #[test]
927 fn inspect_not_found() {
928 let manager = AppManager::new();
929 assert!(matches!(
930 manager.inspect("nope"),
931 Err(AppError::NotFound { .. })
932 ));
933 }
934
935 #[test]
936 fn state_transitions() {
937 let manager = AppManager::new();
938 manager.install(sample_manifest()).unwrap();
939
940 manager
942 .transition_to("code-reviewer", AppState::Starting)
943 .unwrap();
944 manager
945 .transition_to("code-reviewer", AppState::Running)
946 .unwrap();
947 manager
948 .transition_to("code-reviewer", AppState::Stopping)
949 .unwrap();
950 manager
951 .transition_to("code-reviewer", AppState::Stopped)
952 .unwrap();
953
954 let app = manager.inspect("code-reviewer").unwrap();
955 assert_eq!(app.state, AppState::Stopped);
956 }
957
958 #[test]
959 fn state_transition_restart() {
960 let manager = AppManager::new();
961 manager.install(sample_manifest()).unwrap();
962
963 manager
964 .transition_to("code-reviewer", AppState::Starting)
965 .unwrap();
966 manager
967 .transition_to("code-reviewer", AppState::Running)
968 .unwrap();
969 manager
970 .transition_to("code-reviewer", AppState::Stopping)
971 .unwrap();
972 manager
973 .transition_to("code-reviewer", AppState::Stopped)
974 .unwrap();
975 manager
977 .transition_to("code-reviewer", AppState::Starting)
978 .unwrap();
979 }
980
981 #[test]
982 fn invalid_state_transition() {
983 let manager = AppManager::new();
984 manager.install(sample_manifest()).unwrap();
985
986 let err = manager
988 .transition_to("code-reviewer", AppState::Running)
989 .unwrap_err();
990 assert!(matches!(err, AppError::InvalidState { .. }));
991 }
992
993 #[test]
994 fn state_transition_to_failed() {
995 let manager = AppManager::new();
996 manager.install(sample_manifest()).unwrap();
997
998 manager
999 .transition_to("code-reviewer", AppState::Starting)
1000 .unwrap();
1001 manager
1002 .transition_to("code-reviewer", AppState::Failed("agent crash".into()))
1003 .unwrap();
1004
1005 let app = manager.inspect("code-reviewer").unwrap();
1006 assert_eq!(app.state, AppState::Failed("agent crash".into()));
1007 }
1008
1009 #[test]
1010 fn remove_installed_app() {
1011 let manager = AppManager::new();
1012 manager.install(sample_manifest()).unwrap();
1013 let manifest = manager.remove("code-reviewer").unwrap();
1014 assert_eq!(manifest.name, "code-reviewer");
1015 assert!(manager.is_empty());
1016 }
1017
1018 #[test]
1019 fn remove_running_app_fails() {
1020 let manager = AppManager::new();
1021 manager.install(sample_manifest()).unwrap();
1022 manager
1023 .transition_to("code-reviewer", AppState::Starting)
1024 .unwrap();
1025 manager
1026 .transition_to("code-reviewer", AppState::Running)
1027 .unwrap();
1028
1029 let err = manager.remove("code-reviewer").unwrap_err();
1030 assert!(matches!(err, AppError::InvalidState { .. }));
1031 }
1032
1033 #[test]
1034 fn remove_stopped_app() {
1035 let manager = AppManager::new();
1036 manager.install(sample_manifest()).unwrap();
1037 manager
1038 .transition_to("code-reviewer", AppState::Starting)
1039 .unwrap();
1040 manager
1041 .transition_to("code-reviewer", AppState::Running)
1042 .unwrap();
1043 manager
1044 .transition_to("code-reviewer", AppState::Stopping)
1045 .unwrap();
1046 manager
1047 .transition_to("code-reviewer", AppState::Stopped)
1048 .unwrap();
1049
1050 assert!(manager.remove("code-reviewer").is_ok());
1051 }
1052
1053 #[test]
1054 fn add_agent_pid() {
1055 let manager = AppManager::new();
1056 manager.install(sample_manifest()).unwrap();
1057 manager.add_agent_pid("code-reviewer", 42).unwrap();
1058
1059 let app = manager.inspect("code-reviewer").unwrap();
1060 assert_eq!(app.agent_pids, vec![42]);
1061 }
1062
1063 #[test]
1064 fn add_service_name() {
1065 let manager = AppManager::new();
1066 manager.install(sample_manifest()).unwrap();
1067 manager
1068 .add_service_name("code-reviewer", "review-db".into())
1069 .unwrap();
1070
1071 let app = manager.inspect("code-reviewer").unwrap();
1072 assert_eq!(app.service_names, vec!["review-db"]);
1073 }
1074
1075 #[test]
1076 fn namespaced_ids() {
1077 let manifest = sample_manifest();
1078 let agent_ids = AppManager::namespaced_agent_ids(&manifest);
1079 assert_eq!(
1080 agent_ids,
1081 vec!["code-reviewer/reviewer", "code-reviewer/reporter"]
1082 );
1083
1084 let tool_names = AppManager::namespaced_tool_names(&manifest);
1085 assert_eq!(tool_names, vec!["code-reviewer/diff-analyzer"]);
1086 }
1087
1088 #[test]
1089 fn tool_source_variants() {
1090 let wasm = ToolSource::Wasm("tools/my.wasm".into());
1091 let native = ToolSource::Native("read_file".into());
1092 let skill = ToolSource::Skill("skills/REVIEW.md".into());
1093
1094 for source in &[wasm, native, skill] {
1096 let json = serde_json::to_string(source).unwrap();
1097 let _restored: ToolSource = serde_json::from_str(&json).unwrap();
1098 }
1099 }
1100
1101 #[test]
1102 fn app_error_display() {
1103 let err = AppError::ManifestNotFound {
1104 path: "/tmp/weftapp.toml".into(),
1105 };
1106 assert!(err.to_string().contains("manifest not found"));
1107
1108 let err = AppError::AlreadyInstalled {
1109 name: "my-app".into(),
1110 };
1111 assert!(err.to_string().contains("my-app"));
1112
1113 let err = AppError::HookFailed {
1114 app_name: "my-app".into(),
1115 hook: "on_start".into(),
1116 reason: "exit code 1".into(),
1117 };
1118 assert!(err.to_string().contains("on_start"));
1119 }
1120
1121 #[test]
1122 fn app_capabilities_serde_roundtrip() {
1123 let caps = AppCapabilities {
1124 network: true,
1125 filesystem: vec!["/workspace".into(), "/data".into()],
1126 shell: false,
1127 ipc: IpcScope::All,
1128 };
1129 let json = serde_json::to_string(&caps).unwrap();
1130 let restored: AppCapabilities = serde_json::from_str(&json).unwrap();
1131 assert_eq!(restored, caps);
1132 }
1133
1134 #[test]
1135 fn app_hooks_serde_roundtrip() {
1136 let hooks = AppHooks {
1137 on_install: Some("setup.sh".into()),
1138 on_start: None,
1139 on_stop: Some("cleanup.sh".into()),
1140 on_remove: None,
1141 };
1142 let json = serde_json::to_string(&hooks).unwrap();
1143 let restored: AppHooks = serde_json::from_str(&json).unwrap();
1144 assert_eq!(restored.on_install.as_deref(), Some("setup.sh"));
1145 assert!(restored.on_start.is_none());
1146 }
1147
1148 #[test]
1149 fn parse_manifest_from_json() {
1150 let json = serde_json::json!({
1151 "name": "test-app",
1152 "version": "1.0.0",
1153 "description": "A test app",
1154 "agents": [],
1155 "tools": [],
1156 "services": [],
1157 "capabilities": {
1158 "network": false,
1159 "filesystem": [],
1160 "shell": false,
1161 "ipc": "None"
1162 },
1163 "hooks": {}
1164 });
1165 let manifest = AppManifest::from_json_str(&json.to_string()).unwrap();
1166 assert_eq!(manifest.name, "test-app");
1167 assert_eq!(manifest.version, "1.0.0");
1168 assert!(manifest.agents.is_empty());
1169 }
1170
1171 #[test]
1172 fn parse_manifest_from_json_invalid() {
1173 let result = AppManifest::from_json_str("not valid json");
1174 assert!(result.is_err());
1175 assert!(result.unwrap_err().to_string().contains("JSON parse error"));
1176 }
1177
1178 #[test]
1179 fn parse_manifest_from_json_empty_name_fails() {
1180 let json = serde_json::json!({
1181 "name": "",
1182 "version": "1.0.0"
1183 });
1184 let result = AppManifest::from_json_str(&json.to_string());
1185 assert!(result.is_err());
1186 assert!(result.unwrap_err().to_string().contains("empty"));
1187 }
1188
1189 #[test]
1192 fn integration_app_full_lifecycle() {
1193 let manifest = AppManifest {
1195 name: "data-pipeline".into(),
1196 version: "2.1.0".into(),
1197 description: "Real-time data ingestion and analysis pipeline".into(),
1198 author: Some("WeftOS Team".into()),
1199 license: Some("MIT".into()),
1200 agents: vec![
1201 AgentSpec {
1202 id: "ingester".into(),
1203 role: "data-ingestion".into(),
1204 capabilities: AgentCapabilities::default(),
1205 auto_start: true,
1206 },
1207 AgentSpec {
1208 id: "analyzer".into(),
1209 role: "data-analysis".into(),
1210 capabilities: AgentCapabilities::default(),
1211 auto_start: true,
1212 },
1213 ],
1214 tools: vec![ToolSpec {
1215 name: "transform".into(),
1216 source: ToolSource::Wasm("tools/transform.wasm".into()),
1217 schema: Some(serde_json::json!({
1218 "type": "object",
1219 "properties": {
1220 "input": {"type": "string"},
1221 "format": {"type": "string"}
1222 }
1223 })),
1224 }],
1225 services: vec![ServiceSpec {
1226 name: "cache".into(),
1227 image: Some("redis:7-alpine".into()),
1228 command: None,
1229 ports: vec![PortMapping {
1230 host_port: 6380,
1231 container_port: 6379,
1232 protocol: "tcp".into(),
1233 }],
1234 env: HashMap::from([("REDIS_MAX_MEMORY".into(), "256mb".into())]),
1235 health_endpoint: Some("redis://localhost:6380".into()),
1236 }],
1237 capabilities: AppCapabilities {
1238 network: true,
1239 filesystem: vec!["/data".into(), "/workspace".into()],
1240 shell: false,
1241 ipc: IpcScope::All,
1242 },
1243 hooks: AppHooks {
1244 on_install: Some("scripts/setup.sh".into()),
1245 on_start: Some("scripts/migrate.sh".into()),
1246 on_stop: Some("scripts/cleanup.sh".into()),
1247 on_remove: None,
1248 },
1249 };
1250
1251 validate_manifest(&manifest).unwrap();
1253
1254 let agent_ids = AppManager::namespaced_agent_ids(&manifest);
1256 assert_eq!(
1257 agent_ids,
1258 vec!["data-pipeline/ingester", "data-pipeline/analyzer"]
1259 );
1260 let tool_names = AppManager::namespaced_tool_names(&manifest);
1261 assert_eq!(tool_names, vec!["data-pipeline/transform"]);
1262
1263 let manager = AppManager::new();
1265 let app_name = manager.install(manifest.clone()).unwrap();
1266 assert_eq!(app_name, "data-pipeline");
1267
1268 let list = manager.list();
1270 assert_eq!(list.len(), 1);
1271 assert_eq!(list[0].0, "data-pipeline");
1272
1273 let inspected = manager.inspect("data-pipeline").unwrap();
1275 assert_eq!(inspected.manifest.agents.len(), 2);
1276 assert_eq!(inspected.manifest.services.len(), 1);
1277 assert_eq!(inspected.manifest.tools.len(), 1);
1278 assert_eq!(inspected.manifest.services[0].name, "cache");
1279 assert_eq!(
1280 inspected.manifest.services[0].image,
1281 Some("redis:7-alpine".into())
1282 );
1283 assert!(inspected.manifest.capabilities.network);
1284 assert_eq!(
1285 inspected.manifest.capabilities.filesystem,
1286 vec!["/data", "/workspace"]
1287 );
1288 assert!(!inspected.manifest.capabilities.shell);
1289 assert_eq!(inspected.manifest.capabilities.ipc, IpcScope::All);
1290 assert_eq!(
1291 inspected.manifest.hooks.on_install,
1292 Some("scripts/setup.sh".into())
1293 );
1294 assert_eq!(
1295 inspected.manifest.hooks.on_start,
1296 Some("scripts/migrate.sh".into())
1297 );
1298 assert_eq!(
1299 inspected.manifest.hooks.on_stop,
1300 Some("scripts/cleanup.sh".into())
1301 );
1302 assert!(inspected.manifest.hooks.on_remove.is_none());
1303 assert_eq!(inspected.manifest.author, Some("WeftOS Team".into()));
1304 assert_eq!(inspected.manifest.license, Some("MIT".into()));
1305
1306 manager
1308 .transition_to("data-pipeline", AppState::Starting)
1309 .unwrap();
1310 manager
1311 .transition_to("data-pipeline", AppState::Running)
1312 .unwrap();
1313
1314 manager.add_agent_pid("data-pipeline", 10).unwrap(); manager.add_agent_pid("data-pipeline", 11).unwrap(); manager
1320 .add_service_name("data-pipeline", "cache".into())
1321 .unwrap();
1322
1323 let running = manager.inspect("data-pipeline").unwrap();
1325 assert!(matches!(running.state, AppState::Running));
1326 assert_eq!(running.agent_pids.len(), 2);
1327 assert!(running.agent_pids.contains(&10));
1328 assert!(running.agent_pids.contains(&11));
1329 assert_eq!(running.service_names.len(), 1);
1330 assert!(running.service_names.contains(&"cache".to_string()));
1331
1332 manager
1334 .transition_to("data-pipeline", AppState::Stopping)
1335 .unwrap();
1336 manager
1337 .transition_to("data-pipeline", AppState::Stopped)
1338 .unwrap();
1339
1340 let stopped = manager.inspect("data-pipeline").unwrap();
1341 assert!(matches!(stopped.state, AppState::Stopped));
1342
1343 let removed = manager.remove("data-pipeline").unwrap();
1345 assert_eq!(removed.name, "data-pipeline");
1346 assert!(manager.is_empty());
1347 }
1348
1349 #[test]
1350 fn integration_multi_app_isolation() {
1351 let manager = AppManager::new();
1352
1353 let app1 = AppManifest {
1355 name: "frontend".into(),
1356 version: "1.0.0".into(),
1357 description: "Web frontend".into(),
1358 author: None,
1359 license: None,
1360 agents: vec![AgentSpec {
1361 id: "worker".into(),
1362 role: "serve".into(),
1363 capabilities: AgentCapabilities::default(),
1364 auto_start: true,
1365 }],
1366 tools: vec![ToolSpec {
1367 name: "render".into(),
1368 source: ToolSource::Native("fs.read_file".into()),
1369 schema: None,
1370 }],
1371 services: vec![],
1372 capabilities: AppCapabilities {
1373 network: true,
1374 filesystem: vec![],
1375 shell: false,
1376 ipc: IpcScope::None,
1377 },
1378 hooks: AppHooks::default(),
1379 };
1380
1381 let app2 = AppManifest {
1382 name: "backend".into(),
1383 version: "2.0.0".into(),
1384 description: "API backend".into(),
1385 author: None,
1386 license: None,
1387 agents: vec![AgentSpec {
1388 id: "worker".into(), role: "api".into(),
1390 capabilities: AgentCapabilities::default(),
1391 auto_start: true,
1392 }],
1393 tools: vec![ToolSpec {
1394 name: "render".into(), source: ToolSource::Wasm("tools/render.wasm".into()),
1396 schema: None,
1397 }],
1398 services: vec![ServiceSpec {
1399 name: "db".into(),
1400 image: Some("postgres:16-alpine".into()),
1401 command: None,
1402 ports: vec![PortMapping {
1403 host_port: 5432,
1404 container_port: 5432,
1405 protocol: "tcp".into(),
1406 }],
1407 env: HashMap::from([("POSTGRES_PASSWORD".into(), "dev".into())]),
1408 health_endpoint: None,
1409 }],
1410 capabilities: AppCapabilities {
1411 network: true,
1412 filesystem: vec!["/data".into()],
1413 shell: false,
1414 ipc: IpcScope::All,
1415 },
1416 hooks: AppHooks::default(),
1417 };
1418
1419 manager.install(app1).unwrap();
1420 manager.install(app2).unwrap();
1421
1422 assert_eq!(manager.list().len(), 2);
1424
1425 let fe_agents =
1427 AppManager::namespaced_agent_ids(&manager.inspect("frontend").unwrap().manifest);
1428 let be_agents =
1429 AppManager::namespaced_agent_ids(&manager.inspect("backend").unwrap().manifest);
1430 assert_eq!(fe_agents, vec!["frontend/worker"]);
1431 assert_eq!(be_agents, vec!["backend/worker"]);
1432
1433 let fe_tools =
1434 AppManager::namespaced_tool_names(&manager.inspect("frontend").unwrap().manifest);
1435 let be_tools =
1436 AppManager::namespaced_tool_names(&manager.inspect("backend").unwrap().manifest);
1437 assert_eq!(fe_tools, vec!["frontend/render"]);
1438 assert_eq!(be_tools, vec!["backend/render"]);
1439
1440 manager
1442 .transition_to("frontend", AppState::Starting)
1443 .unwrap();
1444 manager
1445 .transition_to("frontend", AppState::Running)
1446 .unwrap();
1447 let fe = manager.inspect("frontend").unwrap();
1450 let be = manager.inspect("backend").unwrap();
1451 assert!(matches!(fe.state, AppState::Running));
1452 assert!(matches!(be.state, AppState::Installed));
1453
1454 manager
1456 .transition_to("backend", AppState::Starting)
1457 .unwrap();
1458 manager
1459 .transition_to("backend", AppState::Running)
1460 .unwrap();
1461
1462 manager.add_agent_pid("frontend", 100).unwrap();
1464 manager.add_agent_pid("backend", 200).unwrap();
1465
1466 let fe = manager.inspect("frontend").unwrap();
1467 let be = manager.inspect("backend").unwrap();
1468 assert_eq!(fe.agent_pids, vec![100]);
1469 assert_eq!(be.agent_pids, vec![200]);
1470
1471 manager
1473 .transition_to("frontend", AppState::Stopping)
1474 .unwrap();
1475 manager
1476 .transition_to("frontend", AppState::Stopped)
1477 .unwrap();
1478
1479 let fe = manager.inspect("frontend").unwrap();
1480 let be = manager.inspect("backend").unwrap();
1481 assert!(matches!(fe.state, AppState::Stopped));
1482 assert!(matches!(be.state, AppState::Running));
1483 }
1484
1485 #[test]
1486 fn app_hooks_lifecycle() {
1487 let manifest = AppManifest {
1488 name: "hooks-test".into(),
1489 version: "0.1.0".into(),
1490 description: String::new(),
1491 author: None,
1492 license: None,
1493 agents: Vec::new(),
1494 tools: Vec::new(),
1495 services: Vec::new(),
1496 capabilities: AppCapabilities::default(),
1497 hooks: AppHooks {
1498 on_install: Some("scripts/setup.sh".into()),
1499 on_start: Some("scripts/migrate.sh".into()),
1500 on_stop: Some("scripts/cleanup.sh".into()),
1501 on_remove: None,
1502 },
1503 };
1504 assert_eq!(manifest.hooks.on_install, Some("scripts/setup.sh".into()));
1505 assert_eq!(manifest.hooks.on_start, Some("scripts/migrate.sh".into()));
1506 assert_eq!(manifest.hooks.on_stop, Some("scripts/cleanup.sh".into()));
1507 assert!(manifest.hooks.on_remove.is_none());
1508 }
1509
1510 #[test]
1513 fn k5_manifest_parsed_and_validated() {
1514 let manifest = AppManifest {
1516 name: "test-app".into(),
1517 version: "1.0.0".into(),
1518 description: "A test application".into(),
1519 author: None,
1520 license: None,
1521 agents: vec![AgentSpec {
1522 id: "worker".into(),
1523 role: "coder".into(),
1524 capabilities: AgentCapabilities::default(),
1525 auto_start: true,
1526 }],
1527 tools: Vec::new(),
1528 services: vec![ServiceSpec {
1529 name: "api".into(),
1530 image: None,
1531 command: Some("serve".into()),
1532 ports: vec![PortMapping {
1533 host_port: 8080,
1534 container_port: 8080,
1535 protocol: "tcp".into(),
1536 }],
1537 env: HashMap::new(),
1538 health_endpoint: None,
1539 }],
1540 capabilities: AppCapabilities::default(),
1541 hooks: AppHooks::default(),
1542 };
1543 assert!(validate_manifest(&manifest).is_ok());
1544 assert!(!manifest.name.is_empty());
1545 assert!(!manifest.version.is_empty());
1546
1547 let json = serde_json::to_string(&manifest).unwrap();
1549 let parsed = AppManifest::from_json_str(&json).unwrap();
1550 assert_eq!(parsed.name, "test-app");
1551 assert_eq!(parsed.agents.len(), 1);
1552 assert_eq!(parsed.services.len(), 1);
1553 }
1554
1555 #[test]
1556 fn k5_app_install_start_stop_lifecycle() {
1557 let mgr = AppManager::new();
1558 let manifest = AppManifest {
1559 name: "lifecycle-app".into(),
1560 version: "2.0.0".into(),
1561 description: "Lifecycle test".into(),
1562 author: None,
1563 license: None,
1564 agents: vec![
1565 AgentSpec {
1566 id: "alpha".into(),
1567 role: "coder".into(),
1568 capabilities: AgentCapabilities::default(),
1569 auto_start: true,
1570 },
1571 AgentSpec {
1572 id: "beta".into(),
1573 role: "reviewer".into(),
1574 capabilities: AgentCapabilities {
1575 can_network: true,
1576 ..Default::default()
1577 },
1578 auto_start: false,
1579 },
1580 ],
1581 tools: Vec::new(),
1582 services: Vec::new(),
1583 capabilities: AppCapabilities::default(),
1584 hooks: AppHooks::default(),
1585 };
1586
1587 let app_id = mgr.install(manifest).unwrap();
1589 assert_eq!(app_id, "lifecycle-app");
1590 let app = mgr.inspect(&app_id).unwrap();
1591 assert_eq!(app.state, AppState::Installed);
1592
1593 let spawn_reqs = mgr.start(&app_id).unwrap();
1595 assert_eq!(spawn_reqs.len(), 2);
1596 assert_eq!(spawn_reqs[0].agent_id, "lifecycle-app/alpha");
1597 assert_eq!(spawn_reqs[1].agent_id, "lifecycle-app/beta");
1598
1599 let app = mgr.inspect(&app_id).unwrap();
1600 assert_eq!(app.state, AppState::Running);
1601
1602 mgr.stop(&app_id).unwrap();
1604 let app = mgr.inspect(&app_id).unwrap();
1605 assert_eq!(app.state, AppState::Stopped);
1606
1607 let spawn_reqs = mgr.start(&app_id).unwrap();
1609 assert_eq!(spawn_reqs.len(), 2);
1610 let app = mgr.inspect(&app_id).unwrap();
1611 assert_eq!(app.state, AppState::Running);
1612 }
1613
1614 #[test]
1615 fn k5_app_agents_spawn_with_correct_capabilities() {
1616 let manifest = AppManifest {
1617 name: "cap-app".into(),
1618 version: "1.0.0".into(),
1619 description: String::new(),
1620 author: None,
1621 license: None,
1622 agents: vec![
1623 AgentSpec {
1624 id: "networker".into(),
1625 role: "fetcher".into(),
1626 capabilities: AgentCapabilities {
1627 can_network: true,
1628 can_spawn: false,
1629 can_ipc: true,
1630 can_exec_tools: true,
1631 ipc_scope: IpcScope::All,
1632 ..Default::default()
1633 },
1634 auto_start: true,
1635 },
1636 AgentSpec {
1637 id: "sandboxed".into(),
1638 role: "compute".into(),
1639 capabilities: AgentCapabilities {
1640 can_network: false,
1641 can_spawn: false,
1642 can_ipc: false,
1643 can_exec_tools: false,
1644 ipc_scope: IpcScope::None,
1645 ..Default::default()
1646 },
1647 auto_start: true,
1648 },
1649 ],
1650 tools: Vec::new(),
1651 services: Vec::new(),
1652 capabilities: AppCapabilities::default(),
1653 hooks: AppHooks::default(),
1654 };
1655
1656 let spawn_reqs = AppManager::build_spawn_requests(&manifest);
1657 assert_eq!(spawn_reqs.len(), 2);
1658
1659 assert_eq!(spawn_reqs[0].agent_id, "cap-app/networker");
1661 let caps0 = spawn_reqs[0].capabilities.as_ref().unwrap();
1662 assert!(caps0.can_network);
1663 assert!(!caps0.can_spawn);
1664 assert!(caps0.can_ipc);
1665 assert!(caps0.can_exec_tools);
1666
1667 assert_eq!(spawn_reqs[1].agent_id, "cap-app/sandboxed");
1669 let caps1 = spawn_reqs[1].capabilities.as_ref().unwrap();
1670 assert!(!caps1.can_network);
1671 assert!(!caps1.can_spawn);
1672 assert!(!caps1.can_ipc);
1673 assert!(!caps1.can_exec_tools);
1674 assert_eq!(caps1.ipc_scope, IpcScope::None);
1675 }
1676
1677 #[test]
1678 fn k5_app_list_shows_installed() {
1679 let mgr = AppManager::new();
1680
1681 let app1 = AppManifest {
1682 name: "app-one".into(),
1683 version: "1.0.0".into(),
1684 description: String::new(),
1685 author: None,
1686 license: None,
1687 agents: Vec::new(),
1688 tools: Vec::new(),
1689 services: Vec::new(),
1690 capabilities: AppCapabilities::default(),
1691 hooks: AppHooks::default(),
1692 };
1693 let app2 = AppManifest {
1694 name: "app-two".into(),
1695 version: "2.0.0".into(),
1696 description: String::new(),
1697 author: None,
1698 license: None,
1699 agents: Vec::new(),
1700 tools: Vec::new(),
1701 services: Vec::new(),
1702 capabilities: AppCapabilities::default(),
1703 hooks: AppHooks::default(),
1704 };
1705
1706 mgr.install(app1).unwrap();
1707 mgr.install(app2).unwrap();
1708
1709 let list = mgr.list();
1710 assert_eq!(list.len(), 2);
1711 let names: Vec<&str> = list.iter().map(|(n, _, _)| n.as_str()).collect();
1712 assert!(names.contains(&"app-one"));
1713 assert!(names.contains(&"app-two"));
1714 }
1715
1716 #[test]
1717 fn k5_invalid_manifest_rejected() {
1718 let mgr = AppManager::new();
1719
1720 let bad = AppManifest {
1722 name: String::new(),
1723 version: "1.0.0".into(),
1724 description: String::new(),
1725 author: None,
1726 license: None,
1727 agents: Vec::new(),
1728 tools: Vec::new(),
1729 services: Vec::new(),
1730 capabilities: AppCapabilities::default(),
1731 hooks: AppHooks::default(),
1732 };
1733 assert!(mgr.install(bad).is_err());
1734
1735 let bad = AppManifest {
1737 name: "ok-name".into(),
1738 version: String::new(),
1739 description: String::new(),
1740 author: None,
1741 license: None,
1742 agents: Vec::new(),
1743 tools: Vec::new(),
1744 services: Vec::new(),
1745 capabilities: AppCapabilities::default(),
1746 hooks: AppHooks::default(),
1747 };
1748 assert!(mgr.install(bad).is_err());
1749
1750 let bad = AppManifest {
1752 name: "bad name!".into(),
1753 version: "1.0.0".into(),
1754 description: String::new(),
1755 author: None,
1756 license: None,
1757 agents: Vec::new(),
1758 tools: Vec::new(),
1759 services: Vec::new(),
1760 capabilities: AppCapabilities::default(),
1761 hooks: AppHooks::default(),
1762 };
1763 assert!(mgr.install(bad).is_err());
1764
1765 assert!(mgr.is_empty());
1767 }
1768
1769 #[test]
1770 fn k5_start_wrong_state_fails() {
1771 let mgr = AppManager::new();
1772 let manifest = AppManifest {
1773 name: "state-test".into(),
1774 version: "1.0.0".into(),
1775 description: String::new(),
1776 author: None,
1777 license: None,
1778 agents: Vec::new(),
1779 tools: Vec::new(),
1780 services: Vec::new(),
1781 capabilities: AppCapabilities::default(),
1782 hooks: AppHooks::default(),
1783 };
1784 mgr.install(manifest).unwrap();
1785 mgr.start("state-test").unwrap();
1786
1787 let err = mgr.start("state-test").unwrap_err();
1789 assert!(matches!(err, AppError::InvalidState { .. }));
1790 }
1791
1792 #[test]
1793 fn k5_stop_wrong_state_fails() {
1794 let mgr = AppManager::new();
1795 let manifest = AppManifest {
1796 name: "stop-test".into(),
1797 version: "1.0.0".into(),
1798 description: String::new(),
1799 author: None,
1800 license: None,
1801 agents: Vec::new(),
1802 tools: Vec::new(),
1803 services: Vec::new(),
1804 capabilities: AppCapabilities::default(),
1805 hooks: AppHooks::default(),
1806 };
1807 mgr.install(manifest).unwrap();
1808
1809 let err = mgr.stop("stop-test").unwrap_err();
1811 assert!(matches!(err, AppError::InvalidState { .. }));
1812 }
1813
1814 #[test]
1815 fn k5_stop_clears_agent_pids() {
1816 let mgr = AppManager::new();
1817 let manifest = AppManifest {
1818 name: "pid-test".into(),
1819 version: "1.0.0".into(),
1820 description: String::new(),
1821 author: None,
1822 license: None,
1823 agents: vec![AgentSpec {
1824 id: "w".into(),
1825 role: "worker".into(),
1826 capabilities: AgentCapabilities::default(),
1827 auto_start: true,
1828 }],
1829 tools: Vec::new(),
1830 services: Vec::new(),
1831 capabilities: AppCapabilities::default(),
1832 hooks: AppHooks::default(),
1833 };
1834 mgr.install(manifest).unwrap();
1835 mgr.start("pid-test").unwrap();
1836
1837 mgr.add_agent_pid("pid-test", 42).unwrap();
1839 mgr.add_service_name("pid-test", "svc".into()).unwrap();
1840 let app = mgr.inspect("pid-test").unwrap();
1841 assert_eq!(app.agent_pids.len(), 1);
1842 assert_eq!(app.service_names.len(), 1);
1843
1844 mgr.stop("pid-test").unwrap();
1846 let app = mgr.inspect("pid-test").unwrap();
1847 assert!(app.agent_pids.is_empty());
1848 assert!(app.service_names.is_empty());
1849 }
1850}