1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct PackageSource {
9 pub name: String,
10 #[serde(default)]
11 pub kind: String,
12 #[serde(default)]
13 pub package_kind: String,
14 #[serde(default)]
15 pub runtime_provider: String,
16 #[serde(default)]
17 pub current_source: String,
18 #[serde(default)]
19 pub trusted: bool,
20 #[serde(default)]
21 pub signature: String,
22 #[serde(default)]
23 pub source_authority: String,
24 #[serde(default)]
25 pub source_public_keys: Vec<String>,
26 #[serde(default)]
27 pub provides: Vec<String>,
28 #[serde(default)]
29 pub requires: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct PackageSourceDto {
34 pub name: String,
35 #[serde(default)]
36 pub kind: String,
37 #[serde(default)]
38 pub package_kind: String,
39 #[serde(default)]
40 pub runtime_provider: String,
41 #[serde(default)]
42 pub current_source: String,
43 #[serde(default)]
44 pub trusted: bool,
45 #[serde(default)]
46 pub signature: String,
47 #[serde(default)]
48 pub source_authority: String,
49 #[serde(default)]
50 pub source_public_keys: Vec<String>,
51 #[serde(default)]
52 pub provides: Vec<String>,
53 #[serde(default)]
54 pub requires: Vec<String>,
55}
56
57impl From<PackageSourceDto> for PackageSource {
58 fn from(dto: PackageSourceDto) -> Self {
59 Self {
60 name: dto.name,
61 kind: dto.kind,
62 package_kind: dto.package_kind,
63 runtime_provider: dto.runtime_provider,
64 current_source: dto.current_source,
65 trusted: dto.trusted,
66 signature: dto.signature,
67 source_authority: dto.source_authority,
68 source_public_keys: dto.source_public_keys,
69 provides: dto.provides,
70 requires: dto.requires,
71 }
72 }
73}
74
75impl PackageSource {
76 pub fn runtime_provider_name(&self) -> String {
77 if !self.runtime_provider.trim().is_empty() {
78 return self.runtime_provider.trim().to_string();
79 }
80
81 std::path::Path::new(&self.current_source)
82 .file_name()
83 .and_then(|name| name.to_str())
84 .map(|name| name.to_string())
85 .filter(|name| !name.trim().is_empty())
86 .unwrap_or_else(|| self.name.clone())
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
91pub struct PackageIndex {
92 #[serde(default)]
93 pub version: u32,
94 #[serde(default)]
95 pub revision: String,
96 #[serde(default)]
97 pub source_url: String,
98 #[serde(default)]
99 pub package_sources: Vec<PackageSource>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103struct PackageIndexDto {
104 #[serde(default)]
105 pub version: u32,
106 #[serde(default)]
107 pub revision: String,
108 #[serde(default)]
109 pub source_url: String,
110 #[serde(default)]
111 pub package_sources: Vec<PackageSourceDto>,
112}
113
114impl From<PackageIndexDto> for PackageIndex {
115 fn from(dto: PackageIndexDto) -> Self {
116 Self {
117 version: dto.version,
118 revision: dto.revision,
119 source_url: dto.source_url,
120 package_sources: dto
121 .package_sources
122 .into_iter()
123 .map(PackageSource::from)
124 .collect(),
125 }
126 }
127}
128
129#[async_trait]
130pub trait PackageServiceClient: Send + Sync {
131 async fn fetch_package_index(&self, source_url: &str) -> Result<PackageIndex>;
132}
133
134#[derive(Default, Debug, Clone)]
135pub struct ReqwestPackageServiceClient {
136 http_client: reqwest::Client,
137}
138
139impl ReqwestPackageServiceClient {
140 pub fn new() -> Self {
141 Self {
142 http_client: reqwest::Client::new(),
143 }
144 }
145}
146
147#[async_trait]
148impl PackageServiceClient for ReqwestPackageServiceClient {
149 async fn fetch_package_index(&self, source_url: &str) -> Result<PackageIndex> {
150 let response = self
151 .http_client
152 .get(source_url)
153 .send()
154 .await?
155 .error_for_status()?;
156 let text = response.text().await?;
157 parse_package_index_response(&text)
158 .with_context(|| format!("Failed to parse package index response from {}", source_url))
159 }
160}
161
162impl PackageIndex {
163 pub fn get(&self, name: &str) -> Option<&PackageSource> {
164 self.package_sources.iter().find(|p| {
165 p.name == name || (!p.runtime_provider.trim().is_empty() && p.runtime_provider == name)
166 })
167 }
168
169 pub fn names(&self) -> Vec<&str> {
170 self.package_sources
171 .iter()
172 .map(|p| p.name.as_str())
173 .collect()
174 }
175
176 pub fn unmet_requirements(&self) -> Vec<UnmetRequirement> {
185 let provided: std::collections::HashSet<&str> = self
186 .package_sources
187 .iter()
188 .flat_map(|p| p.provides.iter().map(|s| s.as_str()))
189 .collect();
190
191 let mut unmet = Vec::new();
192 for source in &self.package_sources {
193 for capability in &source.requires {
194 let cap = capability.trim();
195 if cap.is_empty() {
196 continue;
197 }
198 if !provided.contains(cap) {
199 unmet.push(UnmetRequirement {
200 package: source.name.clone(),
201 capability: cap.to_string(),
202 });
203 }
204 }
205 }
206 unmet
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct UnmetRequirement {
213 pub package: String,
214 pub capability: String,
215}
216
217pub fn load_package_index(packages_dir: &Path) -> Result<PackageIndex> {
218 let path = packages_dir.join("index.toml");
219 let content = std::fs::read_to_string(&path)
220 .with_context(|| format!("Failed to read {}", path.display()))?;
221 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
222}
223
224pub async fn fetch_package_index_from_url(source_url: &str) -> Result<PackageIndex> {
225 ReqwestPackageServiceClient::new()
226 .fetch_package_index(source_url)
227 .await
228}
229
230fn parse_package_index_response(text: &str) -> Result<PackageIndex> {
231 let dto: PackageIndexDto = serde_json::from_str(text)?;
232 Ok(dto.into())
233}
234
235fn load_cached_json<T>(path: &Path) -> Option<T>
236where
237 T: DeserializeOwned,
238{
239 std::fs::read_to_string(path)
240 .ok()
241 .and_then(|content| serde_json::from_str(&content).ok())
242}
243
244fn save_cached_json<T>(path: &Path, value: &T)
245where
246 T: serde::Serialize,
247{
248 let Some(parent) = path.parent() else {
249 return;
250 };
251
252 if let Err(error) = std::fs::create_dir_all(parent) {
253 tracing::warn!(
254 "Failed to create cache directory '{}': {}",
255 parent.display(),
256 error
257 );
258 return;
259 }
260
261 match serde_json::to_string_pretty(value) {
262 Ok(content) => {
263 if let Err(error) = std::fs::write(path, content) {
264 tracing::warn!("Failed to write cache file '{}': {}", path.display(), error);
265 }
266 }
267 Err(error) => {
268 tracing::warn!(
269 "Failed to serialize cache file '{}': {}",
270 path.display(),
271 error
272 );
273 }
274 }
275}
276
277pub async fn resolve_package_index(
278 repo_root: &Path,
279 data_dir: &str,
280 configured_source_url: Option<&str>,
281) -> PackageIndex {
282 resolve_package_index_with_fallback(repo_root, data_dir, configured_source_url).await
283}
284
285pub async fn resolve_package_index_with_fallback(
286 repo_root: &Path,
287 data_dir: &str,
288 configured_source_url: Option<&str>,
289) -> PackageIndex {
290 let client = ReqwestPackageServiceClient::new();
291 resolve_package_index_with_client(repo_root, data_dir, configured_source_url, &client).await
292}
293
294pub async fn resolve_package_index_with_client<C: PackageServiceClient + ?Sized>(
295 repo_root: &Path,
296 data_dir: &str,
297 configured_source_url: Option<&str>,
298 package_service_client: &C,
299) -> PackageIndex {
300 let packages_dir = repo_root.join("packages");
301 let package_cache_path = repo_root
302 .join(data_dir)
303 .join("source-cache")
304 .join("package-index.json");
305
306 let local_index = load_package_index(&packages_dir).unwrap_or_else(|e| {
307 tracing::warn!("Failed to load package index: {}", e);
308 PackageIndex::default()
309 });
310
311 let source_url = configured_source_url
312 .map(str::to_string)
313 .unwrap_or_else(|| local_index.source_url.clone());
314
315 if source_url.starts_with("http://") || source_url.starts_with("https://") {
316 match package_service_client
317 .fetch_package_index(&source_url)
318 .await
319 {
320 Ok(remote_index) => {
321 save_cached_json(&package_cache_path, &remote_index);
322 remote_index
323 }
324 Err(error) => {
325 if let Some(cached_index) = load_cached_json::<PackageIndex>(&package_cache_path) {
326 tracing::warn!(
327 "Failed to fetch package index from '{}': {}. Falling back to cached snapshot at '{}'.",
328 source_url,
329 error,
330 package_cache_path.display()
331 );
332 cached_index
333 } else {
334 tracing::warn!(
335 "Failed to fetch package index from '{}': {}. Falling back to local index.",
336 source_url,
337 error
338 );
339 local_index
340 }
341 }
342 }
343 } else {
344 local_index
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::{
351 fetch_package_index_from_url, load_package_index, parse_package_index_response,
352 PackageIndex, PackageSource,
353 };
354 use crate::app::resolve_package_index;
355 use axum::routing::post;
356 use axum::{routing::get, Json, Router};
357 use serde_json::json;
358 use tokio::net::TcpListener;
359
360 fn src(name: &str, provides: &[&str], requires: &[&str]) -> PackageSource {
361 PackageSource {
362 name: name.to_string(),
363 provides: provides.iter().map(|s| s.to_string()).collect(),
364 requires: requires.iter().map(|s| s.to_string()).collect(),
365 ..Default::default()
366 }
367 }
368
369 #[test]
370 fn unmet_requirements_empty_when_all_satisfied() {
371 let index = PackageIndex {
372 package_sources: vec![
373 src("a", &["session.events"], &[]),
374 src("b", &["agent.runtime"], &["session.events"]),
375 ],
376 ..Default::default()
377 };
378 assert!(index.unmet_requirements().is_empty());
379 }
380
381 #[test]
382 fn unmet_requirements_flags_missing_provider() {
383 let index = PackageIndex {
384 package_sources: vec![
385 src("weft-claw", &["weft_claw.turn"], &["ext.mcp", "agent.runtime"]),
387 src("agent", &["agent.runtime"], &[]),
388 ],
389 ..Default::default()
390 };
391 let unmet = index.unmet_requirements();
392 assert_eq!(unmet.len(), 1);
393 assert_eq!(unmet[0].package, "weft-claw");
394 assert_eq!(unmet[0].capability, "ext.mcp");
395 }
396
397 #[test]
398 fn unmet_requirements_repo_index_is_satisfied() {
399 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
401 let index = load_package_index(&repo_root.join("packages")).expect("index loads");
402 let unmet = index.unmet_requirements();
403 assert!(
404 unmet.is_empty(),
405 "shipped index has unmet requirements: {unmet:?}"
406 );
407 }
408
409 const TRUSTED_INDEX_PACKAGES: &[&str] = &[
410 "agent-runtime",
411 "memory-store",
412 "skills-runtime",
413 "channel-core",
414 "cron",
415 "mcp-client",
416 ];
417 #[test]
418 fn repository_package_index_includes_current_trusted_entries() {
419 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
420 let index = load_package_index(&repo_root.join("packages")).expect("package index loads");
421
422 assert_eq!(index.revision, "packages-index-v1");
423
424 for package_name in TRUSTED_INDEX_PACKAGES {
425 let pkg = index
426 .get(package_name)
427 .unwrap_or_else(|| panic!("{package_name} package exists in index"));
428
429 assert!(pkg.trusted, "{package_name} should remain trusted");
430 assert!(
431 !pkg.current_source.trim().is_empty(),
432 "{package_name} should declare a current source"
433 );
434 assert!(
435 !pkg.signature.trim().is_empty(),
436 "{package_name} should declare a signature or builtin trust marker"
437 );
438 assert!(
439 !pkg.source_authority.trim().is_empty(),
440 "{package_name} should declare a source authority"
441 );
442 }
443 }
444
445 #[tokio::test]
446 async fn fetch_package_index_from_url_preserves_revision_metadata() {
447 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
448 let addr = listener.local_addr().unwrap();
449 let app = Router::new().route(
450 "/packages",
451 get(|| async {
452 Json(json!({
453 "version": 1,
454 "revision": "packages-remote-r7",
455 "source_url": "https://registry.example/api/sources/packages",
456 "package_sources": [
457 {
458 "name": "agent-runtime",
459 "kind": "wasm",
460 "current_source": "packages/official/agent-core",
461 "trusted": true,
462 "signature": "builtin:official",
463 "source_authority": "official",
464 "source_public_keys": []
465 }
466 ]
467 }))
468 }),
469 );
470 tokio::spawn(async move {
471 axum::serve(listener, app).await.unwrap();
472 });
473
474 let index = fetch_package_index_from_url(&format!("http://{addr}/packages"))
475 .await
476 .expect("remote package index loads");
477
478 assert_eq!(index.version, 1);
479 assert_eq!(index.revision, "packages-remote-r7");
480 assert_eq!(index.package_sources[0].source_authority, "official");
481 }
482
483 #[test]
484 fn parse_package_index_response_adapts_dto() {
485 let text = r#"
486 {
487 "version": 1,
488 "revision": "dto-v1",
489 "source_url": "https://registry.example/api/sources/packages",
490 "package_sources": [
491 {
492 "name": "agent-runtime",
493 "kind": "wasm",
494 "current_source": "packages/official/agent-core",
495 "trusted": true,
496 "signature": "builtin:official",
497 "source_authority": "official",
498 "source_public_keys": []
499 }
500 ]
501 }
502 "#;
503
504 let index = parse_package_index_response(text).expect("dto parse");
505 assert_eq!(index.version, 1);
506 assert_eq!(index.revision, "dto-v1");
507 assert_eq!(
508 index.source_url,
509 "https://registry.example/api/sources/packages"
510 );
511
512 let pkg = &index.package_sources[0];
513 assert_eq!(pkg.name, "agent-runtime");
514 assert_eq!(pkg.kind, "wasm");
515 assert_eq!(pkg.current_source, "packages/official/agent-core");
516 assert_eq!(pkg.signature, "builtin:official");
517 }
518
519 fn write_package_index(path: &std::path::Path, index: &PackageIndex) {
520 let content = toml::to_string_pretty(index).expect("index serializes");
521 std::fs::write(path, content).expect("index written");
522 }
523
524 fn write_cached_index(path: &std::path::Path, index: &PackageIndex) {
525 let content = serde_json::to_string_pretty(index).expect("cache serializes");
526 std::fs::create_dir_all(path.parent().unwrap()).expect("cache parent exists");
527 std::fs::write(path, content).expect("cache written");
528 }
529
530 #[tokio::test]
531 async fn resolve_package_index_prefers_remote_and_caches_result() {
532 let root = tempfile::tempdir().expect("temp dir");
533 let packages_dir = root.path().join("packages");
534 std::fs::create_dir_all(&packages_dir).expect("packages dir");
535
536 let local_index = PackageIndex {
537 version: 1,
538 revision: "local-v1".into(),
539 source_url: String::new(),
540 package_sources: vec![],
541 };
542 write_package_index(&packages_dir.join("index.toml"), &local_index);
543
544 let remote_index = PackageIndex {
545 version: 2,
546 revision: "remote-v2".into(),
547 source_url: "https://registry.example/api/sources/packages".into(),
548 package_sources: vec![],
549 };
550
551 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
552 let addr = listener.local_addr().unwrap();
553 let app = Router::new().route(
554 "/packages",
555 get(move || {
556 let remote_index = remote_index.clone();
557 async move { Json(remote_index) }
558 }),
559 );
560 tokio::spawn(async move {
561 axum::serve(listener, app).await.unwrap();
562 });
563
564 let package_index = resolve_package_index(
565 root.path(),
566 "./data",
567 Some(&format!("http://{addr}/packages")),
568 )
569 .await;
570
571 assert_eq!(package_index.version, 2);
572 assert_eq!(package_index.revision, "remote-v2");
573
574 let cached_path = root.path().join("./data/source-cache/package-index.json");
575 let cached: PackageIndex =
576 serde_json::from_str(&std::fs::read_to_string(&cached_path).expect("cache exists"))
577 .expect("cache parse");
578 assert_eq!(cached.revision, "remote-v2");
579 }
580
581 #[tokio::test]
582 async fn resolve_package_index_falls_back_to_cached_when_remote_fails() {
583 let root = tempfile::tempdir().expect("temp dir");
584 let packages_dir = root.path().join("packages");
585 std::fs::create_dir_all(&packages_dir).expect("packages dir");
586
587 let local_index = PackageIndex {
588 version: 1,
589 revision: "local-v1".into(),
590 source_url: "https://registry.example/api/sources/packages".into(),
591 package_sources: vec![],
592 };
593 write_package_index(&packages_dir.join("index.toml"), &local_index);
594
595 let cached_index = PackageIndex {
596 version: 3,
597 revision: "cached-v3".into(),
598 source_url: String::new(),
599 package_sources: vec![],
600 };
601
602 let cache_path = root.path().join("./data/source-cache/package-index.json");
603 write_cached_index(&cache_path, &cached_index);
604
605 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
606 let addr = listener.local_addr().unwrap();
607 let app = Router::new().route(
608 "/packages",
609 post(|| async { axum::http::StatusCode::INTERNAL_SERVER_ERROR }),
610 );
611 tokio::spawn(async move {
612 axum::serve(listener, app).await.unwrap();
613 });
614
615 let package_index = resolve_package_index(
616 root.path(),
617 "./data",
618 Some(&format!("http://{addr}/packages")),
619 )
620 .await;
621
622 assert_eq!(package_index.version, 3);
623 assert_eq!(package_index.revision, "cached-v3");
624 }
625}