Skip to main content

clawft_kernel/
app.rs

1//! Application framework for WeftOS.
2//!
3//! Applications are packaged units that declare their agents, tools,
4//! services, capabilities, and lifecycle hooks via a manifest file
5//! (`weftapp.toml` or `weftapp.json`). The kernel manages application
6//! installation, startup, shutdown, and removal.
7//!
8//! # Design
9//!
10//! All types compile unconditionally. The `AppManager` tracks installed
11//! applications and their lifecycle state. Actual filesystem operations
12//! (install from disk, hook execution) require the `native` feature
13//! and a running async runtime -- those integrations are future work.
14//!
15//! Agent IDs are namespaced as `app-name/agent-id` to avoid conflicts
16//! between apps and with built-in agents.
17
18use 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// ── Manifest Types ──────────────────────────────────────────────────
35
36/// Application manifest, parsed from `weftapp.toml` or `weftapp.json`.
37///
38/// Declares the agents, tools, services, capabilities, and lifecycle
39/// hooks for a WeftOS application.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AppManifest {
42    /// Application name (unique identifier).
43    pub name: String,
44
45    /// Semantic version string.
46    pub version: String,
47
48    /// Human-readable description.
49    #[serde(default)]
50    pub description: String,
51
52    /// Application author.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub author: Option<String>,
55
56    /// License identifier (SPDX).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub license: Option<String>,
59
60    /// Agent specifications.
61    #[serde(default)]
62    pub agents: Vec<AgentSpec>,
63
64    /// Tool specifications.
65    #[serde(default)]
66    pub tools: Vec<ToolSpec>,
67
68    /// Service specifications (containers, processes).
69    #[serde(default)]
70    pub services: Vec<ServiceSpec>,
71
72    /// Application-level capability requirements.
73    #[serde(default)]
74    pub capabilities: AppCapabilities,
75
76    /// Lifecycle hooks.
77    #[serde(default)]
78    pub hooks: AppHooks,
79}
80
81/// Specification for an agent within an application.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AgentSpec {
84    /// Agent identifier (scoped to the app: `app-name/id`).
85    pub id: String,
86
87    /// Agent role (e.g. "code-review", "report-generator").
88    #[serde(default)]
89    pub role: String,
90
91    /// Capabilities for this agent.
92    #[serde(default)]
93    pub capabilities: AgentCapabilities,
94
95    /// Whether to start this agent automatically when the app starts.
96    #[serde(default)]
97    pub auto_start: bool,
98}
99
100/// Specification for a tool provided by an application.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ToolSpec {
103    /// Tool name (scoped to the app: `app-name/name`).
104    pub name: String,
105
106    /// Where the tool implementation comes from.
107    pub source: ToolSource,
108
109    /// JSON Schema for the tool's input parameters.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub schema: Option<serde_json::Value>,
112}
113
114/// Source of a tool implementation.
115#[non_exhaustive]
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub enum ToolSource {
118    /// WASM module (path relative to app directory).
119    Wasm(String),
120    /// Built-in native tool (name).
121    Native(String),
122    /// Skill file (path relative to app directory).
123    Skill(String),
124}
125
126/// Specification for a sidecar service.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ServiceSpec {
129    /// Service name.
130    pub name: String,
131
132    /// Docker image (for container services).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub image: Option<String>,
135
136    /// Native command (for process services).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub command: Option<String>,
139
140    /// Port mappings.
141    #[serde(default)]
142    pub ports: Vec<PortMapping>,
143
144    /// Environment variables.
145    #[serde(default)]
146    pub env: HashMap<String, String>,
147
148    /// Health check endpoint URL.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub health_endpoint: Option<String>,
151}
152
153/// Application-level capability requirements.
154#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
155pub struct AppCapabilities {
156    /// Whether the app needs network access.
157    #[serde(default)]
158    pub network: bool,
159
160    /// Filesystem paths the app needs access to.
161    #[serde(default)]
162    pub filesystem: Vec<String>,
163
164    /// Whether the app needs shell access.
165    #[serde(default)]
166    pub shell: bool,
167
168    /// IPC scope for the app's agents.
169    #[serde(default)]
170    pub ipc: IpcScope,
171}
172
173/// Lifecycle hooks (scripts run at lifecycle transitions).
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct AppHooks {
176    /// Script to run after installation.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub on_install: Option<String>,
179
180    /// Script to run before starting agents.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub on_start: Option<String>,
183
184    /// Script to run after stopping agents.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub on_stop: Option<String>,
187
188    /// Script to run before removal.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub on_remove: Option<String>,
191}
192
193// ── Application Lifecycle ───────────────────────────────────────────
194
195/// Application lifecycle state.
196#[non_exhaustive]
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub enum AppState {
199    /// Installed but not started.
200    Installed,
201    /// Starting agents and services.
202    Starting,
203    /// All agents and services running.
204    Running,
205    /// Shutting down agents and services.
206    Stopping,
207    /// Stopped (can be restarted).
208    Stopped,
209    /// Failed with a reason.
210    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/// An installed application with its runtime state.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct InstalledApp {
229    /// Application manifest.
230    pub manifest: AppManifest,
231
232    /// Current lifecycle state.
233    pub state: AppState,
234
235    /// When the app was installed.
236    pub installed_at: DateTime<Utc>,
237
238    /// PIDs of agents spawned by this app (populated at start time).
239    #[serde(default)]
240    pub agent_pids: Vec<Pid>,
241
242    /// Names of services started by this app (populated at start time).
243    #[serde(default)]
244    pub service_names: Vec<String>,
245}
246
247// ── Errors ──────────────────────────────────────────────────────────
248
249/// Application framework errors.
250#[non_exhaustive]
251#[derive(Debug, thiserror::Error)]
252pub enum AppError {
253    /// Manifest file not found.
254    #[error("manifest not found at '{path}'")]
255    ManifestNotFound {
256        /// Path that was checked.
257        path: String,
258    },
259
260    /// Manifest parsing failed.
261    #[error("invalid manifest: {reason}")]
262    ManifestInvalid {
263        /// Why parsing failed.
264        reason: String,
265    },
266
267    /// App with this name already installed.
268    #[error("app already installed: '{name}'")]
269    AlreadyInstalled {
270        /// App name.
271        name: String,
272    },
273
274    /// App not found.
275    #[error("app not found: '{name}'")]
276    NotFound {
277        /// App name.
278        name: String,
279    },
280
281    /// Invalid state for the requested operation.
282    #[error("invalid state for app '{name}': expected {expected}, got {actual}")]
283    InvalidState {
284        /// App name.
285        name: String,
286        /// Expected state description.
287        expected: String,
288        /// Actual state.
289        actual: String,
290    },
291
292    /// Agent spawn failed.
293    #[error("failed to spawn agent '{agent_id}' for app '{app_name}': {reason}")]
294    SpawnFailed {
295        /// App name.
296        app_name: String,
297        /// Agent ID within the app.
298        agent_id: String,
299        /// Failure reason.
300        reason: String,
301    },
302
303    /// Governance gate denied the operation.
304    #[error("governance denied app operation '{action}': {reason}")]
305    GovernanceDenied {
306        /// The action that was denied.
307        action: String,
308        /// Reason for denial.
309        reason: String,
310    },
311
312    /// Hook execution failed.
313    #[error("hook '{hook}' failed for app '{app_name}': {reason}")]
314    HookFailed {
315        /// App name.
316        app_name: String,
317        /// Hook name (on_install, on_start, etc.).
318        hook: String,
319        /// Failure reason.
320        reason: String,
321    },
322}
323
324// ── Manifest Validation ─────────────────────────────────────────────
325
326/// Validate an application manifest for structural correctness.
327///
328/// Checks:
329/// - Name is non-empty and contains only valid characters
330/// - Version is non-empty
331/// - Agent IDs are unique within the app
332/// - Tool names are unique within the app
333/// - Service names are unique within the app
334/// - Tool sources are valid variants
335pub fn validate_manifest(manifest: &AppManifest) -> Result<(), AppError> {
336    // Name validation
337    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    // Version validation
357    if manifest.version.is_empty() {
358        return Err(AppError::ManifestInvalid {
359            reason: "version must not be empty".into(),
360        });
361    }
362
363    // Unique agent IDs
364    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    // Unique tool names
379    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    // Unique service names
394    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
411// ── On-disk manifest store ──────────────────────────────────────────
412
413// The store lives at `RuntimePaths::apps()` (`<runtime root>/apps.json`).
414
415fn default_apps_file_version() -> u32 {
416    1
417}
418
419/// On-disk format for persisted AppManager state.
420///
421/// Written atomically (tmp + rename) on every install/remove/state
422/// mutation when persistence is enabled. Rehydrated at boot via
423/// [`AppManager::with_persist_path`].
424#[derive(Debug, Clone, Serialize, Deserialize, Default)]
425pub struct AppsFile {
426    /// Schema version for forward compatibility.
427    #[serde(default = "default_apps_file_version")]
428    pub version: u32,
429    /// Persisted installed applications.
430    #[serde(default)]
431    pub apps: Vec<InstalledApp>,
432}
433
434// ── AppManager ──────────────────────────────────────────────────────
435
436/// Application lifecycle manager.
437///
438/// Tracks installed applications and their lifecycle state. Agent
439/// spawning and service starting are delegated to the supervisor and
440/// container manager respectively -- those integrations are wired in
441/// the kernel boot sequence.
442///
443/// When a persist path is configured (see [`Self::with_persist_path`]),
444/// installed apps survive kernel restarts via an atomic on-disk
445/// manifest store (same pattern as cluster peer membership).
446pub struct AppManager {
447    apps: DashMap<String, InstalledApp>,
448    /// Optional path for installed-app persistence.
449    ///
450    /// When set, mutations (install/remove/state-change) are reflected
451    /// to disk so the catalog survives kernel restarts. A `Mutex`
452    /// serialises writers; the lock is held only for the JSON encode +
453    /// atomic rename.
454    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    /// Create a new application manager.
463    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    /// Enable installed-app persistence at the given path (builder style).
475    ///
476    /// If the file exists and is readable, apps are rehydrated into the
477    /// in-memory map so installs survive kernel restarts. Parse errors
478    /// log a warning and leave the map empty. Subsequent mutations write
479    /// back to the same path via [`Self::persist`].
480    ///
481    /// Runtime handles (agent PIDs, service names) are cleared on
482    /// rehydrate, and transient lifecycle states (`Starting` /
483    /// `Running` / `Stopping`) are normalized to `Stopped` because
484    /// those processes do not survive a kernel restart.
485    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                            // Processes die with the kernel; drop runtime handles.
493                            app.agent_pids.clear();
494                            app.service_names.clear();
495                            // Normalize transient states so apps can be restarted.
496                            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    /// Snapshot installed apps to disk (no-op when persistence is disabled).
536    ///
537    /// Writes to a sibling `.tmp` file then renames for atomic replacement.
538    /// Errors are logged but not propagated — the in-memory catalog stays
539    /// correct, and the next successful mutation will re-sync the file.
540    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    /// Attach a chain manager for audit logging.
582    #[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    /// Attach a governance gate for policy enforcement.
591    #[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    /// Register an application from a parsed manifest.
600    ///
601    /// The app is placed in the `Installed` state. Call `transition_to`
602    /// to advance the state (e.g., to `Starting` or `Running`).
603    ///
604    /// # Errors
605    ///
606    /// Returns `AppError::AlreadyInstalled` if an app with the same
607    /// name is already registered.
608    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        // Governance gate — block install if policy denies it.
618        #[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        // Chain logging — record the install event.
648        #[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    /// Transition an app to a new state.
668    ///
669    /// Validates that the transition is legal per the state machine:
670    /// - Installed -> Starting
671    /// - Starting -> Running | Failed
672    /// - Running -> Stopping
673    /// - Stopping -> Stopped | Failed
674    /// - Stopped -> Starting
675    ///
676    /// # Errors
677    ///
678    /// Returns `AppError::NotFound` or `AppError::InvalidState`.
679    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); // release lock before persist (which snapshots the map)
708
709        // Chain logging — record state transition.
710        #[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    /// Record an agent PID for a running app.
728    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    /// Record a service name for a running app.
737    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    /// Remove an installed application.
746    ///
747    /// The app must be in `Installed`, `Stopped`, or `Failed` state.
748    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        // Governance gate — block removal if policy denies it.
767        #[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); // release the read lock before remove
784        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        // Chain logging — record the removal event.
791        #[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    /// List all installed applications.
808    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    /// Get details for an installed application.
822    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    /// Get the number of installed apps.
832    pub fn len(&self) -> usize {
833        self.apps.len()
834    }
835
836    /// Check whether any apps are installed.
837    pub fn is_empty(&self) -> bool {
838        self.apps.is_empty()
839    }
840
841    /// Get namespaced agent IDs for an app's manifest.
842    ///
843    /// Returns IDs in the form `app-name/agent-id`.
844    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    /// Get namespaced tool names for an app's manifest.
853    ///
854    /// Returns names in the form `app-name/tool-name`.
855    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    /// Start an installed or stopped application.
864    ///
865    /// Transitions the app through `Starting` to `Running` and builds
866    /// [`SpawnRequest`]s for each agent declared in the manifest. The
867    /// caller (kernel boot / CLI) is responsible for executing the spawn
868    /// requests via the [`AgentSupervisor`].
869    ///
870    /// Returns the list of spawn requests so the caller can hand them to
871    /// the supervisor.
872    ///
873    /// # Errors
874    ///
875    /// Returns `AppError::NotFound` if the app is not installed, or
876    /// `AppError::InvalidState` if the app is not in `Installed` or
877    /// `Stopped` state.
878    pub fn start(&self, name: &str) -> Result<Vec<SpawnRequest>, AppError> {
879        // Validate current state allows starting.
880        {
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        // Transition: current -> Starting
895        self.transition_to(name, AppState::Starting)?;
896
897        // Build spawn requests from manifest agent specs.
898        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        // Transition: Starting -> Running
906        self.transition_to(name, AppState::Running)?;
907
908        debug!(
909            app = name,
910            agents = spawn_requests.len(),
911            "application started"
912        );
913
914        // Chain logging — record the start event.
915        #[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    /// Stop a running application.
931    ///
932    /// Transitions `Running` -> `Stopping` -> `Stopped` and clears the
933    /// recorded agent PIDs and service names.
934    ///
935    /// # Errors
936    ///
937    /// Returns `AppError::NotFound` or `AppError::InvalidState`.
938    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        // Clear runtime bookkeeping.
955        {
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        // Chain logging — record the stop event.
968        #[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    /// Build [`SpawnRequest`]s for every agent declared in a manifest.
983    ///
984    /// Each request carries the agent's capabilities from the manifest
985    /// and a namespaced agent ID (`app-name/agent-id`).
986    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
1007// ── Manifest Parsing ────────────────────────────────────────────────
1008
1009impl AppManifest {
1010    /// Parse an [`AppManifest`] from a JSON string.
1011    ///
1012    /// The manifest is validated after parsing; structural errors
1013    /// (empty name, duplicate IDs, etc.) are returned as
1014    /// [`AppError::ManifestInvalid`].
1015    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    /// Parse an [`AppManifest`] from a `weftapp.toml` string.
1025    ///
1026    /// Same validation as [`Self::from_json_str`]; parse errors are
1027    /// returned as [`AppError::ManifestInvalid`].
1028    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(), // duplicate
1169            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(), // duplicate
1182            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(), // duplicate
1194            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        // Installed -> Starting -> Running -> Stopping -> Stopped
1259        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        // Restart: Stopped -> Starting
1294        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        // Installed -> Running (should fail, must go through Starting)
1305        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        // Serde roundtrip
1413        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    // ── K5 Integration Tests ────────────────────────────────────────
1508
1509    #[test]
1510    fn integration_app_full_lifecycle() {
1511        // Build a realistic app manifest
1512        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        // 1. Validate
1570        validate_manifest(&manifest).unwrap();
1571
1572        // 2. Verify namespacing
1573        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        // 3. Install
1582        let manager = AppManager::new();
1583        let app_name = manager.install(manifest.clone()).unwrap();
1584        assert_eq!(app_name, "data-pipeline");
1585
1586        // 4. List shows the app
1587        let list = manager.list();
1588        assert_eq!(list.len(), 1);
1589        assert_eq!(list[0].0, "data-pipeline");
1590
1591        // 5. Inspect preserves all details
1592        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        // 6. Lifecycle transitions: Installed -> Starting -> Running
1625        manager
1626            .transition_to("data-pipeline", AppState::Starting)
1627            .unwrap();
1628        manager
1629            .transition_to("data-pipeline", AppState::Running)
1630            .unwrap();
1631
1632        // 7. Simulate agent spawning (supervisor assigns PIDs)
1633        manager.add_agent_pid("data-pipeline", 10).unwrap(); // ingester
1634        manager.add_agent_pid("data-pipeline", 11).unwrap(); // analyzer
1635
1636        // 8. Simulate service registration
1637        manager
1638            .add_service_name("data-pipeline", "cache".into())
1639            .unwrap();
1640
1641        // 9. Verify running state with agents and services
1642        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        // 10. Stop: Running -> Stopping -> Stopped
1651        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        // 11. Remove
1662        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        // Install two apps with overlapping agent/tool names
1672        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(), // same agent ID as frontend
1707                role: "api".into(),
1708                capabilities: AgentCapabilities::default(),
1709                auto_start: true,
1710            }],
1711            tools: vec![ToolSpec {
1712                name: "render".into(), // same tool name as frontend
1713                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        // Both apps installed
1741        assert_eq!(manager.list().len(), 2);
1742
1743        // Namespaces prevent conflicts despite identical agent/tool names
1744        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        // Each app transitions independently
1759        manager
1760            .transition_to("frontend", AppState::Starting)
1761            .unwrap();
1762        manager
1763            .transition_to("frontend", AppState::Running)
1764            .unwrap();
1765        // backend stays Installed
1766
1767        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        // Backend can also transition without affecting frontend
1773        manager
1774            .transition_to("backend", AppState::Starting)
1775            .unwrap();
1776        manager.transition_to("backend", AppState::Running).unwrap();
1777
1778        // Add PIDs to each app independently
1779        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        // Stop frontend, backend stays running
1788        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    // ── K5 Gate Tests ──────────────────────────────────────────────
1827
1828    #[test]
1829    fn k5_manifest_parsed_and_validated() {
1830        // Programmatic manifest creation and validation.
1831        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        // Also verify JSON parsing path.
1864        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        // Install
1904        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        // Start -- returns spawn requests for both agents
1910        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        // Stop
1919        mgr.stop(&app_id).unwrap();
1920        let app = mgr.inspect(&app_id).unwrap();
1921        assert_eq!(app.state, AppState::Stopped);
1922
1923        // Restart after stop
1924        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        // First agent: networker with network access
1976        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        // Second agent: sandboxed with no capabilities
1984        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        // Empty name
2037        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        // Empty version
2052        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        // Invalid name chars
2067        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        // No valid apps were installed
2082        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        // Already running -- start again should fail
2104        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        // Not running -- stop should fail
2126        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        // Simulate supervisor assigning PIDs after start.
2154        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        // Stop clears runtime bookkeeping.
2161        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    // ── Persistence (WEFT-136) ──────────────────────────────────────
2168
2169    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        // Simulate restart: new AppManager rehydrates from disk.
2197        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        // Sibling .tmp must not remain after a successful atomic rename.
2215        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        // Rehydrate: Running -> Stopped; runtime handles discarded.
2254        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}