use std::collections::BTreeMap;
use car_bundle::{AgentManifest, CapabilityDeclarations};
use semver::{Version, VersionReq};
use crate::supervisor::SupervisorError;
#[derive(Debug, Clone, Default)]
pub struct HostCapabilities {
pub provides: BTreeMap<String, Vec<String>>,
pub car_version: String,
}
impl HostCapabilities {
pub fn provide(
mut self,
namespace: impl Into<String>,
features: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let entry = self.provides.entry(namespace.into()).or_default();
for f in features {
let s = f.into();
if !entry.contains(&s) {
entry.push(s);
}
}
self
}
pub fn satisfies(&self, namespace: &str, feature: &str) -> bool {
self.provides
.get(namespace)
.is_some_and(|features| features.iter().any(|f| f == feature))
}
pub fn daemon_default(car_version: impl Into<String>) -> Self {
Self {
car_version: car_version.into(),
provides: BTreeMap::from([
(
"inference".to_string(),
vec![
"text-generation".to_string(),
"embedding".to_string(),
"classification".to_string(),
"tool-use".to_string(),
],
),
(
"storage".to_string(),
vec![
"persistent-kv".to_string(),
"persistent-journal".to_string(),
"persistent-graph".to_string(),
"temporary".to_string(),
],
),
(
"a2ui".to_string(),
vec![
"render_report.subscribe".to_string(),
"render_report.emit".to_string(),
"patch_components.emit".to_string(),
"surface_subscribe".to_string(),
],
),
(
"a2a".to_string(),
vec!["message_send".to_string(), "task_subscribe".to_string()],
),
]),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct InstallCheckReport {
pub missing_optional: Vec<(String, String)>,
}
pub fn install_check(
manifest: &AgentManifest,
host: &HostCapabilities,
) -> Result<InstallCheckReport, SupervisorError> {
if let Some(runtime) = &manifest.runtime {
if let Some(min) = &runtime.car_min_version {
check_car_min_version(min, &host.car_version, &manifest.agent.id)?;
}
}
let mut report = InstallCheckReport::default();
if let Some(caps) = &manifest.capabilities {
check_required(caps, host, &manifest.agent.id)?;
for (namespace, features) in &caps.optional {
for feature in features {
if !host.satisfies(namespace, feature) {
report
.missing_optional
.push((namespace.clone(), feature.clone()));
}
}
}
}
Ok(report)
}
fn check_car_min_version(
requirement: &str,
host_version: &str,
agent_id: &str,
) -> Result<(), SupervisorError> {
let parsed_req = parse_version_req(requirement).map_err(|e| {
SupervisorError::Other(format!(
"agent `{agent_id}` has invalid car_min_version `{requirement}`: {e}"
))
})?;
let host = Version::parse(host_version).map_err(|e| {
SupervisorError::Other(format!(
"host car_version `{host_version}` is not valid semver: {e}"
))
})?;
if !parsed_req.matches(&host) {
return Err(SupervisorError::Other(format!(
"agent `{agent_id}` requires car `{requirement}` but host runtime is `{host_version}`"
)));
}
Ok(())
}
fn parse_version_req(s: &str) -> Result<VersionReq, semver::Error> {
if let Ok(v) = Version::parse(s.trim()) {
return Ok(VersionReq::parse(&format!(">={v}"))?);
}
VersionReq::parse(s)
}
fn check_required(
caps: &CapabilityDeclarations,
host: &HostCapabilities,
agent_id: &str,
) -> Result<(), SupervisorError> {
for (namespace, features) in &caps.required {
for feature in features {
if !host.satisfies(namespace, feature) {
return Err(SupervisorError::Other(format!(
"agent `{agent_id}` requires `{namespace}.{feature}` but host does not provide it"
)));
}
}
}
Ok(())
}
pub fn resolve_highest_version<'a>(
candidates: impl IntoIterator<Item = &'a AgentManifest>,
namespace: Option<&str>,
name: &str,
) -> Option<&'a AgentManifest> {
let mut best: Option<(Version, &AgentManifest)> = None;
let mut fallback: Option<&AgentManifest> = None;
for m in candidates {
let m_name = m.agent.name.as_str();
let m_namespace = m.agent.namespace.as_deref();
let name_matches = m_name == name || m.agent.id == name;
let namespace_matches = match (namespace, m_namespace) {
(Some(want), Some(have)) => want == have,
(None, _) => true,
_ => false,
};
if !(name_matches && namespace_matches) {
continue;
}
match m
.agent
.version
.as_deref()
.and_then(|v| Version::parse(v).ok())
{
Some(parsed) => match best.as_ref() {
Some((b, _)) if &parsed <= b => {}
_ => best = Some((parsed, m)),
},
None => {
if fallback.is_none() {
fallback = Some(m);
}
}
}
}
best.map(|(_, m)| m).or(fallback)
}
#[cfg(test)]
mod tests {
use super::*;
use car_bundle::{AgentIdentity, ExternalProcessTransport, RuntimeRequirements, TransportSpec};
fn external_manifest(id: &str, version: Option<&str>) -> AgentManifest {
AgentManifest {
agent: AgentIdentity {
id: id.into(),
name: id.into(),
namespace: Some("parslee".into()),
version: version.map(str::to_string),
description: None,
license: None,
homepage: None,
},
publisher: None,
runtime: None,
lifecycle: None,
transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
command: Some("/usr/local/bin/agent".into()),
binary_url: None,
sha256: Some("abc".into()),
health_url: None,
args: vec![],
cwd: None,
env: BTreeMap::new(),
restart: car_bundle::RestartPolicy::OnFailure,
max_restarts: 10,
backoff_secs: 5,
auto_start: false,
token: String::new(),
}),
capabilities: None,
}
}
#[test]
fn install_check_passes_when_no_capabilities_declared() {
let m = external_manifest("alpha", Some("0.1.0"));
let host = HostCapabilities {
car_version: "0.8.0".into(),
..Default::default()
};
let report = install_check(&m, &host).expect("no requirements means pass");
assert!(report.missing_optional.is_empty());
}
#[test]
fn install_check_fails_on_missing_required_capability() {
let mut m = external_manifest("alpha", Some("0.1.0"));
m.capabilities = Some(CapabilityDeclarations {
required: BTreeMap::from([("inference".into(), vec!["text-generation".into()])]),
..Default::default()
});
let host = HostCapabilities {
car_version: "0.8.0".into(),
..Default::default()
};
let err = install_check(&m, &host).expect_err("missing capability must fail");
assert!(
err.to_string().contains("inference.text-generation"),
"expected missing-cap reason, got: {err}"
);
}
#[test]
fn install_check_records_missing_optional_capabilities() {
let mut m = external_manifest("alpha", Some("0.1.0"));
m.capabilities = Some(CapabilityDeclarations {
required: BTreeMap::from([("inference".into(), vec!["text-generation".into()])]),
optional: BTreeMap::from([
("inference".into(), vec!["embedding".into()]),
("a2a".into(), vec!["message_send".into()]),
]),
..Default::default()
});
let host = HostCapabilities::default()
.provide("inference", ["text-generation"])
;
let host = HostCapabilities {
car_version: "0.8.0".into(),
..host
};
let report = install_check(&m, &host).expect("required satisfied");
assert_eq!(report.missing_optional.len(), 2);
let names: Vec<&str> = report
.missing_optional
.iter()
.map(|(_n, f)| f.as_str())
.collect();
assert!(names.contains(&"embedding"));
assert!(names.contains(&"message_send"));
}
#[test]
fn install_check_rejects_when_car_min_version_too_high() {
let mut m = external_manifest("alpha", Some("0.1.0"));
m.runtime = Some(RuntimeRequirements {
car_min_version: Some("0.9.0".into()),
bundle_format_version: 1,
});
let host = HostCapabilities {
car_version: "0.8.0".into(),
..Default::default()
};
let err = install_check(&m, &host).expect_err("min version unmet must fail");
assert!(err.to_string().contains("0.9.0"));
assert!(err.to_string().contains("0.8.0"));
}
#[test]
fn install_check_accepts_bare_semver_as_minimum() {
let mut m = external_manifest("alpha", Some("0.1.0"));
m.runtime = Some(RuntimeRequirements {
car_min_version: Some("0.7.0".into()),
bundle_format_version: 1,
});
let host = HostCapabilities {
car_version: "0.8.0".into(),
..Default::default()
};
install_check(&m, &host).expect("bare semver is interpreted as `>=`");
}
#[test]
fn install_check_accepts_cargo_style_requirement() {
let mut m = external_manifest("alpha", Some("0.1.0"));
m.runtime = Some(RuntimeRequirements {
car_min_version: Some(">=0.8, <0.9".into()),
bundle_format_version: 1,
});
let host = HostCapabilities {
car_version: "0.8.5".into(),
..Default::default()
};
install_check(&m, &host).expect("range requirement satisfied");
}
#[test]
fn resolve_highest_version_picks_max_semver() {
let v1 = external_manifest("ui", Some("0.1.0"));
let v2 = external_manifest("ui", Some("0.2.0"));
let v3 = external_manifest("ui", Some("0.1.5"));
let all = vec![&v1, &v2, &v3];
let resolved = resolve_highest_version(all.iter().copied(), Some("parslee"), "ui").unwrap();
assert_eq!(resolved.agent.version.as_deref(), Some("0.2.0"));
}
#[test]
fn resolve_falls_back_to_unversioned_when_no_versioned_match() {
let mut legacy = external_manifest("ui", None);
legacy.agent.namespace = None;
let all = vec![&legacy];
let resolved = resolve_highest_version(all.iter().copied(), None, "ui").unwrap();
assert!(resolved.agent.version.is_none());
}
#[test]
fn resolve_versioned_wins_over_unversioned() {
let mut legacy = external_manifest("ui", None);
legacy.agent.namespace = None;
let versioned = external_manifest("ui", Some("0.1.0"));
let all = vec![&legacy, &versioned];
let resolved = resolve_highest_version(all.iter().copied(), Some("parslee"), "ui").unwrap();
assert_eq!(resolved.agent.version.as_deref(), Some("0.1.0"));
}
}