Skip to main content

harn_vm/
host_attachments.rs

1//! Host-neutral materialization for durable attachment pointers.
2//!
3//! Harn owns attachment delivery policy, while embedders own artifact storage.
4//! This registry is the narrow bridge between those responsibilities: a host
5//! resolves an opaque pointer into provider-neutral content, and Harn decides
6//! whether that content may enter the active model's context.
7
8use std::sync::{Arc, OnceLock, RwLock};
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum MaterializedAttachment {
12    Text(String),
13    ImageUrl(String),
14    ImageBase64 { media_type: String, data: String },
15}
16
17pub trait HostAttachmentResolver: Send + Sync {
18    fn resolve(
19        &self,
20        artifact_pointer: &str,
21        media_type: &str,
22    ) -> Result<MaterializedAttachment, String>;
23}
24
25struct Entry {
26    id: u64,
27    #[cfg(test)]
28    owner: std::thread::ThreadId,
29    resolver: Arc<dyn HostAttachmentResolver>,
30}
31
32fn registry() -> &'static RwLock<Vec<Entry>> {
33    static REGISTRY: OnceLock<RwLock<Vec<Entry>>> = OnceLock::new();
34    REGISTRY.get_or_init(|| RwLock::new(Vec::new()))
35}
36
37#[must_use = "dropping the registration unregisters the attachment resolver"]
38pub struct HostAttachmentResolverRegistration {
39    id: u64,
40}
41
42impl Drop for HostAttachmentResolverRegistration {
43    fn drop(&mut self) {
44        if let Ok(mut entries) = registry().write() {
45            entries.retain(|entry| entry.id != self.id);
46        }
47    }
48}
49
50pub fn register_host_attachment_resolver(
51    resolver: Arc<dyn HostAttachmentResolver>,
52) -> HostAttachmentResolverRegistration {
53    use std::sync::atomic::{AtomicU64, Ordering};
54    static NEXT_ID: AtomicU64 = AtomicU64::new(1);
55    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
56    registry()
57        .write()
58        .expect("host attachment resolver registry poisoned")
59        .push(Entry {
60            id,
61            #[cfg(test)]
62            owner: std::thread::current().id(),
63            resolver,
64        });
65    HostAttachmentResolverRegistration { id }
66}
67
68pub(crate) fn materialize(
69    artifact_pointer: &str,
70    media_type: &str,
71) -> Result<MaterializedAttachment, String> {
72    let resolver = registry()
73        .read()
74        .map_err(|_| "host attachment resolver registry poisoned".to_string())?
75        .iter()
76        .rev()
77        .find(|entry| {
78            #[cfg(test)]
79            {
80                entry.owner == std::thread::current().id()
81            }
82            #[cfg(not(test))]
83            {
84                let _ = entry;
85                true
86            }
87        })
88        .map(|entry| Arc::clone(&entry.resolver));
89    match resolver {
90        Some(resolver) => resolver.resolve(artifact_pointer, media_type),
91        None => Err("no host attachment resolver registered".to_string()),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    struct TextResolver;
100
101    impl HostAttachmentResolver for TextResolver {
102        fn resolve(
103            &self,
104            pointer: &str,
105            _media_type: &str,
106        ) -> Result<MaterializedAttachment, String> {
107            Ok(MaterializedAttachment::Text(format!("resolved:{pointer}")))
108        }
109    }
110
111    #[test]
112    fn registration_is_scoped() {
113        assert!(materialize("artifact:one", "text/plain").is_err());
114        {
115            let _registration = register_host_attachment_resolver(Arc::new(TextResolver));
116            assert_eq!(
117                materialize("artifact:one", "text/plain").unwrap(),
118                MaterializedAttachment::Text("resolved:artifact:one".into())
119            );
120        }
121        assert!(materialize("artifact:one", "text/plain").is_err());
122    }
123}