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#[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
53pub 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 pub fn register(&mut self, info: PackageInfo) {
73 self.packages.insert(info.name.clone(), info);
74 }
75
76 pub fn list(&self) -> Vec<&PackageInfo> {
78 self.packages.values().collect()
79 }
80
81 pub fn unregister(&mut self, name: &str) -> Option<PackageInfo> {
83 self.packages.remove(name)
84 }
85
86 pub fn get(&self, name: &str) -> Option<&PackageInfo> {
88 self.packages.get(name)
89 }
90
91 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#[derive(Debug, Clone, serde::Serialize)]
434pub struct MissingDep {
435 pub name: String,
436 pub required_version: String,
437}
438
439pub 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
458pub 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 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 #[cfg(windows)]
673 {
674 use std::os::windows::process::CommandExt;
675 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 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 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 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}