1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct PackageManifest {
9 #[serde(default)]
10 pub identity: Option<PackageIdentity>,
11 #[serde(rename = "package_info")]
12 pub package_info: PackageDescriptor,
13 #[serde(default)]
14 pub capability: Option<PackageCapability>,
15 #[serde(default)]
16 pub package: Option<PackageMeta>,
17 #[serde(default)]
18 pub runtime_contract: Option<PackageRuntimeContract>,
19 #[serde(default)]
20 pub lifecycle: Option<PackageLifecycle>,
21 #[serde(default)]
22 pub permissions: PackagePermissions,
23 #[serde(default)]
24 pub dependencies: HashMap<String, String>,
25 #[serde(default)]
26 pub config: HashMap<String, PackageConfigField>,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct PackageCapability {
31 #[serde(default)]
32 pub provides: Vec<String>,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct PackageIdentity {
37 #[serde(default)]
38 pub name: String,
39 #[serde(default)]
40 pub version: String,
41 #[serde(default)]
42 pub description: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PackageDescriptor {
48 pub name: String,
49 pub version: String,
50 pub description: String,
51 #[serde(default = "default_entry")]
52 pub entry: String,
53 #[serde(default)]
55 pub provides: Vec<String>,
56 pub chat_endpoint: Option<String>,
58}
59
60fn default_entry() -> String {
61 "package.wasm".into()
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct PackageMeta {
66 #[serde(default)]
67 pub entry: Option<String>,
68 #[serde(default)]
69 pub runtime: Option<String>,
70 #[serde(default)]
73 pub kind: Option<String>,
74 #[serde(default)]
75 pub api_version: Option<String>,
76 #[serde(default)]
77 pub native_allowed: bool,
78 #[serde(default)]
79 pub expected_digest: Option<String>,
80 #[serde(default)]
81 pub chat_endpoint: Option<String>,
82 #[serde(default)]
83 pub provides: Vec<String>,
84 #[serde(default)]
85 pub priority: Option<i32>,
86 #[serde(default)]
87 pub assets: Vec<String>,
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct PackageRuntimeContract {
92 #[serde(default)]
93 pub startup_mode: Option<String>,
94 #[serde(default)]
95 pub communication: Vec<String>,
96 #[serde(default)]
97 pub config_delivery: Vec<String>,
98 #[serde(default)]
99 pub readiness_probe: Option<String>,
100 #[serde(default)]
101 pub liveness_probe: Option<String>,
102 #[serde(default)]
103 pub stop_mode: Option<String>,
104 #[serde(default)]
105 pub restart_policy: Option<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PackageConfigField {
111 #[serde(rename = "type")]
112 pub field_type: String, #[serde(default)]
114 pub required: bool,
115 #[serde(default)]
116 pub secret: bool,
117 pub default: Option<serde_json::Value>,
118 pub description: Option<String>,
119 #[serde(default)]
120 pub options: Option<Vec<String>>, pub min: Option<f64>,
122 pub max: Option<f64>,
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct PackagePermissions {
128 #[serde(default)]
129 pub process: bool,
130 #[serde(default)]
131 pub network: bool,
132 #[serde(default)]
133 pub auth: bool,
134 #[serde(default)]
135 pub storage: bool,
136 #[serde(default)]
137 pub pipeline: bool,
138 #[serde(default)]
139 pub routes: bool,
140 #[serde(default)]
141 pub events: bool,
142 #[serde(default)]
143 pub log: bool,
144 #[serde(default)]
145 pub ui: bool,
146 #[serde(default)]
147 pub config: bool,
148 #[serde(default)]
149 pub scheduler: bool,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct PackageLifecycle {
154 #[serde(default)]
155 pub healthcheck: Option<String>,
156}
157
158fn clean_optional_string(value: Option<String>) -> Option<String> {
159 value.and_then(|entry| {
160 let trimmed = entry.trim();
161 if trimmed.is_empty() {
162 None
163 } else {
164 Some(trimmed.to_string())
165 }
166 })
167}
168
169impl PackageManifest {
170 pub fn resolved_entry(&self) -> Option<String> {
171 clean_optional_string(
172 self.package
173 .as_ref()
174 .and_then(|package| package.entry.clone())
175 .or_else(|| Some(self.package_info.entry.clone())),
176 )
177 }
178
179 pub fn runtime_kind(&self) -> String {
180 if let Some(runtime) = clean_optional_string(
181 self.package
182 .as_ref()
183 .and_then(|package| package.runtime.clone()),
184 ) {
185 return runtime;
186 }
187
188 match self.resolved_entry() {
189 Some(entry) if entry.ends_with(".wasm") => "wasm".into(),
190 Some(_) => "service".into(),
191 None => "wasm".into(),
192 }
193 }
194
195 pub fn resolved_chat_endpoint(&self) -> Option<String> {
196 clean_optional_string(
197 self.package
198 .as_ref()
199 .and_then(|package| package.chat_endpoint.clone())
200 .or_else(|| self.package_info.chat_endpoint.clone()),
201 )
202 }
203
204 pub fn resolved_provides(&self) -> Vec<String> {
205 let mut provides = self.package_info.provides.clone();
206 if let Some(capability) = &self.capability {
207 provides.extend(capability.provides.clone());
208 }
209 if let Some(package) = &self.package {
210 provides.extend(package.provides.clone());
211 }
212 provides.retain(|value| !value.trim().is_empty());
213 provides.sort();
214 provides.dedup();
215 provides
216 }
217
218 pub fn is_product(&self) -> bool {
222 self.package
223 .as_ref()
224 .and_then(|package| package.kind.as_deref())
225 .map(|kind| kind.eq_ignore_ascii_case("product"))
226 .unwrap_or(false)
227 }
228
229 pub fn resolved_healthcheck(&self) -> Option<String> {
230 clean_optional_string(
231 self.runtime_contract
232 .as_ref()
233 .and_then(|contract| contract.readiness_probe.clone())
234 .or_else(|| {
235 self.lifecycle
236 .as_ref()
237 .and_then(|lifecycle| lifecycle.healthcheck.clone())
238 }),
239 )
240 }
241}
242
243pub fn load_manifest(package_dir: &Path) -> Result<PackageManifest> {
245 let manifest_path = package_dir.join("package.toml");
246 let content = std::fs::read_to_string(&manifest_path)
247 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
248
249 let has_package_section = content.lines().any(|line| line.trim() == "[package_info]");
250 let mut manifest: PackageManifest = if has_package_section {
251 toml::from_str(&content)
252 .with_context(|| format!("Failed to parse {}", manifest_path.display()))?
253 } else {
254 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
255 struct IdentityOnlyManifest {
256 #[serde(default)]
257 identity: Option<PackageIdentity>,
258 #[serde(default)]
259 capability: Option<PackageCapability>,
260 #[serde(default)]
261 package: Option<PackageMeta>,
262 #[serde(default)]
263 runtime_contract: Option<PackageRuntimeContract>,
264 #[serde(default)]
265 lifecycle: Option<PackageLifecycle>,
266 #[serde(default)]
267 permissions: PackagePermissions,
268 #[serde(default)]
269 dependencies: HashMap<String, String>,
270 #[serde(default)]
271 config: HashMap<String, PackageConfigField>,
272 }
273
274 let raw: IdentityOnlyManifest = toml::from_str(&content)
275 .with_context(|| format!("Failed to parse {}", manifest_path.display()))?;
276 PackageManifest {
277 identity: raw.identity,
278 package_info: PackageDescriptor {
279 name: String::new(),
280 version: String::new(),
281 description: String::new(),
282 entry: default_entry(),
283 provides: Vec::new(),
284 chat_endpoint: None,
285 },
286 capability: raw.capability,
287 package: raw.package,
288 runtime_contract: raw.runtime_contract,
289 lifecycle: raw.lifecycle,
290 permissions: raw.permissions,
291 dependencies: raw.dependencies,
292 config: raw.config,
293 }
294 };
295
296 if manifest.package_info.name.trim().is_empty() {
297 if let Some(identity) = &manifest.identity {
298 manifest.package_info.name = identity.name.clone();
299 manifest.package_info.version = identity.version.clone();
300 manifest.package_info.description = identity.description.clone();
301 if manifest.package_info.entry.trim().is_empty() {
302 manifest.package_info.entry = default_entry();
303 }
304 }
305 }
306
307 Ok(manifest)
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_parse_package_manifest() {
316 let toml_str = r#"
317[package_info]
318name = "weft-claw"
319version = "0.1.0"
320description = "Weft Claw AI agent orchestration"
321entry = "backend.ts"
322
323[permissions]
324process = true
325network = true
326auth = true
327storage = true
328pipeline = false
329routes = true
330events = true
331log = true
332ui = true
333config = true
334scheduler = true
335
336[ui]
337title = "Weft Claw"
338icon = "robot"
339entry = "ui/index.html"
340"#;
341 let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
342 assert_eq!(manifest.package_info.name, "weft-claw");
343 assert_eq!(manifest.package_info.version, "0.1.0");
344 assert_eq!(manifest.package_info.entry, "backend.ts"); assert!(manifest.permissions.process);
346 assert!(manifest.permissions.network);
347 assert!(!manifest.permissions.pipeline);
348 }
349
350 #[test]
351 fn test_parse_minimal_manifest() {
352 let toml_str = r#"
353[package_info]
354name = "simple"
355version = "0.1.0"
356description = "A simple package"
357"#;
358 let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
359 assert_eq!(manifest.package_info.name, "simple");
360 assert_eq!(manifest.package_info.entry, "package.wasm"); assert!(!manifest.permissions.process);
362 }
363
364 #[test]
365 fn test_parse_identity_manifest() {
366 let toml_str = r#"
367[identity]
368name = "companion-core"
369version = "0.1.0"
370description = "Companion runtime"
371
372[package]
373entry = "server.py"
374runtime = "service"
375"#;
376
377 let temp = tempfile::tempdir().unwrap();
378 let path = temp.path().join("package.toml");
379 std::fs::write(&path, toml_str).unwrap();
380 let loaded = load_manifest(temp.path()).unwrap();
381 assert_eq!(loaded.package_info.name, "companion-core");
382 assert_eq!(loaded.package_info.version, "0.1.0");
383 assert_eq!(loaded.package_info.description, "Companion runtime");
384 assert_eq!(loaded.resolved_entry().as_deref(), Some("server.py"));
385 }
386
387 #[test]
388 fn test_parse_modern_service_manifest() {
389 let toml_str = r#"
390[package_info]
391name = "ticker-watch"
392version = "0.1.0"
393description = "Ticker watcher"
394
395[package]
396entry = "dist/server.js"
397runtime = "service"
398chat_endpoint = "/chat"
399
400[runtime_contract]
401startup_mode = "persistent"
402communication = ["http"]
403config_delivery = ["config_file"]
404readiness_probe = "http://127.0.0.1:43111/health"
405restart_policy = "host_managed"
406
407[lifecycle]
408healthcheck = "http://127.0.0.1:43111/health"
409"#;
410 let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
411 assert_eq!(manifest.resolved_entry().as_deref(), Some("dist/server.js"));
412 assert_eq!(manifest.runtime_kind(), "service");
413 assert_eq!(manifest.resolved_chat_endpoint().as_deref(), Some("/chat"));
414 assert_eq!(
415 manifest.resolved_healthcheck().as_deref(),
416 Some("http://127.0.0.1:43111/health")
417 );
418 }
419
420 #[test]
421 fn test_resolved_provides_includes_package_values() {
422 let toml_str = r#"
423[package_info]
424name = "companion-core"
425version = "0.1.0"
426description = "Companion"
427
428[package]
429entry = "server.py"
430runtime = "service"
431provides = ["chat_channel", "companion.turn.handle"]
432"#;
433 let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
434 assert!(manifest
435 .resolved_provides()
436 .contains(&"chat_channel".to_string()));
437 assert!(manifest
438 .resolved_provides()
439 .contains(&"companion.turn.handle".to_string()));
440 }
441
442 #[test]
443 fn test_resolved_provides_includes_capability_section() {
444 let toml_str = r#"
445[identity]
446name = "companion-core"
447version = "0.1.0"
448description = "Companion runtime"
449
450[package]
451entry = "server.py"
452runtime = "service"
453
454[capability]
455provides = ["chat_channel", "companion.turn.handle"]
456"#;
457 let temp = tempfile::tempdir().unwrap();
458 let path = temp.path().join("package.toml");
459 std::fs::write(&path, toml_str).unwrap();
460 let loaded = load_manifest(temp.path()).unwrap();
461 assert!(loaded
462 .resolved_provides()
463 .contains(&"chat_channel".to_string()));
464 assert!(loaded
465 .resolved_provides()
466 .contains(&"companion.turn.handle".to_string()));
467 }
468
469 #[test]
470 fn test_load_current_official_package_manifests() {
471 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
472
473 let agent_core = load_manifest(
474 &repo_root
475 .join("packages")
476 .join("official")
477 .join("agent-core"),
478 )
479 .expect("agent-core manifest should load");
480 assert_eq!(agent_core.package_info.name, "agent-runtime");
481 assert_eq!(agent_core.runtime_kind(), "wasm");
482 assert!(agent_core
483 .resolved_provides()
484 .contains(&"agent.runtime".to_string()));
485
486 let weft_claw = load_manifest(
487 &repo_root
488 .join("packages")
489 .join("official")
490 .join("weft-claw"),
491 )
492 .expect("weft-claw manifest should load");
493 assert_eq!(weft_claw.package_info.name, "weft-claw");
494 assert_eq!(weft_claw.runtime_kind(), "wasm");
495 assert!(weft_claw
496 .resolved_provides()
497 .contains(&"weft_claw.turn".to_string()));
498 }
499}