1use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::Mutex;
21
22use chrono::{DateTime, Utc};
23use dashmap::DashMap;
24use serde::{Deserialize, Serialize};
25use tracing::{debug, info, warn};
26
27use crate::capability::{AgentCapabilities, IpcScope};
28use crate::container::PortMapping;
29#[cfg(feature = "exochain")]
30use crate::gate::GateBackend;
31use crate::process::Pid;
32use crate::supervisor::SpawnRequest;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AppManifest {
42 pub name: String,
44
45 pub version: String,
47
48 #[serde(default)]
50 pub description: String,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub author: Option<String>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub license: Option<String>,
59
60 #[serde(default)]
62 pub agents: Vec<AgentSpec>,
63
64 #[serde(default)]
66 pub tools: Vec<ToolSpec>,
67
68 #[serde(default)]
70 pub services: Vec<ServiceSpec>,
71
72 #[serde(default)]
74 pub capabilities: AppCapabilities,
75
76 #[serde(default)]
78 pub hooks: AppHooks,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AgentSpec {
84 pub id: String,
86
87 #[serde(default)]
89 pub role: String,
90
91 #[serde(default)]
93 pub capabilities: AgentCapabilities,
94
95 #[serde(default)]
97 pub auto_start: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ToolSpec {
103 pub name: String,
105
106 pub source: ToolSource,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub schema: Option<serde_json::Value>,
112}
113
114#[non_exhaustive]
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub enum ToolSource {
118 Wasm(String),
120 Native(String),
122 Skill(String),
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ServiceSpec {
129 pub name: String,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub image: Option<String>,
135
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub command: Option<String>,
139
140 #[serde(default)]
142 pub ports: Vec<PortMapping>,
143
144 #[serde(default)]
146 pub env: HashMap<String, String>,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub health_endpoint: Option<String>,
151}
152
153#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
155pub struct AppCapabilities {
156 #[serde(default)]
158 pub network: bool,
159
160 #[serde(default)]
162 pub filesystem: Vec<String>,
163
164 #[serde(default)]
166 pub shell: bool,
167
168 #[serde(default)]
170 pub ipc: IpcScope,
171}
172
173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct AppHooks {
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub on_install: Option<String>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub on_start: Option<String>,
183
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub on_stop: Option<String>,
187
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub on_remove: Option<String>,
191}
192
193#[non_exhaustive]
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub enum AppState {
199 Installed,
201 Starting,
203 Running,
205 Stopping,
207 Stopped,
209 Failed(String),
211}
212
213impl std::fmt::Display for AppState {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 match self {
216 AppState::Installed => write!(f, "installed"),
217 AppState::Starting => write!(f, "starting"),
218 AppState::Running => write!(f, "running"),
219 AppState::Stopping => write!(f, "stopping"),
220 AppState::Stopped => write!(f, "stopped"),
221 AppState::Failed(reason) => write!(f, "failed: {reason}"),
222 }
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct InstalledApp {
229 pub manifest: AppManifest,
231
232 pub state: AppState,
234
235 pub installed_at: DateTime<Utc>,
237
238 #[serde(default)]
240 pub agent_pids: Vec<Pid>,
241
242 #[serde(default)]
244 pub service_names: Vec<String>,
245}
246
247#[non_exhaustive]
251#[derive(Debug, thiserror::Error)]
252pub enum AppError {
253 #[error("manifest not found at '{path}'")]
255 ManifestNotFound {
256 path: String,
258 },
259
260 #[error("invalid manifest: {reason}")]
262 ManifestInvalid {
263 reason: String,
265 },
266
267 #[error("app already installed: '{name}'")]
269 AlreadyInstalled {
270 name: String,
272 },
273
274 #[error("app not found: '{name}'")]
276 NotFound {
277 name: String,
279 },
280
281 #[error("invalid state for app '{name}': expected {expected}, got {actual}")]
283 InvalidState {
284 name: String,
286 expected: String,
288 actual: String,
290 },
291
292 #[error("failed to spawn agent '{agent_id}' for app '{app_name}': {reason}")]
294 SpawnFailed {
295 app_name: String,
297 agent_id: String,
299 reason: String,
301 },
302
303 #[error("governance denied app operation '{action}': {reason}")]
305 GovernanceDenied {
306 action: String,
308 reason: String,
310 },
311
312 #[error("hook '{hook}' failed for app '{app_name}': {reason}")]
314 HookFailed {
315 app_name: String,
317 hook: String,
319 reason: String,
321 },
322}
323
324pub fn validate_manifest(manifest: &AppManifest) -> Result<(), AppError> {
336 if manifest.name.is_empty() {
338 return Err(AppError::ManifestInvalid {
339 reason: "app name must not be empty".into(),
340 });
341 }
342
343 if !manifest
344 .name
345 .chars()
346 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
347 {
348 return Err(AppError::ManifestInvalid {
349 reason: format!(
350 "app name '{}' contains invalid characters (use alphanumeric, - or _)",
351 manifest.name
352 ),
353 });
354 }
355
356 if manifest.version.is_empty() {
358 return Err(AppError::ManifestInvalid {
359 reason: "version must not be empty".into(),
360 });
361 }
362
363 let mut agent_ids = std::collections::HashSet::new();
365 for agent in &manifest.agents {
366 if agent.id.is_empty() {
367 return Err(AppError::ManifestInvalid {
368 reason: "agent id must not be empty".into(),
369 });
370 }
371 if !agent_ids.insert(&agent.id) {
372 return Err(AppError::ManifestInvalid {
373 reason: format!("duplicate agent id: '{}'", agent.id),
374 });
375 }
376 }
377
378 let mut tool_names = std::collections::HashSet::new();
380 for tool in &manifest.tools {
381 if tool.name.is_empty() {
382 return Err(AppError::ManifestInvalid {
383 reason: "tool name must not be empty".into(),
384 });
385 }
386 if !tool_names.insert(&tool.name) {
387 return Err(AppError::ManifestInvalid {
388 reason: format!("duplicate tool name: '{}'", tool.name),
389 });
390 }
391 }
392
393 let mut service_names = std::collections::HashSet::new();
395 for service in &manifest.services {
396 if service.name.is_empty() {
397 return Err(AppError::ManifestInvalid {
398 reason: "service name must not be empty".into(),
399 });
400 }
401 if !service_names.insert(&service.name) {
402 return Err(AppError::ManifestInvalid {
403 reason: format!("duplicate service name: '{}'", service.name),
404 });
405 }
406 }
407
408 Ok(())
409}
410
411fn default_apps_file_version() -> u32 {
416 1
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, Default)]
425pub struct AppsFile {
426 #[serde(default = "default_apps_file_version")]
428 pub version: u32,
429 #[serde(default)]
431 pub apps: Vec<InstalledApp>,
432}
433
434pub struct AppManager {
447 apps: DashMap<String, InstalledApp>,
448 persist_path: Mutex<Option<PathBuf>>,
455 #[cfg(feature = "exochain")]
456 chain_manager: Option<std::sync::Arc<crate::chain::ChainManager>>,
457 #[cfg(feature = "exochain")]
458 governance_gate: Option<std::sync::Arc<crate::gate::GovernanceGate>>,
459}
460
461impl AppManager {
462 pub fn new() -> Self {
464 Self {
465 apps: DashMap::new(),
466 persist_path: Mutex::new(None),
467 #[cfg(feature = "exochain")]
468 chain_manager: None,
469 #[cfg(feature = "exochain")]
470 governance_gate: None,
471 }
472 }
473
474 pub fn with_persist_path(self, path: impl AsRef<Path>) -> Self {
486 let path = path.as_ref().to_path_buf();
487 if path.exists() {
488 match std::fs::read_to_string(&path) {
489 Ok(data) => match serde_json::from_str::<AppsFile>(&data) {
490 Ok(file) => {
491 for mut app in file.apps {
492 app.agent_pids.clear();
494 app.service_names.clear();
495 app.state = match app.state {
497 AppState::Starting
498 | AppState::Running
499 | AppState::Stopping => AppState::Stopped,
500 other => other,
501 };
502 let name = app.manifest.name.clone();
503 self.apps.insert(name, app);
504 }
505 info!(
506 count = self.apps.len(),
507 path = %path.display(),
508 "rehydrated installed apps from disk"
509 );
510 }
511 Err(e) => {
512 warn!(
513 error = %e,
514 path = %path.display(),
515 "failed to parse apps file; starting empty"
516 );
517 }
518 },
519 Err(e) => {
520 warn!(
521 error = %e,
522 path = %path.display(),
523 "failed to read apps file; starting empty"
524 );
525 }
526 }
527 }
528 *self
529 .persist_path
530 .lock()
531 .expect("persist_path lock poisoned") = Some(path);
532 self
533 }
534
535 fn persist(&self) {
541 let path_opt = self
542 .persist_path
543 .lock()
544 .expect("persist_path lock poisoned")
545 .clone();
546 let Some(path) = path_opt else {
547 return;
548 };
549
550 let apps: Vec<InstalledApp> = self.apps.iter().map(|e| e.value().clone()).collect();
551 let file = AppsFile {
552 version: default_apps_file_version(),
553 apps,
554 };
555 let json = match serde_json::to_string_pretty(&file) {
556 Ok(j) => j,
557 Err(e) => {
558 warn!(error = %e, "failed to serialise installed apps");
559 return;
560 }
561 };
562
563 if let Some(parent) = path.parent()
564 && let Err(e) = std::fs::create_dir_all(parent)
565 {
566 warn!(error = %e, path = %parent.display(), "failed to create apps persist parent");
567 return;
568 }
569
570 let tmp = path.with_extension("json.tmp");
571 if let Err(e) = std::fs::write(&tmp, &json) {
572 warn!(error = %e, path = %tmp.display(), "failed to write apps tmp");
573 return;
574 }
575 if let Err(e) = std::fs::rename(&tmp, &path) {
576 warn!(error = %e, path = %path.display(), "failed to rename apps file");
577 let _ = std::fs::remove_file(&tmp);
578 }
579 }
580
581 #[cfg(feature = "exochain")]
583 pub fn set_chain_manager(
584 &mut self,
585 chain_manager: Option<std::sync::Arc<crate::chain::ChainManager>>,
586 ) {
587 self.chain_manager = chain_manager;
588 }
589
590 #[cfg(feature = "exochain")]
592 pub fn set_governance_gate(
593 &mut self,
594 gate: Option<std::sync::Arc<crate::gate::GovernanceGate>>,
595 ) {
596 self.governance_gate = gate;
597 }
598
599 pub fn install(&self, manifest: AppManifest) -> Result<String, AppError> {
609 validate_manifest(&manifest)?;
610
611 let name = manifest.name.clone();
612
613 if self.apps.contains_key(&name) {
614 return Err(AppError::AlreadyInstalled { name: name.clone() });
615 }
616
617 #[cfg(feature = "exochain")]
619 if let Some(ref gate) = self.governance_gate {
620 let context = serde_json::json!({
621 "app_name": &name,
622 "version": &manifest.version,
623 "effect": { "risk": 0.3, "security": 0.3 },
624 });
625 let decision = gate.check("kernel", "app.install", &context);
626 if decision.is_deny() {
627 return Err(AppError::GovernanceDenied {
628 action: "app.install".into(),
629 reason: format!("governance denied installing app '{name}'"),
630 });
631 }
632 }
633
634 debug!(app = %name, version = %manifest.version, "installing application");
635
636 self.apps.insert(
637 name.clone(),
638 InstalledApp {
639 manifest: manifest.clone(),
640 state: AppState::Installed,
641 installed_at: Utc::now(),
642 agent_pids: Vec::new(),
643 service_names: Vec::new(),
644 },
645 );
646
647 #[cfg(feature = "exochain")]
649 if let Some(ref cm) = self.chain_manager {
650 cm.append(
651 "app",
652 crate::chain::EVENT_KIND_APP_INSTALL,
653 Some(serde_json::json!({
654 "app_name": &name,
655 "version": &manifest.version,
656 "agents": manifest.agents.len(),
657 "tools": manifest.tools.len(),
658 "services": manifest.services.len(),
659 })),
660 );
661 }
662
663 self.persist();
664 Ok(name)
665 }
666
667 pub fn transition_to(&self, name: &str, new_state: AppState) -> Result<(), AppError> {
680 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
681 name: name.to_owned(),
682 })?;
683
684 let valid = matches!(
685 (&entry.state, &new_state),
686 (AppState::Installed, AppState::Starting)
687 | (AppState::Starting, AppState::Running)
688 | (AppState::Starting, AppState::Failed(_))
689 | (AppState::Running, AppState::Stopping)
690 | (AppState::Stopping, AppState::Stopped)
691 | (AppState::Stopping, AppState::Failed(_))
692 | (AppState::Stopped, AppState::Starting)
693 );
694
695 if !valid {
696 return Err(AppError::InvalidState {
697 name: name.to_owned(),
698 expected: format!("valid transition from {}", entry.state),
699 actual: format!("{} -> {new_state}", entry.state),
700 });
701 }
702
703 let from_state = entry.state.to_string();
704 let to_state = new_state.to_string();
705 debug!(app = name, from = %from_state, to = %to_state, "state transition");
706 entry.state = new_state;
707 drop(entry); #[cfg(feature = "exochain")]
711 if let Some(ref cm) = self.chain_manager {
712 cm.append(
713 "app",
714 crate::chain::EVENT_KIND_APP_TRANSITION,
715 Some(serde_json::json!({
716 "app_name": name,
717 "from": from_state,
718 "to": to_state,
719 })),
720 );
721 }
722
723 self.persist();
724 Ok(())
725 }
726
727 pub fn add_agent_pid(&self, name: &str, pid: Pid) -> Result<(), AppError> {
729 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
730 name: name.to_owned(),
731 })?;
732 entry.agent_pids.push(pid);
733 Ok(())
734 }
735
736 pub fn add_service_name(&self, name: &str, service_name: String) -> Result<(), AppError> {
738 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
739 name: name.to_owned(),
740 })?;
741 entry.service_names.push(service_name);
742 Ok(())
743 }
744
745 pub fn remove(&self, name: &str) -> Result<AppManifest, AppError> {
749 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
750 name: name.to_owned(),
751 })?;
752
753 let removable = matches!(
754 entry.state,
755 AppState::Installed | AppState::Stopped | AppState::Failed(_)
756 );
757
758 if !removable {
759 return Err(AppError::InvalidState {
760 name: name.to_owned(),
761 expected: "Installed, Stopped, or Failed".into(),
762 actual: entry.state.to_string(),
763 });
764 }
765
766 #[cfg(feature = "exochain")]
768 if let Some(ref gate) = self.governance_gate {
769 let context = serde_json::json!({
770 "app_name": name,
771 "state": entry.state.to_string(),
772 "effect": { "risk": 0.4, "security": 0.2 },
773 });
774 let decision = gate.check("kernel", "app.remove", &context);
775 if decision.is_deny() {
776 return Err(AppError::GovernanceDenied {
777 action: "app.remove".into(),
778 reason: format!("governance denied removing app '{name}'"),
779 });
780 }
781 }
782
783 drop(entry); let (_, app) = self.apps.remove(name).ok_or_else(|| AppError::NotFound {
785 name: name.to_owned(),
786 })?;
787
788 debug!(app = name, "removed application");
789
790 #[cfg(feature = "exochain")]
792 if let Some(ref cm) = self.chain_manager {
793 cm.append(
794 "app",
795 crate::chain::EVENT_KIND_APP_REMOVE,
796 Some(serde_json::json!({
797 "app_name": name,
798 "version": &app.manifest.version,
799 })),
800 );
801 }
802
803 self.persist();
804 Ok(app.manifest)
805 }
806
807 pub fn list(&self) -> Vec<(String, AppState, String)> {
809 self.apps
810 .iter()
811 .map(|entry| {
812 (
813 entry.key().clone(),
814 entry.state.clone(),
815 entry.manifest.version.clone(),
816 )
817 })
818 .collect()
819 }
820
821 pub fn inspect(&self, name: &str) -> Result<InstalledApp, AppError> {
823 self.apps
824 .get(name)
825 .map(|e| e.value().clone())
826 .ok_or_else(|| AppError::NotFound {
827 name: name.to_owned(),
828 })
829 }
830
831 pub fn len(&self) -> usize {
833 self.apps.len()
834 }
835
836 pub fn is_empty(&self) -> bool {
838 self.apps.is_empty()
839 }
840
841 pub fn namespaced_agent_ids(manifest: &AppManifest) -> Vec<String> {
845 manifest
846 .agents
847 .iter()
848 .map(|a| format!("{}/{}", manifest.name, a.id))
849 .collect()
850 }
851
852 pub fn namespaced_tool_names(manifest: &AppManifest) -> Vec<String> {
856 manifest
857 .tools
858 .iter()
859 .map(|t| format!("{}/{}", manifest.name, t.name))
860 .collect()
861 }
862
863 pub fn start(&self, name: &str) -> Result<Vec<SpawnRequest>, AppError> {
879 {
881 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
882 name: name.to_owned(),
883 })?;
884 let startable = matches!(entry.state, AppState::Installed | AppState::Stopped);
885 if !startable {
886 return Err(AppError::InvalidState {
887 name: name.to_owned(),
888 expected: "Installed or Stopped".into(),
889 actual: entry.state.to_string(),
890 });
891 }
892 }
893
894 self.transition_to(name, AppState::Starting)?;
896
897 let spawn_requests = {
899 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
900 name: name.to_owned(),
901 })?;
902 Self::build_spawn_requests(&entry.manifest)
903 };
904
905 self.transition_to(name, AppState::Running)?;
907
908 debug!(
909 app = name,
910 agents = spawn_requests.len(),
911 "application started"
912 );
913
914 #[cfg(feature = "exochain")]
916 if let Some(ref cm) = self.chain_manager {
917 cm.append(
918 "app",
919 crate::chain::EVENT_KIND_APP_START,
920 Some(serde_json::json!({
921 "app_name": name,
922 "agents_spawned": spawn_requests.len(),
923 })),
924 );
925 }
926
927 Ok(spawn_requests)
928 }
929
930 pub fn stop(&self, name: &str) -> Result<(), AppError> {
939 {
940 let entry = self.apps.get(name).ok_or_else(|| AppError::NotFound {
941 name: name.to_owned(),
942 })?;
943 if entry.state != AppState::Running {
944 return Err(AppError::InvalidState {
945 name: name.to_owned(),
946 expected: "Running".into(),
947 actual: entry.state.to_string(),
948 });
949 }
950 }
951
952 self.transition_to(name, AppState::Stopping)?;
953
954 {
956 let mut entry = self.apps.get_mut(name).ok_or_else(|| AppError::NotFound {
957 name: name.to_owned(),
958 })?;
959 entry.agent_pids.clear();
960 entry.service_names.clear();
961 }
962
963 self.transition_to(name, AppState::Stopped)?;
964
965 debug!(app = name, "application stopped");
966
967 #[cfg(feature = "exochain")]
969 if let Some(ref cm) = self.chain_manager {
970 cm.append(
971 "app",
972 crate::chain::EVENT_KIND_APP_STOP,
973 Some(serde_json::json!({
974 "app_name": name,
975 })),
976 );
977 }
978
979 Ok(())
980 }
981
982 pub fn build_spawn_requests(manifest: &AppManifest) -> Vec<SpawnRequest> {
987 manifest
988 .agents
989 .iter()
990 .map(|agent| SpawnRequest {
991 agent_id: format!("{}/{}", manifest.name, agent.id),
992 capabilities: Some(agent.capabilities.clone()),
993 parent_pid: None,
994 env: HashMap::new(),
995 backend: None,
996 })
997 .collect()
998 }
999}
1000
1001impl Default for AppManager {
1002 fn default() -> Self {
1003 Self::new()
1004 }
1005}
1006
1007impl AppManifest {
1010 pub fn from_json_str(json: &str) -> Result<Self, AppError> {
1016 let manifest: AppManifest =
1017 serde_json::from_str(json).map_err(|e| AppError::ManifestInvalid {
1018 reason: format!("JSON parse error: {e}"),
1019 })?;
1020 validate_manifest(&manifest)?;
1021 Ok(manifest)
1022 }
1023
1024 pub fn from_toml_str(src: &str) -> Result<Self, AppError> {
1029 let manifest: AppManifest = toml::from_str(src).map_err(|e| AppError::ManifestInvalid {
1030 reason: format!("TOML parse error: {e}"),
1031 })?;
1032 validate_manifest(&manifest)?;
1033 Ok(manifest)
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040
1041 fn sample_manifest() -> AppManifest {
1042 AppManifest {
1043 name: "code-reviewer".into(),
1044 version: "1.0.0".into(),
1045 description: "Automated code review app".into(),
1046 author: Some("WeftOS Team".into()),
1047 license: Some("MIT".into()),
1048 agents: vec![
1049 AgentSpec {
1050 id: "reviewer".into(),
1051 role: "code-review".into(),
1052 capabilities: AgentCapabilities::default(),
1053 auto_start: true,
1054 },
1055 AgentSpec {
1056 id: "reporter".into(),
1057 role: "report-generator".into(),
1058 capabilities: AgentCapabilities {
1059 can_network: false,
1060 ..Default::default()
1061 },
1062 auto_start: true,
1063 },
1064 ],
1065 tools: vec![ToolSpec {
1066 name: "diff-analyzer".into(),
1067 source: ToolSource::Wasm("tools/diff-analyzer.wasm".into()),
1068 schema: None,
1069 }],
1070 services: vec![ServiceSpec {
1071 name: "review-db".into(),
1072 image: Some("redis:7-alpine".into()),
1073 command: None,
1074 ports: vec![PortMapping {
1075 host_port: 6380,
1076 container_port: 6379,
1077 protocol: "tcp".into(),
1078 }],
1079 env: HashMap::new(),
1080 health_endpoint: Some("redis://localhost:6380".into()),
1081 }],
1082 capabilities: AppCapabilities {
1083 network: true,
1084 filesystem: vec!["/workspace".into()],
1085 shell: false,
1086 ipc: IpcScope::All,
1087 },
1088 hooks: AppHooks {
1089 on_install: Some("scripts/setup.sh".into()),
1090 on_start: Some("scripts/migrate.sh".into()),
1091 on_stop: None,
1092 on_remove: None,
1093 },
1094 }
1095 }
1096
1097 #[test]
1098 fn manifest_serde_roundtrip() {
1099 let manifest = sample_manifest();
1100 let json = serde_json::to_string_pretty(&manifest).unwrap();
1101 let restored: AppManifest = serde_json::from_str(&json).unwrap();
1102 assert_eq!(restored.name, "code-reviewer");
1103 assert_eq!(restored.version, "1.0.0");
1104 assert_eq!(restored.agents.len(), 2);
1105 assert_eq!(restored.tools.len(), 1);
1106 assert_eq!(restored.services.len(), 1);
1107 }
1108
1109 #[test]
1110 fn manifest_from_toml_str_parses_and_validates() {
1111 let src = "name = \"toml-app\"\nversion = \"0.2.0\"\n\n[[agents]]\nid = \"worker\"\n";
1112 let manifest = AppManifest::from_toml_str(src).unwrap();
1113 assert_eq!(manifest.name, "toml-app");
1114 assert_eq!(manifest.agents.len(), 1);
1115 assert_eq!(manifest.agents[0].id, "worker");
1116
1117 let bad = AppManifest::from_toml_str("name = \"x\"").unwrap_err();
1118 assert!(matches!(bad, AppError::ManifestInvalid { .. }), "{bad}");
1119 let invalid = AppManifest::from_toml_str("name = \"\"\nversion = \"1\"").unwrap_err();
1120 assert!(matches!(invalid, AppError::ManifestInvalid { .. }), "{invalid}");
1121 }
1122
1123 #[test]
1124 fn manifest_minimal_serde() {
1125 let json = r#"{"name":"my-app","version":"0.1.0"}"#;
1126 let manifest: AppManifest = serde_json::from_str(json).unwrap();
1127 assert_eq!(manifest.name, "my-app");
1128 assert!(manifest.agents.is_empty());
1129 assert!(manifest.tools.is_empty());
1130 assert!(manifest.services.is_empty());
1131 assert!(!manifest.capabilities.network);
1132 }
1133
1134 #[test]
1135 fn validate_manifest_ok() {
1136 let manifest = sample_manifest();
1137 assert!(validate_manifest(&manifest).is_ok());
1138 }
1139
1140 #[test]
1141 fn validate_manifest_empty_name() {
1142 let mut manifest = sample_manifest();
1143 manifest.name = String::new();
1144 let err = validate_manifest(&manifest).unwrap_err();
1145 assert!(err.to_string().contains("empty"));
1146 }
1147
1148 #[test]
1149 fn validate_manifest_invalid_name_chars() {
1150 let mut manifest = sample_manifest();
1151 manifest.name = "my app!".into();
1152 let err = validate_manifest(&manifest).unwrap_err();
1153 assert!(err.to_string().contains("invalid characters"));
1154 }
1155
1156 #[test]
1157 fn validate_manifest_empty_version() {
1158 let mut manifest = sample_manifest();
1159 manifest.version = String::new();
1160 let err = validate_manifest(&manifest).unwrap_err();
1161 assert!(err.to_string().contains("version"));
1162 }
1163
1164 #[test]
1165 fn validate_manifest_duplicate_agent_ids() {
1166 let mut manifest = sample_manifest();
1167 manifest.agents.push(AgentSpec {
1168 id: "reviewer".into(), role: "other".into(),
1170 capabilities: AgentCapabilities::default(),
1171 auto_start: false,
1172 });
1173 let err = validate_manifest(&manifest).unwrap_err();
1174 assert!(err.to_string().contains("duplicate agent"));
1175 }
1176
1177 #[test]
1178 fn validate_manifest_duplicate_tool_names() {
1179 let mut manifest = sample_manifest();
1180 manifest.tools.push(ToolSpec {
1181 name: "diff-analyzer".into(), source: ToolSource::Native("builtin".into()),
1183 schema: None,
1184 });
1185 let err = validate_manifest(&manifest).unwrap_err();
1186 assert!(err.to_string().contains("duplicate tool"));
1187 }
1188
1189 #[test]
1190 fn validate_manifest_duplicate_service_names() {
1191 let mut manifest = sample_manifest();
1192 manifest.services.push(ServiceSpec {
1193 name: "review-db".into(), image: None,
1195 command: Some("redis-server".into()),
1196 ports: Vec::new(),
1197 env: HashMap::new(),
1198 health_endpoint: None,
1199 });
1200 let err = validate_manifest(&manifest).unwrap_err();
1201 assert!(err.to_string().contains("duplicate service"));
1202 }
1203
1204 #[test]
1205 fn app_state_display() {
1206 assert_eq!(AppState::Installed.to_string(), "installed");
1207 assert_eq!(AppState::Running.to_string(), "running");
1208 assert_eq!(AppState::Stopped.to_string(), "stopped");
1209 assert_eq!(
1210 AppState::Failed("timeout".into()).to_string(),
1211 "failed: timeout"
1212 );
1213 }
1214
1215 #[test]
1216 fn install_and_list() {
1217 let manager = AppManager::new();
1218 let name = manager.install(sample_manifest()).unwrap();
1219 assert_eq!(name, "code-reviewer");
1220
1221 let list = manager.list();
1222 assert_eq!(list.len(), 1);
1223 assert_eq!(list[0].0, "code-reviewer");
1224 assert_eq!(list[0].1, AppState::Installed);
1225 }
1226
1227 #[test]
1228 fn install_duplicate_fails() {
1229 let manager = AppManager::new();
1230 manager.install(sample_manifest()).unwrap();
1231 let err = manager.install(sample_manifest()).unwrap_err();
1232 assert!(matches!(err, AppError::AlreadyInstalled { .. }));
1233 }
1234
1235 #[test]
1236 fn inspect_installed_app() {
1237 let manager = AppManager::new();
1238 manager.install(sample_manifest()).unwrap();
1239 let app = manager.inspect("code-reviewer").unwrap();
1240 assert_eq!(app.state, AppState::Installed);
1241 assert_eq!(app.manifest.agents.len(), 2);
1242 }
1243
1244 #[test]
1245 fn inspect_not_found() {
1246 let manager = AppManager::new();
1247 assert!(matches!(
1248 manager.inspect("nope"),
1249 Err(AppError::NotFound { .. })
1250 ));
1251 }
1252
1253 #[test]
1254 fn state_transitions() {
1255 let manager = AppManager::new();
1256 manager.install(sample_manifest()).unwrap();
1257
1258 manager
1260 .transition_to("code-reviewer", AppState::Starting)
1261 .unwrap();
1262 manager
1263 .transition_to("code-reviewer", AppState::Running)
1264 .unwrap();
1265 manager
1266 .transition_to("code-reviewer", AppState::Stopping)
1267 .unwrap();
1268 manager
1269 .transition_to("code-reviewer", AppState::Stopped)
1270 .unwrap();
1271
1272 let app = manager.inspect("code-reviewer").unwrap();
1273 assert_eq!(app.state, AppState::Stopped);
1274 }
1275
1276 #[test]
1277 fn state_transition_restart() {
1278 let manager = AppManager::new();
1279 manager.install(sample_manifest()).unwrap();
1280
1281 manager
1282 .transition_to("code-reviewer", AppState::Starting)
1283 .unwrap();
1284 manager
1285 .transition_to("code-reviewer", AppState::Running)
1286 .unwrap();
1287 manager
1288 .transition_to("code-reviewer", AppState::Stopping)
1289 .unwrap();
1290 manager
1291 .transition_to("code-reviewer", AppState::Stopped)
1292 .unwrap();
1293 manager
1295 .transition_to("code-reviewer", AppState::Starting)
1296 .unwrap();
1297 }
1298
1299 #[test]
1300 fn invalid_state_transition() {
1301 let manager = AppManager::new();
1302 manager.install(sample_manifest()).unwrap();
1303
1304 let err = manager
1306 .transition_to("code-reviewer", AppState::Running)
1307 .unwrap_err();
1308 assert!(matches!(err, AppError::InvalidState { .. }));
1309 }
1310
1311 #[test]
1312 fn state_transition_to_failed() {
1313 let manager = AppManager::new();
1314 manager.install(sample_manifest()).unwrap();
1315
1316 manager
1317 .transition_to("code-reviewer", AppState::Starting)
1318 .unwrap();
1319 manager
1320 .transition_to("code-reviewer", AppState::Failed("agent crash".into()))
1321 .unwrap();
1322
1323 let app = manager.inspect("code-reviewer").unwrap();
1324 assert_eq!(app.state, AppState::Failed("agent crash".into()));
1325 }
1326
1327 #[test]
1328 fn remove_installed_app() {
1329 let manager = AppManager::new();
1330 manager.install(sample_manifest()).unwrap();
1331 let manifest = manager.remove("code-reviewer").unwrap();
1332 assert_eq!(manifest.name, "code-reviewer");
1333 assert!(manager.is_empty());
1334 }
1335
1336 #[test]
1337 fn remove_running_app_fails() {
1338 let manager = AppManager::new();
1339 manager.install(sample_manifest()).unwrap();
1340 manager
1341 .transition_to("code-reviewer", AppState::Starting)
1342 .unwrap();
1343 manager
1344 .transition_to("code-reviewer", AppState::Running)
1345 .unwrap();
1346
1347 let err = manager.remove("code-reviewer").unwrap_err();
1348 assert!(matches!(err, AppError::InvalidState { .. }));
1349 }
1350
1351 #[test]
1352 fn remove_stopped_app() {
1353 let manager = AppManager::new();
1354 manager.install(sample_manifest()).unwrap();
1355 manager
1356 .transition_to("code-reviewer", AppState::Starting)
1357 .unwrap();
1358 manager
1359 .transition_to("code-reviewer", AppState::Running)
1360 .unwrap();
1361 manager
1362 .transition_to("code-reviewer", AppState::Stopping)
1363 .unwrap();
1364 manager
1365 .transition_to("code-reviewer", AppState::Stopped)
1366 .unwrap();
1367
1368 assert!(manager.remove("code-reviewer").is_ok());
1369 }
1370
1371 #[test]
1372 fn add_agent_pid() {
1373 let manager = AppManager::new();
1374 manager.install(sample_manifest()).unwrap();
1375 manager.add_agent_pid("code-reviewer", 42).unwrap();
1376
1377 let app = manager.inspect("code-reviewer").unwrap();
1378 assert_eq!(app.agent_pids, vec![42]);
1379 }
1380
1381 #[test]
1382 fn add_service_name() {
1383 let manager = AppManager::new();
1384 manager.install(sample_manifest()).unwrap();
1385 manager
1386 .add_service_name("code-reviewer", "review-db".into())
1387 .unwrap();
1388
1389 let app = manager.inspect("code-reviewer").unwrap();
1390 assert_eq!(app.service_names, vec!["review-db"]);
1391 }
1392
1393 #[test]
1394 fn namespaced_ids() {
1395 let manifest = sample_manifest();
1396 let agent_ids = AppManager::namespaced_agent_ids(&manifest);
1397 assert_eq!(
1398 agent_ids,
1399 vec!["code-reviewer/reviewer", "code-reviewer/reporter"]
1400 );
1401
1402 let tool_names = AppManager::namespaced_tool_names(&manifest);
1403 assert_eq!(tool_names, vec!["code-reviewer/diff-analyzer"]);
1404 }
1405
1406 #[test]
1407 fn tool_source_variants() {
1408 let wasm = ToolSource::Wasm("tools/my.wasm".into());
1409 let native = ToolSource::Native("read_file".into());
1410 let skill = ToolSource::Skill("skills/REVIEW.md".into());
1411
1412 for source in &[wasm, native, skill] {
1414 let json = serde_json::to_string(source).unwrap();
1415 let _restored: ToolSource = serde_json::from_str(&json).unwrap();
1416 }
1417 }
1418
1419 #[test]
1420 fn app_error_display() {
1421 let err = AppError::ManifestNotFound {
1422 path: "/tmp/weftapp.toml".into(),
1423 };
1424 assert!(err.to_string().contains("manifest not found"));
1425
1426 let err = AppError::AlreadyInstalled {
1427 name: "my-app".into(),
1428 };
1429 assert!(err.to_string().contains("my-app"));
1430
1431 let err = AppError::HookFailed {
1432 app_name: "my-app".into(),
1433 hook: "on_start".into(),
1434 reason: "exit code 1".into(),
1435 };
1436 assert!(err.to_string().contains("on_start"));
1437 }
1438
1439 #[test]
1440 fn app_capabilities_serde_roundtrip() {
1441 let caps = AppCapabilities {
1442 network: true,
1443 filesystem: vec!["/workspace".into(), "/data".into()],
1444 shell: false,
1445 ipc: IpcScope::All,
1446 };
1447 let json = serde_json::to_string(&caps).unwrap();
1448 let restored: AppCapabilities = serde_json::from_str(&json).unwrap();
1449 assert_eq!(restored, caps);
1450 }
1451
1452 #[test]
1453 fn app_hooks_serde_roundtrip() {
1454 let hooks = AppHooks {
1455 on_install: Some("setup.sh".into()),
1456 on_start: None,
1457 on_stop: Some("cleanup.sh".into()),
1458 on_remove: None,
1459 };
1460 let json = serde_json::to_string(&hooks).unwrap();
1461 let restored: AppHooks = serde_json::from_str(&json).unwrap();
1462 assert_eq!(restored.on_install.as_deref(), Some("setup.sh"));
1463 assert!(restored.on_start.is_none());
1464 }
1465
1466 #[test]
1467 fn parse_manifest_from_json() {
1468 let json = serde_json::json!({
1469 "name": "test-app",
1470 "version": "1.0.0",
1471 "description": "A test app",
1472 "agents": [],
1473 "tools": [],
1474 "services": [],
1475 "capabilities": {
1476 "network": false,
1477 "filesystem": [],
1478 "shell": false,
1479 "ipc": "None"
1480 },
1481 "hooks": {}
1482 });
1483 let manifest = AppManifest::from_json_str(&json.to_string()).unwrap();
1484 assert_eq!(manifest.name, "test-app");
1485 assert_eq!(manifest.version, "1.0.0");
1486 assert!(manifest.agents.is_empty());
1487 }
1488
1489 #[test]
1490 fn parse_manifest_from_json_invalid() {
1491 let result = AppManifest::from_json_str("not valid json");
1492 assert!(result.is_err());
1493 assert!(result.unwrap_err().to_string().contains("JSON parse error"));
1494 }
1495
1496 #[test]
1497 fn parse_manifest_from_json_empty_name_fails() {
1498 let json = serde_json::json!({
1499 "name": "",
1500 "version": "1.0.0"
1501 });
1502 let result = AppManifest::from_json_str(&json.to_string());
1503 assert!(result.is_err());
1504 assert!(result.unwrap_err().to_string().contains("empty"));
1505 }
1506
1507 #[test]
1510 fn integration_app_full_lifecycle() {
1511 let manifest = AppManifest {
1513 name: "data-pipeline".into(),
1514 version: "2.1.0".into(),
1515 description: "Real-time data ingestion and analysis pipeline".into(),
1516 author: Some("WeftOS Team".into()),
1517 license: Some("MIT".into()),
1518 agents: vec![
1519 AgentSpec {
1520 id: "ingester".into(),
1521 role: "data-ingestion".into(),
1522 capabilities: AgentCapabilities::default(),
1523 auto_start: true,
1524 },
1525 AgentSpec {
1526 id: "analyzer".into(),
1527 role: "data-analysis".into(),
1528 capabilities: AgentCapabilities::default(),
1529 auto_start: true,
1530 },
1531 ],
1532 tools: vec![ToolSpec {
1533 name: "transform".into(),
1534 source: ToolSource::Wasm("tools/transform.wasm".into()),
1535 schema: Some(serde_json::json!({
1536 "type": "object",
1537 "properties": {
1538 "input": {"type": "string"},
1539 "format": {"type": "string"}
1540 }
1541 })),
1542 }],
1543 services: vec![ServiceSpec {
1544 name: "cache".into(),
1545 image: Some("redis:7-alpine".into()),
1546 command: None,
1547 ports: vec![PortMapping {
1548 host_port: 6380,
1549 container_port: 6379,
1550 protocol: "tcp".into(),
1551 }],
1552 env: HashMap::from([("REDIS_MAX_MEMORY".into(), "256mb".into())]),
1553 health_endpoint: Some("redis://localhost:6380".into()),
1554 }],
1555 capabilities: AppCapabilities {
1556 network: true,
1557 filesystem: vec!["/data".into(), "/workspace".into()],
1558 shell: false,
1559 ipc: IpcScope::All,
1560 },
1561 hooks: AppHooks {
1562 on_install: Some("scripts/setup.sh".into()),
1563 on_start: Some("scripts/migrate.sh".into()),
1564 on_stop: Some("scripts/cleanup.sh".into()),
1565 on_remove: None,
1566 },
1567 };
1568
1569 validate_manifest(&manifest).unwrap();
1571
1572 let agent_ids = AppManager::namespaced_agent_ids(&manifest);
1574 assert_eq!(
1575 agent_ids,
1576 vec!["data-pipeline/ingester", "data-pipeline/analyzer"]
1577 );
1578 let tool_names = AppManager::namespaced_tool_names(&manifest);
1579 assert_eq!(tool_names, vec!["data-pipeline/transform"]);
1580
1581 let manager = AppManager::new();
1583 let app_name = manager.install(manifest.clone()).unwrap();
1584 assert_eq!(app_name, "data-pipeline");
1585
1586 let list = manager.list();
1588 assert_eq!(list.len(), 1);
1589 assert_eq!(list[0].0, "data-pipeline");
1590
1591 let inspected = manager.inspect("data-pipeline").unwrap();
1593 assert_eq!(inspected.manifest.agents.len(), 2);
1594 assert_eq!(inspected.manifest.services.len(), 1);
1595 assert_eq!(inspected.manifest.tools.len(), 1);
1596 assert_eq!(inspected.manifest.services[0].name, "cache");
1597 assert_eq!(
1598 inspected.manifest.services[0].image,
1599 Some("redis:7-alpine".into())
1600 );
1601 assert!(inspected.manifest.capabilities.network);
1602 assert_eq!(
1603 inspected.manifest.capabilities.filesystem,
1604 vec!["/data", "/workspace"]
1605 );
1606 assert!(!inspected.manifest.capabilities.shell);
1607 assert_eq!(inspected.manifest.capabilities.ipc, IpcScope::All);
1608 assert_eq!(
1609 inspected.manifest.hooks.on_install,
1610 Some("scripts/setup.sh".into())
1611 );
1612 assert_eq!(
1613 inspected.manifest.hooks.on_start,
1614 Some("scripts/migrate.sh".into())
1615 );
1616 assert_eq!(
1617 inspected.manifest.hooks.on_stop,
1618 Some("scripts/cleanup.sh".into())
1619 );
1620 assert!(inspected.manifest.hooks.on_remove.is_none());
1621 assert_eq!(inspected.manifest.author, Some("WeftOS Team".into()));
1622 assert_eq!(inspected.manifest.license, Some("MIT".into()));
1623
1624 manager
1626 .transition_to("data-pipeline", AppState::Starting)
1627 .unwrap();
1628 manager
1629 .transition_to("data-pipeline", AppState::Running)
1630 .unwrap();
1631
1632 manager.add_agent_pid("data-pipeline", 10).unwrap(); manager.add_agent_pid("data-pipeline", 11).unwrap(); manager
1638 .add_service_name("data-pipeline", "cache".into())
1639 .unwrap();
1640
1641 let running = manager.inspect("data-pipeline").unwrap();
1643 assert!(matches!(running.state, AppState::Running));
1644 assert_eq!(running.agent_pids.len(), 2);
1645 assert!(running.agent_pids.contains(&10));
1646 assert!(running.agent_pids.contains(&11));
1647 assert_eq!(running.service_names.len(), 1);
1648 assert!(running.service_names.contains(&"cache".to_string()));
1649
1650 manager
1652 .transition_to("data-pipeline", AppState::Stopping)
1653 .unwrap();
1654 manager
1655 .transition_to("data-pipeline", AppState::Stopped)
1656 .unwrap();
1657
1658 let stopped = manager.inspect("data-pipeline").unwrap();
1659 assert!(matches!(stopped.state, AppState::Stopped));
1660
1661 let removed = manager.remove("data-pipeline").unwrap();
1663 assert_eq!(removed.name, "data-pipeline");
1664 assert!(manager.is_empty());
1665 }
1666
1667 #[test]
1668 fn integration_multi_app_isolation() {
1669 let manager = AppManager::new();
1670
1671 let app1 = AppManifest {
1673 name: "frontend".into(),
1674 version: "1.0.0".into(),
1675 description: "Web frontend".into(),
1676 author: None,
1677 license: None,
1678 agents: vec![AgentSpec {
1679 id: "worker".into(),
1680 role: "serve".into(),
1681 capabilities: AgentCapabilities::default(),
1682 auto_start: true,
1683 }],
1684 tools: vec![ToolSpec {
1685 name: "render".into(),
1686 source: ToolSource::Native("fs.read_file".into()),
1687 schema: None,
1688 }],
1689 services: vec![],
1690 capabilities: AppCapabilities {
1691 network: true,
1692 filesystem: vec![],
1693 shell: false,
1694 ipc: IpcScope::None,
1695 },
1696 hooks: AppHooks::default(),
1697 };
1698
1699 let app2 = AppManifest {
1700 name: "backend".into(),
1701 version: "2.0.0".into(),
1702 description: "API backend".into(),
1703 author: None,
1704 license: None,
1705 agents: vec![AgentSpec {
1706 id: "worker".into(), role: "api".into(),
1708 capabilities: AgentCapabilities::default(),
1709 auto_start: true,
1710 }],
1711 tools: vec![ToolSpec {
1712 name: "render".into(), source: ToolSource::Wasm("tools/render.wasm".into()),
1714 schema: None,
1715 }],
1716 services: vec![ServiceSpec {
1717 name: "db".into(),
1718 image: Some("postgres:16-alpine".into()),
1719 command: None,
1720 ports: vec![PortMapping {
1721 host_port: 5432,
1722 container_port: 5432,
1723 protocol: "tcp".into(),
1724 }],
1725 env: HashMap::from([("POSTGRES_PASSWORD".into(), "dev".into())]),
1726 health_endpoint: None,
1727 }],
1728 capabilities: AppCapabilities {
1729 network: true,
1730 filesystem: vec!["/data".into()],
1731 shell: false,
1732 ipc: IpcScope::All,
1733 },
1734 hooks: AppHooks::default(),
1735 };
1736
1737 manager.install(app1).unwrap();
1738 manager.install(app2).unwrap();
1739
1740 assert_eq!(manager.list().len(), 2);
1742
1743 let fe_agents =
1745 AppManager::namespaced_agent_ids(&manager.inspect("frontend").unwrap().manifest);
1746 let be_agents =
1747 AppManager::namespaced_agent_ids(&manager.inspect("backend").unwrap().manifest);
1748 assert_eq!(fe_agents, vec!["frontend/worker"]);
1749 assert_eq!(be_agents, vec!["backend/worker"]);
1750
1751 let fe_tools =
1752 AppManager::namespaced_tool_names(&manager.inspect("frontend").unwrap().manifest);
1753 let be_tools =
1754 AppManager::namespaced_tool_names(&manager.inspect("backend").unwrap().manifest);
1755 assert_eq!(fe_tools, vec!["frontend/render"]);
1756 assert_eq!(be_tools, vec!["backend/render"]);
1757
1758 manager
1760 .transition_to("frontend", AppState::Starting)
1761 .unwrap();
1762 manager
1763 .transition_to("frontend", AppState::Running)
1764 .unwrap();
1765 let fe = manager.inspect("frontend").unwrap();
1768 let be = manager.inspect("backend").unwrap();
1769 assert!(matches!(fe.state, AppState::Running));
1770 assert!(matches!(be.state, AppState::Installed));
1771
1772 manager
1774 .transition_to("backend", AppState::Starting)
1775 .unwrap();
1776 manager.transition_to("backend", AppState::Running).unwrap();
1777
1778 manager.add_agent_pid("frontend", 100).unwrap();
1780 manager.add_agent_pid("backend", 200).unwrap();
1781
1782 let fe = manager.inspect("frontend").unwrap();
1783 let be = manager.inspect("backend").unwrap();
1784 assert_eq!(fe.agent_pids, vec![100]);
1785 assert_eq!(be.agent_pids, vec![200]);
1786
1787 manager
1789 .transition_to("frontend", AppState::Stopping)
1790 .unwrap();
1791 manager
1792 .transition_to("frontend", AppState::Stopped)
1793 .unwrap();
1794
1795 let fe = manager.inspect("frontend").unwrap();
1796 let be = manager.inspect("backend").unwrap();
1797 assert!(matches!(fe.state, AppState::Stopped));
1798 assert!(matches!(be.state, AppState::Running));
1799 }
1800
1801 #[test]
1802 fn app_hooks_lifecycle() {
1803 let manifest = AppManifest {
1804 name: "hooks-test".into(),
1805 version: "0.1.0".into(),
1806 description: String::new(),
1807 author: None,
1808 license: None,
1809 agents: Vec::new(),
1810 tools: Vec::new(),
1811 services: Vec::new(),
1812 capabilities: AppCapabilities::default(),
1813 hooks: AppHooks {
1814 on_install: Some("scripts/setup.sh".into()),
1815 on_start: Some("scripts/migrate.sh".into()),
1816 on_stop: Some("scripts/cleanup.sh".into()),
1817 on_remove: None,
1818 },
1819 };
1820 assert_eq!(manifest.hooks.on_install, Some("scripts/setup.sh".into()));
1821 assert_eq!(manifest.hooks.on_start, Some("scripts/migrate.sh".into()));
1822 assert_eq!(manifest.hooks.on_stop, Some("scripts/cleanup.sh".into()));
1823 assert!(manifest.hooks.on_remove.is_none());
1824 }
1825
1826 #[test]
1829 fn k5_manifest_parsed_and_validated() {
1830 let manifest = AppManifest {
1832 name: "test-app".into(),
1833 version: "1.0.0".into(),
1834 description: "A test application".into(),
1835 author: None,
1836 license: None,
1837 agents: vec![AgentSpec {
1838 id: "worker".into(),
1839 role: "coder".into(),
1840 capabilities: AgentCapabilities::default(),
1841 auto_start: true,
1842 }],
1843 tools: Vec::new(),
1844 services: vec![ServiceSpec {
1845 name: "api".into(),
1846 image: None,
1847 command: Some("serve".into()),
1848 ports: vec![PortMapping {
1849 host_port: 8080,
1850 container_port: 8080,
1851 protocol: "tcp".into(),
1852 }],
1853 env: HashMap::new(),
1854 health_endpoint: None,
1855 }],
1856 capabilities: AppCapabilities::default(),
1857 hooks: AppHooks::default(),
1858 };
1859 assert!(validate_manifest(&manifest).is_ok());
1860 assert!(!manifest.name.is_empty());
1861 assert!(!manifest.version.is_empty());
1862
1863 let json = serde_json::to_string(&manifest).unwrap();
1865 let parsed = AppManifest::from_json_str(&json).unwrap();
1866 assert_eq!(parsed.name, "test-app");
1867 assert_eq!(parsed.agents.len(), 1);
1868 assert_eq!(parsed.services.len(), 1);
1869 }
1870
1871 #[test]
1872 fn k5_app_install_start_stop_lifecycle() {
1873 let mgr = AppManager::new();
1874 let manifest = AppManifest {
1875 name: "lifecycle-app".into(),
1876 version: "2.0.0".into(),
1877 description: "Lifecycle test".into(),
1878 author: None,
1879 license: None,
1880 agents: vec![
1881 AgentSpec {
1882 id: "alpha".into(),
1883 role: "coder".into(),
1884 capabilities: AgentCapabilities::default(),
1885 auto_start: true,
1886 },
1887 AgentSpec {
1888 id: "beta".into(),
1889 role: "reviewer".into(),
1890 capabilities: AgentCapabilities {
1891 can_network: true,
1892 ..Default::default()
1893 },
1894 auto_start: false,
1895 },
1896 ],
1897 tools: Vec::new(),
1898 services: Vec::new(),
1899 capabilities: AppCapabilities::default(),
1900 hooks: AppHooks::default(),
1901 };
1902
1903 let app_id = mgr.install(manifest).unwrap();
1905 assert_eq!(app_id, "lifecycle-app");
1906 let app = mgr.inspect(&app_id).unwrap();
1907 assert_eq!(app.state, AppState::Installed);
1908
1909 let spawn_reqs = mgr.start(&app_id).unwrap();
1911 assert_eq!(spawn_reqs.len(), 2);
1912 assert_eq!(spawn_reqs[0].agent_id, "lifecycle-app/alpha");
1913 assert_eq!(spawn_reqs[1].agent_id, "lifecycle-app/beta");
1914
1915 let app = mgr.inspect(&app_id).unwrap();
1916 assert_eq!(app.state, AppState::Running);
1917
1918 mgr.stop(&app_id).unwrap();
1920 let app = mgr.inspect(&app_id).unwrap();
1921 assert_eq!(app.state, AppState::Stopped);
1922
1923 let spawn_reqs = mgr.start(&app_id).unwrap();
1925 assert_eq!(spawn_reqs.len(), 2);
1926 let app = mgr.inspect(&app_id).unwrap();
1927 assert_eq!(app.state, AppState::Running);
1928 }
1929
1930 #[test]
1931 fn k5_app_agents_spawn_with_correct_capabilities() {
1932 let manifest = AppManifest {
1933 name: "cap-app".into(),
1934 version: "1.0.0".into(),
1935 description: String::new(),
1936 author: None,
1937 license: None,
1938 agents: vec![
1939 AgentSpec {
1940 id: "networker".into(),
1941 role: "fetcher".into(),
1942 capabilities: AgentCapabilities {
1943 can_network: true,
1944 can_spawn: false,
1945 can_ipc: true,
1946 can_exec_tools: true,
1947 ipc_scope: IpcScope::All,
1948 ..Default::default()
1949 },
1950 auto_start: true,
1951 },
1952 AgentSpec {
1953 id: "sandboxed".into(),
1954 role: "compute".into(),
1955 capabilities: AgentCapabilities {
1956 can_network: false,
1957 can_spawn: false,
1958 can_ipc: false,
1959 can_exec_tools: false,
1960 ipc_scope: IpcScope::None,
1961 ..Default::default()
1962 },
1963 auto_start: true,
1964 },
1965 ],
1966 tools: Vec::new(),
1967 services: Vec::new(),
1968 capabilities: AppCapabilities::default(),
1969 hooks: AppHooks::default(),
1970 };
1971
1972 let spawn_reqs = AppManager::build_spawn_requests(&manifest);
1973 assert_eq!(spawn_reqs.len(), 2);
1974
1975 assert_eq!(spawn_reqs[0].agent_id, "cap-app/networker");
1977 let caps0 = spawn_reqs[0].capabilities.as_ref().unwrap();
1978 assert!(caps0.can_network);
1979 assert!(!caps0.can_spawn);
1980 assert!(caps0.can_ipc);
1981 assert!(caps0.can_exec_tools);
1982
1983 assert_eq!(spawn_reqs[1].agent_id, "cap-app/sandboxed");
1985 let caps1 = spawn_reqs[1].capabilities.as_ref().unwrap();
1986 assert!(!caps1.can_network);
1987 assert!(!caps1.can_spawn);
1988 assert!(!caps1.can_ipc);
1989 assert!(!caps1.can_exec_tools);
1990 assert_eq!(caps1.ipc_scope, IpcScope::None);
1991 }
1992
1993 #[test]
1994 fn k5_app_list_shows_installed() {
1995 let mgr = AppManager::new();
1996
1997 let app1 = AppManifest {
1998 name: "app-one".into(),
1999 version: "1.0.0".into(),
2000 description: String::new(),
2001 author: None,
2002 license: None,
2003 agents: Vec::new(),
2004 tools: Vec::new(),
2005 services: Vec::new(),
2006 capabilities: AppCapabilities::default(),
2007 hooks: AppHooks::default(),
2008 };
2009 let app2 = AppManifest {
2010 name: "app-two".into(),
2011 version: "2.0.0".into(),
2012 description: String::new(),
2013 author: None,
2014 license: None,
2015 agents: Vec::new(),
2016 tools: Vec::new(),
2017 services: Vec::new(),
2018 capabilities: AppCapabilities::default(),
2019 hooks: AppHooks::default(),
2020 };
2021
2022 mgr.install(app1).unwrap();
2023 mgr.install(app2).unwrap();
2024
2025 let list = mgr.list();
2026 assert_eq!(list.len(), 2);
2027 let names: Vec<&str> = list.iter().map(|(n, _, _)| n.as_str()).collect();
2028 assert!(names.contains(&"app-one"));
2029 assert!(names.contains(&"app-two"));
2030 }
2031
2032 #[test]
2033 fn k5_invalid_manifest_rejected() {
2034 let mgr = AppManager::new();
2035
2036 let bad = AppManifest {
2038 name: String::new(),
2039 version: "1.0.0".into(),
2040 description: String::new(),
2041 author: None,
2042 license: None,
2043 agents: Vec::new(),
2044 tools: Vec::new(),
2045 services: Vec::new(),
2046 capabilities: AppCapabilities::default(),
2047 hooks: AppHooks::default(),
2048 };
2049 assert!(mgr.install(bad).is_err());
2050
2051 let bad = AppManifest {
2053 name: "ok-name".into(),
2054 version: String::new(),
2055 description: String::new(),
2056 author: None,
2057 license: None,
2058 agents: Vec::new(),
2059 tools: Vec::new(),
2060 services: Vec::new(),
2061 capabilities: AppCapabilities::default(),
2062 hooks: AppHooks::default(),
2063 };
2064 assert!(mgr.install(bad).is_err());
2065
2066 let bad = AppManifest {
2068 name: "bad name!".into(),
2069 version: "1.0.0".into(),
2070 description: String::new(),
2071 author: None,
2072 license: None,
2073 agents: Vec::new(),
2074 tools: Vec::new(),
2075 services: Vec::new(),
2076 capabilities: AppCapabilities::default(),
2077 hooks: AppHooks::default(),
2078 };
2079 assert!(mgr.install(bad).is_err());
2080
2081 assert!(mgr.is_empty());
2083 }
2084
2085 #[test]
2086 fn k5_start_wrong_state_fails() {
2087 let mgr = AppManager::new();
2088 let manifest = AppManifest {
2089 name: "state-test".into(),
2090 version: "1.0.0".into(),
2091 description: String::new(),
2092 author: None,
2093 license: None,
2094 agents: Vec::new(),
2095 tools: Vec::new(),
2096 services: Vec::new(),
2097 capabilities: AppCapabilities::default(),
2098 hooks: AppHooks::default(),
2099 };
2100 mgr.install(manifest).unwrap();
2101 mgr.start("state-test").unwrap();
2102
2103 let err = mgr.start("state-test").unwrap_err();
2105 assert!(matches!(err, AppError::InvalidState { .. }));
2106 }
2107
2108 #[test]
2109 fn k5_stop_wrong_state_fails() {
2110 let mgr = AppManager::new();
2111 let manifest = AppManifest {
2112 name: "stop-test".into(),
2113 version: "1.0.0".into(),
2114 description: String::new(),
2115 author: None,
2116 license: None,
2117 agents: Vec::new(),
2118 tools: Vec::new(),
2119 services: Vec::new(),
2120 capabilities: AppCapabilities::default(),
2121 hooks: AppHooks::default(),
2122 };
2123 mgr.install(manifest).unwrap();
2124
2125 let err = mgr.stop("stop-test").unwrap_err();
2127 assert!(matches!(err, AppError::InvalidState { .. }));
2128 }
2129
2130 #[test]
2131 fn k5_stop_clears_agent_pids() {
2132 let mgr = AppManager::new();
2133 let manifest = AppManifest {
2134 name: "pid-test".into(),
2135 version: "1.0.0".into(),
2136 description: String::new(),
2137 author: None,
2138 license: None,
2139 agents: vec![AgentSpec {
2140 id: "w".into(),
2141 role: "worker".into(),
2142 capabilities: AgentCapabilities::default(),
2143 auto_start: true,
2144 }],
2145 tools: Vec::new(),
2146 services: Vec::new(),
2147 capabilities: AppCapabilities::default(),
2148 hooks: AppHooks::default(),
2149 };
2150 mgr.install(manifest).unwrap();
2151 mgr.start("pid-test").unwrap();
2152
2153 mgr.add_agent_pid("pid-test", 42).unwrap();
2155 mgr.add_service_name("pid-test", "svc".into()).unwrap();
2156 let app = mgr.inspect("pid-test").unwrap();
2157 assert_eq!(app.agent_pids.len(), 1);
2158 assert_eq!(app.service_names.len(), 1);
2159
2160 mgr.stop("pid-test").unwrap();
2162 let app = mgr.inspect("pid-test").unwrap();
2163 assert!(app.agent_pids.is_empty());
2164 assert!(app.service_names.is_empty());
2165 }
2166
2167 fn apps_persist_tmp_path(suffix: &str) -> PathBuf {
2170 let pid = std::process::id();
2171 let ns = std::time::SystemTime::now()
2172 .duration_since(std::time::UNIX_EPOCH)
2173 .unwrap()
2174 .as_nanos();
2175 let dir = std::env::temp_dir().join(format!("weftos-apps-{pid}-{ns}-{suffix}"));
2176 std::fs::create_dir_all(&dir).unwrap();
2177 dir.join("apps.json")
2178 }
2179
2180 #[test]
2181 fn persisted_apps_rehydrate_on_restart() {
2182 let path = apps_persist_tmp_path("rehydrate");
2183
2184 {
2185 let manager = AppManager::new().with_persist_path(&path);
2186 manager.install(sample_manifest()).unwrap();
2187 let mut other = sample_manifest();
2188 other.name = "second-app".into();
2189 other.version = "2.0.0".into();
2190 manager.install(other).unwrap();
2191 assert_eq!(manager.len(), 2);
2192 }
2193
2194 assert!(path.exists(), "persist path should exist after install");
2195
2196 let restored = AppManager::new().with_persist_path(&path);
2198 assert_eq!(restored.len(), 2, "apps should rehydrate from disk");
2199 let app = restored.inspect("code-reviewer").unwrap();
2200 assert_eq!(app.manifest.version, "1.0.0");
2201 assert_eq!(app.state, AppState::Installed);
2202 assert!(restored.inspect("second-app").is_ok());
2203
2204 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2205 }
2206
2207 #[test]
2208 fn persist_uses_atomic_tmp_rename() {
2209 let path = apps_persist_tmp_path("atomic");
2210 let manager = AppManager::new().with_persist_path(&path);
2211 manager.install(sample_manifest()).unwrap();
2212
2213 assert!(path.exists());
2214 let tmp = path.with_extension("json.tmp");
2216 assert!(
2217 !tmp.exists(),
2218 "tmp file should be renamed away after atomic write"
2219 );
2220
2221 let data = std::fs::read_to_string(&path).unwrap();
2222 let file: AppsFile = serde_json::from_str(&data).unwrap();
2223 assert_eq!(file.version, 1);
2224 assert_eq!(file.apps.len(), 1);
2225 assert_eq!(file.apps[0].manifest.name, "code-reviewer");
2226
2227 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2228 }
2229
2230 #[test]
2231 fn persist_reflects_remove_and_state_change() {
2232 let path = apps_persist_tmp_path("mutations");
2233
2234 let manager = AppManager::new().with_persist_path(&path);
2235 manager.install(sample_manifest()).unwrap();
2236 let mut other = sample_manifest();
2237 other.name = "to-remove".into();
2238 manager.install(other).unwrap();
2239 manager
2240 .transition_to("code-reviewer", AppState::Starting)
2241 .unwrap();
2242 manager
2243 .transition_to("code-reviewer", AppState::Running)
2244 .unwrap();
2245 manager.remove("to-remove").unwrap();
2246
2247 let data = std::fs::read_to_string(&path).unwrap();
2248 let file: AppsFile = serde_json::from_str(&data).unwrap();
2249 assert_eq!(file.apps.len(), 1);
2250 assert_eq!(file.apps[0].manifest.name, "code-reviewer");
2251 assert_eq!(file.apps[0].state, AppState::Running);
2252
2253 let restored = AppManager::new().with_persist_path(&path);
2255 let app = restored.inspect("code-reviewer").unwrap();
2256 assert_eq!(
2257 app.state,
2258 AppState::Stopped,
2259 "transient Running state normalizes to Stopped on rehydrate"
2260 );
2261 assert!(app.agent_pids.is_empty());
2262
2263 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2264 }
2265
2266 #[test]
2267 fn with_persist_path_missing_file_is_ok() {
2268 let path = apps_persist_tmp_path("missing")
2269 .parent()
2270 .unwrap()
2271 .join("nope.json");
2272 let manager = AppManager::new().with_persist_path(&path);
2273 assert_eq!(manager.len(), 0);
2274
2275 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2276 }
2277}