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