Skip to main content

weft_core/package/
mod.rs

1pub mod bridge;
2pub mod circuit_breaker;
3pub mod config;
4pub mod fallback;
5pub mod native;
6pub mod permissions;
7pub mod validate;
8
9use crate::config::ServiceConfig;
10use anyhow::{bail, Context, Result};
11use bridge::WasmStartupMode;
12use config::{load_manifest, PackageManifest};
13pub use native::{
14    native_library_candidates, NativeHandle, NativePackageHost, NativePackageLoadInfo,
15};
16use std::cmp::Ordering;
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20/// Metadata for a loaded package (exposed via API).
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct PackageInfo {
23    pub name: String,
24    pub version: Option<String>,
25    #[serde(default)]
26    pub overrides: Vec<String>,
27    #[serde(default = "default_enabled")]
28    pub enabled: bool,
29    #[serde(default)]
30    pub has_ui: bool,
31    #[serde(default)]
32    pub description: Option<String>,
33}
34
35fn default_enabled() -> bool {
36    true
37}
38
39fn normalize_existing_path(path: &Path) -> PathBuf {
40    if let Ok(canonical) = std::fs::canonicalize(path) {
41        return canonical;
42    }
43
44    if path.is_absolute() {
45        return path.to_path_buf();
46    }
47
48    std::env::current_dir()
49        .map(|cwd| cwd.join(path))
50        .unwrap_or_else(|_| path.to_path_buf())
51}
52
53/// Manages package registration and listing.
54pub struct PackageManager {
55    packages: HashMap<String, PackageInfo>,
56}
57
58impl Default for PackageManager {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl PackageManager {
65    pub fn new() -> Self {
66        Self {
67            packages: HashMap::new(),
68        }
69    }
70
71    /// Register a plugin's metadata (for listing).
72    pub fn register(&mut self, info: PackageInfo) {
73        self.packages.insert(info.name.clone(), info);
74    }
75
76    /// List all registered plugins.
77    pub fn list(&self) -> Vec<&PackageInfo> {
78        self.packages.values().collect()
79    }
80
81    /// Unregister a package by name.
82    pub fn unregister(&mut self, name: &str) -> Option<PackageInfo> {
83        self.packages.remove(name)
84    }
85
86    /// Get a package by name.
87    pub fn get(&self, name: &str) -> Option<&PackageInfo> {
88        self.packages.get(name)
89    }
90
91    /// Get a mutable reference to a package by name.
92    pub fn get_mut(&mut self, name: &str) -> Option<&mut PackageInfo> {
93        self.packages.get_mut(name)
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum PackageRuntime {
99    Wasm,
100    Native,
101    Service,
102    Remote,
103    Unknown(String),
104}
105
106impl PackageRuntime {
107    pub fn from_manifest(manifest: &PackageManifest) -> Self {
108        match manifest.runtime_kind().as_str() {
109            "wasm" => Self::Wasm,
110            "native" => Self::Native,
111            "service" => Self::Service,
112            "remote" => Self::Remote,
113            other => Self::Unknown(other.to_string()),
114        }
115    }
116
117    pub fn as_str(&self) -> &str {
118        match self {
119            Self::Wasm => "wasm",
120            Self::Native => "native",
121            Self::Service => "service",
122            Self::Remote => "remote",
123            Self::Unknown(value) => value.as_str(),
124        }
125    }
126}
127
128#[derive(Debug, Clone)]
129pub struct DiscoveredPackage {
130    pub manifest: PackageManifest,
131    pub dir: PathBuf,
132    pub entry_path: Option<PathBuf>,
133    pub runtime: PackageRuntime,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
137pub enum PackageSourcePrecedence {
138    Official,
139    Installed,
140    Other,
141}
142
143impl PackageSourcePrecedence {
144    pub fn as_str(&self) -> &'static str {
145        match self {
146            Self::Installed => "installed",
147            Self::Official => "official",
148            Self::Other => "other",
149        }
150    }
151}
152
153pub fn packages_dir(repo_root: &Path) -> PathBuf {
154    repo_root.join("packages")
155}
156
157pub fn is_managed_runtime_root(repo_root: &Path) -> bool {
158    if std::env::var("WEFT_CORE_MANAGED")
159        .map(|value| value == "1")
160        .unwrap_or(false)
161    {
162        return true;
163    }
164
165    let config_path = repo_root.join("config").join("config.toml");
166    let installed_dir = repo_root.join("plugins").join("installed");
167    let suite_state_dir = repo_root.join("suite-state");
168
169    config_path.is_file() && (installed_dir.is_dir() || suite_state_dir.is_dir())
170}
171
172pub fn installed_packages_dir(repo_root: &Path) -> PathBuf {
173    if is_managed_runtime_root(repo_root) {
174        repo_root.join("plugins").join("installed")
175    } else {
176        packages_dir(repo_root).join("installed")
177    }
178}
179
180pub fn official_packages_dir(repo_root: &Path) -> PathBuf {
181    if is_managed_runtime_root(repo_root) {
182        repo_root.join("plugins").join("official")
183    } else {
184        packages_dir(repo_root).join("official")
185    }
186}
187
188pub fn package_source_precedence(repo_root: &Path, package_dir: &Path) -> PackageSourcePrecedence {
189    let normalized_dir = normalize_existing_path(package_dir);
190    let official_dir = normalize_existing_path(&official_packages_dir(repo_root));
191    let installed_dir = normalize_existing_path(&installed_packages_dir(repo_root));
192
193    if normalized_dir.starts_with(&official_dir) {
194        PackageSourcePrecedence::Official
195    } else if normalized_dir.starts_with(&installed_dir) {
196        PackageSourcePrecedence::Installed
197    } else {
198        PackageSourcePrecedence::Other
199    }
200}
201
202pub fn runtime_package_aliases(
203    package_index: &crate::app::PackageIndex,
204) -> HashMap<String, String> {
205    package_index
206        .package_sources
207        .iter()
208        .filter_map(|source| {
209            let runtime_provider = source.runtime_provider_name();
210            let package_name = source.name.trim();
211            if runtime_provider.is_empty()
212                || package_name.is_empty()
213                || runtime_provider == package_name
214            {
215                None
216            } else {
217                Some((runtime_provider, package_name.to_string()))
218            }
219        })
220        .collect()
221}
222
223pub fn merged_package_aliases(
224    package_index: &crate::app::PackageIndex,
225    configured_aliases: &HashMap<String, String>,
226) -> HashMap<String, String> {
227    let mut aliases = runtime_package_aliases(package_index);
228    aliases.extend(configured_aliases.clone());
229    aliases
230}
231
232fn compare_package_precedence(
233    repo_root: &Path,
234    left: &DiscoveredPackage,
235    right: &DiscoveredPackage,
236) -> Ordering {
237    package_source_precedence(repo_root, &left.dir)
238        .cmp(&package_source_precedence(repo_root, &right.dir))
239        .then_with(|| left.dir.cmp(&right.dir))
240}
241
242fn runtime_package_identity(
243    repo_root: &Path,
244    package_index: &crate::app::PackageIndex,
245    package: &DiscoveredPackage,
246) -> String {
247    let normalized_dir = normalize_existing_path(&package.dir);
248    let manifest_name = package.manifest.package_info.name.trim();
249
250    for source in &package_index.package_sources {
251        let package_name = source.name.trim();
252        if package_name.is_empty() {
253            continue;
254        }
255
256        let current_source = source.current_source.trim();
257        if !current_source.is_empty()
258            && normalized_dir == normalize_existing_path(&repo_root.join(current_source))
259        {
260            return package_name.to_string();
261        }
262
263        if manifest_name == package_name || manifest_name == source.runtime_provider_name() {
264            return package_name.to_string();
265        }
266    }
267
268    manifest_name.to_string()
269}
270
271fn is_declared_current_runtime_source(
272    repo_root: &Path,
273    package_index: &crate::app::PackageIndex,
274    package: &DiscoveredPackage,
275) -> bool {
276    let identity = runtime_package_identity(repo_root, package_index, package);
277    let normalized_dir = normalize_existing_path(&package.dir);
278
279    package_index
280        .get(&identity)
281        .map(|source| {
282            normalize_existing_path(&repo_root.join(&source.current_source)) == normalized_dir
283        })
284        .unwrap_or(false)
285}
286
287fn compare_runtime_plugin_package_precedence(
288    repo_root: &Path,
289    package_index: &crate::app::PackageIndex,
290    left: &DiscoveredPackage,
291    right: &DiscoveredPackage,
292) -> Ordering {
293    let left_is_current = is_declared_current_runtime_source(repo_root, package_index, left);
294    let right_is_current = is_declared_current_runtime_source(repo_root, package_index, right);
295
296    right_is_current
297        .cmp(&left_is_current)
298        .then_with(|| compare_package_precedence(repo_root, left, right))
299}
300
301pub fn select_runtime_packages(
302    repo_root: &Path,
303    package_index: &crate::app::PackageIndex,
304    mut packages: Vec<DiscoveredPackage>,
305) -> Vec<DiscoveredPackage> {
306    packages.sort_by(|left, right| {
307        runtime_package_identity(repo_root, package_index, left)
308            .cmp(&runtime_package_identity(repo_root, package_index, right))
309            .then_with(|| {
310                compare_runtime_plugin_package_precedence(repo_root, package_index, left, right)
311            })
312    });
313
314    let mut selected = Vec::new();
315
316    for package in packages {
317        let package_identity = runtime_package_identity(repo_root, package_index, &package);
318        let duplicate = selected.iter().position(|existing: &DiscoveredPackage| {
319            runtime_package_identity(repo_root, package_index, existing) == package_identity
320        });
321
322        if let Some(existing_index) = duplicate {
323            let existing = &selected[existing_index];
324            let comparison = compare_runtime_plugin_package_precedence(
325                repo_root,
326                package_index,
327                &package,
328                existing,
329            );
330            if comparison == Ordering::Less {
331                tracing::info!(
332                    "Runtime package '{}' selected manifest '{}' from {} at {} (replacing manifest '{}' from {} at {})",
333                    package_identity,
334                    package.manifest.package_info.name,
335                    package_source_precedence(repo_root, &package.dir).as_str(),
336                    package.dir.display(),
337                    existing.manifest.package_info.name,
338                    package_source_precedence(repo_root, &existing.dir).as_str(),
339                    existing.dir.display()
340                );
341                selected[existing_index] = package;
342            } else if existing.dir != package.dir {
343                tracing::info!(
344                    "Runtime package '{}' ignored manifest '{}' from {} at {}; keeping manifest '{}' from {} at {}",
345                    package_identity,
346                    package.manifest.package_info.name,
347                    package_source_precedence(repo_root, &package.dir).as_str(),
348                    package.dir.display(),
349                    existing.manifest.package_info.name,
350                    package_source_precedence(repo_root, &existing.dir).as_str(),
351                    existing.dir.display()
352                );
353            }
354            continue;
355        }
356
357        selected.push(package);
358    }
359
360    selected
361}
362
363pub fn discover_runtime_packages(
364    repo_root: &Path,
365    package_index: &crate::app::PackageIndex,
366) -> Vec<DiscoveredPackage> {
367    let installed_dir = installed_packages_dir(repo_root);
368    let official_dir = official_packages_dir(repo_root);
369
370    let mut discovered_packages = discover_packages(&installed_dir);
371    discovered_packages.extend(discover_packages(&official_dir));
372
373    for source in &package_index.package_sources {
374        let package_dir = repo_root.join(&source.current_source);
375        if let Some(package) = discover_package(&package_dir) {
376            discovered_packages.push(package);
377        }
378    }
379
380    select_runtime_packages(repo_root, package_index, discovered_packages)
381}
382
383pub fn discover_runtime_package(
384    repo_root: &Path,
385    package_index: &crate::app::PackageIndex,
386    name: &str,
387) -> Option<DiscoveredPackage> {
388    let canonical_name = canonical_runtime_package_name(package_index, name);
389
390    discover_runtime_packages(repo_root, package_index)
391        .into_iter()
392        .find(|package| {
393            package.manifest.package_info.name == canonical_name
394                || package.manifest.package_info.name == name
395        })
396}
397
398pub fn canonical_runtime_package_name(
399    package_index: &crate::app::PackageIndex,
400    requested_name: &str,
401) -> String {
402    let requested_name = requested_name.trim();
403    if requested_name.is_empty() {
404        return String::new();
405    }
406
407    package_index
408        .get(requested_name)
409        .map(|source| source.name.trim())
410        .filter(|name| !name.is_empty())
411        .unwrap_or(requested_name)
412        .to_string()
413}
414
415pub fn resolve_runtime_package(
416    repo_root: &Path,
417    package_index: &crate::app::PackageIndex,
418    requested_name: &str,
419) -> Option<DiscoveredPackage> {
420    let canonical_name = canonical_runtime_package_name(package_index, requested_name);
421
422    if let Some(source) = package_index.get(&canonical_name) {
423        let package_dir = repo_root.join(&source.current_source);
424        if let Some(package) = discover_package(&package_dir) {
425            return Some(package);
426        }
427    }
428
429    discover_runtime_package(repo_root, package_index, &canonical_name)
430}
431
432/// A missing dependency descriptor.
433#[derive(Debug, Clone, serde::Serialize)]
434pub struct MissingDep {
435    pub name: String,
436    pub required_version: String,
437}
438
439/// Check if all dependencies of a manifest are satisfied by installed plugins.
440pub fn check_dependencies(
441    manifest: &PackageManifest,
442    installed: &[PackageInfo],
443) -> Vec<MissingDep> {
444    let installed_names: std::collections::HashSet<&str> =
445        installed.iter().map(|p| p.name.as_str()).collect();
446
447    manifest
448        .dependencies
449        .iter()
450        .filter(|(name, _)| !installed_names.contains(name.as_str()))
451        .map(|(name, version)| MissingDep {
452            name: name.clone(),
453            required_version: version.clone(),
454        })
455        .collect()
456}
457
458/// Check if any other package depends on the given package name.
459/// Returns the names of plugins that depend on it.
460pub fn check_uninstall_safety(name: &str, plugins_dir: &Path) -> Vec<String> {
461    let manifests = discover_manifests(plugins_dir);
462    manifests
463        .iter()
464        .filter(|m| m.package_info.name != name && m.dependencies.contains_key(name))
465        .map(|m| m.package_info.name.clone())
466        .collect()
467}
468
469fn resolve_entry_path(package_dir: &Path, manifest: &PackageManifest) -> Option<PathBuf> {
470    let declared_entry = manifest
471        .resolved_entry()
472        .map(|entry| normalize_existing_path(&package_dir.join(entry)));
473
474    if let Some(path) = declared_entry.as_ref().filter(|path| path.exists()) {
475        return Some(path.clone());
476    }
477
478    if manifest.runtime_kind() == "wasm" {
479        if let Some(path) = locate_built_wasm(package_dir) {
480            return Some(path);
481        }
482
483        if package_dir.join("Cargo.toml").exists() {
484            if let Err(error) =
485                build_wasm_package_artifact(package_dir, &manifest.package_info.name)
486            {
487                tracing::warn!(
488                    "Package '{}': failed to build missing wasm artifact: {:#}",
489                    manifest.package_info.name,
490                    error
491                );
492            } else if let Some(path) = locate_built_wasm(package_dir) {
493                return Some(path);
494            }
495        }
496    }
497
498    declared_entry
499}
500
501fn locate_built_wasm(package_dir: &Path) -> Option<PathBuf> {
502    ["debug", "release"].into_iter().find_map(|profile| {
503        let target_dir = package_dir
504            .join("target")
505            .join("wasm32-wasip1")
506            .join(profile);
507        let entries = std::fs::read_dir(&target_dir).ok()?;
508        let mut wasm_files = entries
509            .flatten()
510            .map(|entry| entry.path())
511            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("wasm"))
512            .collect::<Vec<_>>();
513        wasm_files.sort();
514        wasm_files.into_iter().next()
515    })
516}
517
518fn build_wasm_package_artifact(package_dir: &Path, package_name: &str) -> Result<()> {
519    let output = std::process::Command::new("cargo")
520        .arg("build")
521        .arg("--target")
522        .arg("wasm32-wasip1")
523        .current_dir(package_dir)
524        .output()
525        .with_context(|| format!("failed to spawn cargo build for '{}'", package_name))?;
526
527    if output.status.success() {
528        tracing::info!(
529            "Package '{}': built missing wasm artifact from {}",
530            package_name,
531            package_dir.display()
532        );
533        return Ok(());
534    }
535
536    let stderr = String::from_utf8_lossy(&output.stderr);
537    let stdout = String::from_utf8_lossy(&output.stdout);
538    bail!(
539        "cargo build --target wasm32-wasip1 failed in '{}': {}{}{}",
540        package_dir.display(),
541        stderr.trim(),
542        if stderr.trim().is_empty() || stdout.trim().is_empty() {
543            ""
544        } else {
545            " | stdout: "
546        },
547        if stderr.trim().is_empty() {
548            stdout.trim()
549        } else {
550            ""
551        }
552    )
553}
554
555pub fn discover_package(package_dir: &Path) -> Option<DiscoveredPackage> {
556    let path = normalize_existing_path(package_dir);
557    if !path.is_dir() {
558        return None;
559    }
560
561    let manifest_path = path.join("package.toml");
562    if !manifest_path.exists() {
563        return None;
564    }
565
566    match load_manifest(&path) {
567        Ok(manifest) => {
568            let runtime = PackageRuntime::from_manifest(&manifest);
569            let entry_path = resolve_entry_path(&path, &manifest);
570
571            // B1: surface manifest inconsistencies as explicit findings.
572            // load_manifest is lenient (empty name, kind/runtime mismatch still
573            // parse); validate_manifest turns that into severity-rated warnings.
574            let entry_exists = entry_path.as_ref().map(|p| p.exists());
575            for issue in validate::validate_manifest(&manifest, entry_exists) {
576                match issue.severity {
577                    validate::Severity::Error => tracing::warn!(
578                        "manifest validation [{}] at {}: {}",
579                        issue.code,
580                        path.display(),
581                        issue.message
582                    ),
583                    validate::Severity::Warning => tracing::debug!(
584                        "manifest validation [{}] at {}: {}",
585                        issue.code,
586                        path.display(),
587                        issue.message
588                    ),
589                }
590            }
591
592            let requires_local_entry = matches!(
593                runtime,
594                PackageRuntime::Wasm | PackageRuntime::Service
595            );
596            if requires_local_entry {
597                match entry_path.as_ref() {
598                    Some(candidate) if candidate.exists() => {}
599                    Some(candidate) => {
600                        tracing::warn!(
601                            "Package '{}': entry file '{}' not found, skipping",
602                            manifest.package_info.name,
603                            candidate.display()
604                        );
605                        return None;
606                    }
607                    None => {
608                        tracing::warn!(
609                            "Package '{}': no entry declared for runtime '{}', skipping",
610                            manifest.package_info.name,
611                            runtime.as_str()
612                        );
613                        return None;
614                    }
615                }
616            }
617
618            tracing::info!(
619                "Discovered package '{}' v{} at {} (runtime={})",
620                manifest.package_info.name,
621                manifest.package_info.version,
622                path.display(),
623                runtime.as_str()
624            );
625            Some(DiscoveredPackage {
626                manifest,
627                dir: path,
628                entry_path,
629                runtime,
630            })
631        }
632        Err(e) => {
633            tracing::warn!("Failed to load package at {}: {}", path.display(), e);
634            None
635        }
636    }
637}
638
639pub fn discover_packages(plugins_dir: &Path) -> Vec<DiscoveredPackage> {
640    let mut packages = Vec::new();
641
642    let entries = match std::fs::read_dir(plugins_dir) {
643        Ok(e) => e,
644        Err(e) => {
645            tracing::warn!(
646                "Cannot read plugins directory {}: {}",
647                plugins_dir.display(),
648                e
649            );
650            return packages;
651        }
652    };
653
654    for entry in entries.flatten() {
655        if let Some(package) = discover_package(&entry.path()) {
656            packages.push(package);
657        }
658    }
659
660    packages
661}
662
663pub fn discover_manifests(plugins_dir: &Path) -> Vec<PackageManifest> {
664    discover_packages(plugins_dir)
665        .into_iter()
666        .map(|package| package.manifest)
667        .collect()
668}
669
670fn resolve_powershell_command() -> String {
671    // Windows: 优先找 pwsh.exe 真实路径(绕过 scoop .cmd shim,避免弹 cmd 窗口)。
672    #[cfg(windows)]
673    {
674        use std::os::windows::process::CommandExt;
675        // 常见 pwsh 真实路径
676        let candidates = [
677            r"C:\Program Files\PowerShell\7\pwsh.exe",
678            r"C:\Program Files (x86)\PowerShell\7\pwsh.exe",
679        ];
680        for candidate in &candidates {
681            if std::path::Path::new(candidate).exists() {
682                return candidate.to_string();
683            }
684        }
685        // 用 where.exe 找真实路径(不弹窗)
686        let mut cmd = std::process::Command::new("where.exe");
687        cmd.arg("pwsh.exe");
688        cmd.creation_flags(0x08000000);
689        if let Ok(output) = cmd.output() {
690            if output.status.success() {
691                let path = String::from_utf8_lossy(&output.stdout);
692                if let Some(line) = path.lines().find(|l| l.ends_with(".exe")) {
693                    return line.trim().to_string();
694                }
695            }
696        }
697    }
698    #[cfg(not(windows))]
699    {
700        let mut cmd = std::process::Command::new("pwsh");
701        cmd.arg("-NoProfile").arg("-Command").arg("echo ok");
702        if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
703            return "pwsh".into();
704        }
705    }
706    "powershell".into()
707}
708
709fn resolve_service_command(entry_path: &Path) -> (String, Vec<String>) {
710    let mut entry = entry_path.to_string_lossy().to_string();
711    if cfg!(windows) {
712        if let Some(stripped) = entry.strip_prefix(r#"\\?\UNC\"#) {
713            entry = format!(r#"\\{}"#, stripped);
714        } else if let Some(stripped) = entry.strip_prefix(r#"\\?\"#) {
715            entry = stripped.to_string();
716        }
717    }
718
719    let extension = entry_path
720        .extension()
721        .and_then(|ext| ext.to_str())
722        .unwrap_or("")
723        .to_ascii_lowercase();
724
725    match extension.as_str() {
726        "js" | "mjs" | "cjs" => ("node".into(), vec![entry]),
727        "py" => ("python".into(), vec![entry]),
728        "ps1" => (
729            resolve_powershell_command(),
730            vec![
731                "-NoProfile".into(),
732                "-ExecutionPolicy".into(),
733                "Bypass".into(),
734                "-File".into(),
735                entry,
736            ],
737        ),
738        _ => (entry, vec![]),
739    }
740}
741
742pub fn build_service_config(package: &DiscoveredPackage) -> Result<ServiceConfig> {
743    if package.runtime != PackageRuntime::Service {
744        bail!(
745            "Package '{}' is runtime '{}', not a service runtime",
746            package.manifest.package_info.name,
747            package.runtime.as_str()
748        );
749    }
750
751    let entry_path = package.entry_path.as_ref().ok_or_else(|| {
752        anyhow::anyhow!(
753            "Service package '{}' has no entry",
754            package.manifest.package_info.name
755        )
756    })?;
757    let absolute_entry_path =
758        std::fs::canonicalize(entry_path).unwrap_or_else(|_| entry_path.clone());
759
760    let (command, args) = resolve_service_command(&absolute_entry_path);
761
762    let startup_mode = package
763        .manifest
764        .runtime_contract
765        .as_ref()
766        .and_then(|contract| contract.startup_mode.as_deref())
767        .unwrap_or("on_demand");
768    let restart_policy = package
769        .manifest
770        .runtime_contract
771        .as_ref()
772        .and_then(|contract| contract.restart_policy.as_deref())
773        .unwrap_or("host_managed");
774
775    let mut env = HashMap::new();
776    env.insert(
777        "WEFT_PACKAGE_NAME".into(),
778        package.manifest.package_info.name.clone(),
779    );
780    env.insert(
781        "WEFT_PACKAGE_DIR".into(),
782        package.dir.to_string_lossy().to_string(),
783    );
784    env.insert("WEFT_PACKAGE_RUNTIME".into(), "service".into());
785
786    Ok(ServiceConfig {
787        name: package.manifest.package_info.name.clone(),
788        command,
789        args,
790        workdir: Some(package.dir.to_string_lossy().to_string()),
791        env,
792        health_url: package.manifest.resolved_healthcheck(),
793        health_interval: 10,
794        auto_start: startup_mode == "persistent",
795        restart_on_crash: !matches!(restart_policy, "never" | "manual"),
796    })
797}
798
799pub fn resolve_wasm_startup_mode(manifest: &PackageManifest) -> WasmStartupMode {
800    match manifest
801        .runtime_contract
802        .as_ref()
803        .and_then(|contract| contract.startup_mode.as_deref())
804        .map(str::trim)
805    {
806        Some("on_demand") => WasmStartupMode::OnDemand,
807        _ => WasmStartupMode::Persistent,
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use std::fs;
815    use tempfile::TempDir;
816
817    #[test]
818    fn runtime_plugin_aliases_maps_runtime_provider_to_package_name() {
819        let package_index = crate::app::PackageIndex {
820            version: 1,
821            revision: "test".into(),
822            source_url: "local://packages".into(),
823            package_sources: vec![
824                crate::app::PackageSource {
825                    name: "agent-runtime".into(),
826                    kind: "wasm".into(),
827                    package_kind: "foundation".into(),
828                    runtime_provider: "agent-core".into(),
829                    current_source: "packages/official/agent-core".into(),
830                    trusted: true,
831                    signature: "builtin:official".into(),
832                    source_authority: "official".into(),
833                    source_public_keys: vec![],
834                    provides: vec!["agent.runtime".into()],
835                    requires: vec![],
836                },
837                crate::app::PackageSource {
838                    name: "skills-runtime".into(),
839                    kind: "wasm".into(),
840                    package_kind: "provider".into(),
841                    runtime_provider: "skills".into(),
842                    current_source: "packages/official/skills".into(),
843                    trusted: true,
844                    signature: "builtin:official".into(),
845                    source_authority: "official".into(),
846                    source_public_keys: vec![],
847                    provides: vec!["ext.skills".into()],
848                    requires: vec![],
849                },
850            ],
851        };
852
853        let aliases = runtime_package_aliases(&package_index);
854
855        assert_eq!(
856            aliases.get("agent-core").map(String::as_str),
857            Some("agent-runtime")
858        );
859        assert_eq!(
860            aliases.get("skills").map(String::as_str),
861            Some("skills-runtime")
862        );
863    }
864
865    #[test]
866    fn canonical_runtime_package_name_prefers_package_index_name() {
867        let package_index = crate::app::PackageIndex {
868            version: 1,
869            revision: "test".into(),
870            source_url: "local://packages".into(),
871            package_sources: vec![crate::app::PackageSource {
872                name: "agent-runtime".into(),
873                kind: "wasm".into(),
874                package_kind: "foundation".into(),
875                runtime_provider: "agent-core".into(),
876                current_source: "packages/official/agent-core".into(),
877                trusted: true,
878                signature: "builtin:official".into(),
879                source_authority: "official".into(),
880                source_public_keys: vec![],
881                provides: vec!["agent.runtime".into()],
882                requires: vec![],
883            }],
884        };
885
886        assert_eq!(
887            canonical_runtime_package_name(&package_index, "agent-core"),
888            "agent-runtime"
889        );
890        assert_eq!(
891            canonical_runtime_package_name(&package_index, "agent-runtime"),
892            "agent-runtime"
893        );
894        assert_eq!(
895            canonical_runtime_package_name(&package_index, "missing-package"),
896            "missing-package"
897        );
898    }
899
900    #[test]
901    fn test_discover_plugins() {
902        let dir = TempDir::new().unwrap();
903
904        // Create a valid plugin
905        let package_dir = dir.path().join("test-package");
906        fs::create_dir_all(&package_dir).unwrap();
907        fs::write(
908            package_dir.join("package.toml"),
909            r#"
910[package_info]
911name = "test-package"
912version = "0.1.0"
913description = "Test"
914entry = "test.wasm"
915"#,
916        )
917        .unwrap();
918        fs::write(package_dir.join("test.wasm"), b"fake wasm").unwrap();
919
920        // Create an invalid dir (no package.toml)
921        let invalid_dir = dir.path().join("not-a-plugin");
922        fs::create_dir_all(&invalid_dir).unwrap();
923
924        let discovered = discover_manifests(dir.path());
925        assert_eq!(discovered.len(), 1);
926        assert_eq!(discovered[0].package_info.name, "test-package");
927    }
928
929    #[test]
930    fn test_discover_skips_missing_entry() {
931        let dir = TempDir::new().unwrap();
932
933        let package_dir = dir.path().join("broken-package");
934        fs::create_dir_all(&package_dir).unwrap();
935        fs::write(
936            package_dir.join("package.toml"),
937            r#"
938[package_info]
939name = "broken"
940version = "0.1.0"
941description = "Missing entry"
942"#,
943        )
944        .unwrap();
945
946        let discovered = discover_manifests(dir.path());
947        assert_eq!(discovered.len(), 0);
948    }
949
950    #[test]
951    fn test_discover_uses_built_wasm_artifact_when_entry_is_not_materialized() {
952        let dir = TempDir::new().unwrap();
953
954        let package_dir = dir.path().join("source-only-package");
955        fs::create_dir_all(package_dir.join("target/wasm32-wasip1/release")).unwrap();
956        fs::write(
957            package_dir.join("package.toml"),
958            r#"
959[package_info]
960name = "source-only"
961version = "0.1.0"
962description = "Built wasm lives under target"
963"#,
964        )
965        .unwrap();
966
967        let built_wasm = package_dir
968            .join("target")
969            .join("wasm32-wasip1")
970            .join("release")
971            .join("plugin_source_only.wasm");
972        fs::write(&built_wasm, b"fake wasm").unwrap();
973
974        let package = discover_package(&package_dir).expect("package should discover built wasm");
975        assert_eq!(package.manifest.package_info.name, "source-only");
976        assert_eq!(
977            package
978                .entry_path
979                .as_ref()
980                .map(|path| normalize_existing_path(path)),
981            Some(normalize_existing_path(&built_wasm))
982        );
983    }
984
985    #[test]
986    fn test_discover_empty_dir() {
987        let dir = TempDir::new().unwrap();
988        let discovered = discover_manifests(dir.path());
989        assert_eq!(discovered.len(), 0);
990    }
991
992    #[test]
993    fn resolve_runtime_plugin_package_prefers_declared_current_source_over_legacy_alias() {
994        let repo = TempDir::new().unwrap();
995        let packages_dir = repo.path().join("packages");
996        let official_dir = packages_dir.join("official").join("agent-core");
997        let installed_dir = packages_dir.join("installed").join("agent-core");
998
999        fs::create_dir_all(&official_dir).unwrap();
1000        fs::create_dir_all(&installed_dir).unwrap();
1001
1002        fs::write(
1003            official_dir.join("package.toml"),
1004            r#"
1005[identity]
1006                name = "agent-runtime"
1007                version = "0.1.0"
1008                description = "Official agent runtime"
1009
1010[package]
1011entry = "package.wasm"
1012runtime = "wasm"
1013"#,
1014        )
1015        .unwrap();
1016        fs::write(official_dir.join("package.wasm"), b"official wasm").unwrap();
1017
1018        fs::write(
1019            installed_dir.join("package.toml"),
1020            r#"
1021[package_info]
1022name = "agent-core"
1023version = "0.1.0"
1024description = "Installed stale runtime"
1025entry = "package.wasm"
1026"#,
1027        )
1028        .unwrap();
1029        fs::write(installed_dir.join("package.wasm"), b"installed wasm").unwrap();
1030
1031        let package_index = crate::app::PackageIndex {
1032            version: 1,
1033            revision: "test".into(),
1034            source_url: "local://packages".into(),
1035            package_sources: vec![crate::app::PackageSource {
1036                name: "agent-runtime".into(),
1037                kind: "wasm".into(),
1038                package_kind: "foundation".into(),
1039                runtime_provider: "agent-core".into(),
1040                current_source: "packages/official/agent-core".into(),
1041                trusted: true,
1042                signature: "builtin:official".into(),
1043                source_authority: "official".into(),
1044                source_public_keys: vec![],
1045                provides: vec!["agent.runtime".into()],
1046                requires: vec![],
1047            }],
1048        };
1049
1050        let resolved = resolve_runtime_package(repo.path(), &package_index, "agent-core")
1051            .expect("runtime provider alias should resolve");
1052
1053        assert_eq!(resolved.manifest.package_info.name, "agent-runtime");
1054        assert_eq!(
1055            normalize_existing_path(&resolved.dir),
1056            normalize_existing_path(&official_dir)
1057        );
1058    }
1059}