Skip to main content

systemprompt_loader/bundle/
provenance.rs

1//! Where the active services tree came from, as a value a projection can
2//! store and a page can render.
3//!
4//! The sync page reads the same three things every time — the composed hash
5//! the instance booted, the hash of the repository's own `services/` tree,
6//! and each pinned bundle's digest, version and content hash. Recording them
7//! beside an inventory observation is what turns a generation number into
8//! provenance: the reader can say which declarations produced it. Nothing
9//! here fetches; every value comes from the bundle cache's `state.json`, the
10//! cached `bundle.json`, and the services root installed at boot.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use std::collections::BTreeMap;
16use std::path::Path;
17use std::sync::{Mutex, PoisonError};
18use std::time::SystemTime;
19
20use serde::{Deserialize, Serialize};
21use systemprompt_manifest::profile::Profile;
22use systemprompt_manifest::services::bundle::{BUNDLE_ALLOWED_DIRS, ServicesBundleManifest};
23
24use super::bootstrap::cache_root;
25use super::cache::BundleCache;
26use systemprompt_identifiers::ContentHash;
27use systemprompt_manifest::services::root::{ActiveServicesRoot, ServicesProvenance};
28
29/// One pinned bundle as the cache last recorded it. `pinned_digest` is what
30/// the profile asks for and `active_digest` is what was fetched; they differ
31/// while a re-pin is waiting for a restart.
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(default)]
34pub struct BundleProvenance {
35    pub name: String,
36    pub pinned_digest: Option<String>,
37    pub active_digest: Option<String>,
38    pub content_hash: Option<ContentHash>,
39    pub version: Option<String>,
40}
41
42/// The whole declaration surface behind one observation.
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(default)]
45pub struct SourcesProvenance {
46    pub composed_hash: Option<String>,
47    pub base_tree_hash: Option<String>,
48    pub bundles: Vec<BundleProvenance>,
49}
50
51/// Memo of the base services tree hash, owned by whoever records provenance
52/// repeatedly and handed to [`sources_provenance`].
53#[derive(Debug, Default)]
54pub struct BaseTreeHashMemo {
55    last: Mutex<Option<(Fingerprint, ContentHash)>>,
56}
57
58impl BaseTreeHashMemo {
59    #[must_use]
60    pub fn new() -> Self {
61        Self::default()
62    }
63}
64
65#[must_use]
66pub fn sources_provenance(
67    profile: &Profile,
68    root: Option<&ActiveServicesRoot>,
69    memo: &BaseTreeHashMemo,
70) -> SourcesProvenance {
71    let cache = BundleCache::new(cache_root(profile));
72    let state = cache.read_state();
73    let bundles: Vec<BundleProvenance> = profile
74        .services
75        .sources
76        .iter()
77        .map(|source| {
78            let active = state.sources.get(&source.name);
79            let reference = source.oci.as_ref().map_or_else(
80                || {
81                    source
82                        .https
83                        .as_ref()
84                        .map(|h| h.url.clone())
85                        .unwrap_or_default()
86                },
87                |oci| oci.reference.clone(),
88            );
89            BundleProvenance {
90                name: source.name.clone(),
91                pinned_digest: reference
92                    .split_once("@sha256:")
93                    .map(|(_, digest)| format!("sha256:{digest}")),
94                active_digest: active.map(|a| a.digest.clone()),
95                content_hash: active.map(|a| a.content_hash.clone()),
96                version: active.map(|a| a.version.clone()),
97            }
98        })
99        .collect();
100    let base = base_tree_hash(memo, Path::new(&profile.paths.services)).map(String::from);
101    SourcesProvenance {
102        // Why: with no bundles pinned no boot records a composed hash; the
103        // composition is the base tree alone.
104        composed_hash: active_composed_hash(root)
105            .or_else(|| (!state.composed_hash.is_empty()).then(|| state.composed_hash.clone()))
106            .or_else(|| bundles.is_empty().then(|| base.clone()).flatten()),
107        base_tree_hash: base,
108        bundles,
109    }
110}
111
112// Why: keyed by resource name, not by bundle name — the caller has a skill in
113// hand and needs the kit that declares it, which only the cached manifest
114// knows.
115#[must_use]
116pub fn owning_bundle_hashes(profile: &Profile) -> BTreeMap<String, ContentHash> {
117    let cache = BundleCache::new(cache_root(profile));
118    let state = cache.read_state();
119    let mut owners = BTreeMap::new();
120    for source in &profile.services.sources {
121        let Some(active) = state.sources.get(&source.name) else {
122            continue;
123        };
124        let signed = match cache.read_manifest(&source.name, &active.content_hash) {
125            Ok(signed) => signed,
126            Err(error) => {
127                tracing::warn!(source = %source.name, %error, "Cached bundle manifest unreadable; its skills carry no bundle hash");
128                continue;
129            },
130        };
131        for skill in &signed.manifest.owns.skills {
132            owners.insert(skill.clone(), active.content_hash.clone());
133        }
134    }
135    owners
136}
137
138fn active_composed_hash(root: Option<&ActiveServicesRoot>) -> Option<String> {
139    match root.map(|root| &root.provenance) {
140        Some(
141            ServicesProvenance::Fetched { composed_hash, .. }
142            | ServicesProvenance::LastGood { composed_hash, .. },
143        ) => Some(composed_hash.clone()),
144        _ => None,
145    }
146}
147
148// Why: hashing the tree reads every file and the inventory reconciles once a
149// minute, so the result is memoised and recomputed only when the tree's
150// newest mtime or file count moves — a publish or a deploy, not a pass.
151type Fingerprint = (SystemTime, usize);
152
153fn base_tree_hash(memo: &BaseTreeHashMemo, root: &Path) -> Option<ContentHash> {
154    let fingerprint = tree_fingerprint(root)?;
155    if let Some((seen, hash)) = memo
156        .last
157        .lock()
158        .unwrap_or_else(PoisonError::into_inner)
159        .as_ref()
160        && *seen == fingerprint
161    {
162        return Some(hash.clone());
163    }
164    let files = match super::pack::collect_files(root, BUNDLE_ALLOWED_DIRS) {
165        Ok(files) => files,
166        Err(error) => {
167            tracing::warn!(root = %root.display(), %error, "Base services tree unreadable; no base tree hash");
168            return None;
169        },
170    };
171    let hash = ServicesBundleManifest::compute_content_hash(&files);
172    *memo.last.lock().unwrap_or_else(PoisonError::into_inner) = Some((fingerprint, hash.clone()));
173    Some(hash)
174}
175
176fn tree_fingerprint(root: &Path) -> Option<Fingerprint> {
177    let mut newest = SystemTime::UNIX_EPOCH;
178    let mut count = 0usize;
179    for dir in BUNDLE_ALLOWED_DIRS {
180        walk(&root.join(dir), &mut newest, &mut count);
181    }
182    (count > 0).then_some((newest, count))
183}
184
185fn walk(dir: &Path, newest: &mut SystemTime, count: &mut usize) {
186    let Ok(entries) = std::fs::read_dir(dir) else {
187        return;
188    };
189    for entry in entries.flatten() {
190        let path = entry.path();
191        if path.is_dir() {
192            walk(&path, newest, count);
193        } else if let Ok(modified) = entry.metadata().and_then(|meta| meta.modified()) {
194            *count += 1;
195            if modified > *newest {
196                *newest = modified;
197            }
198        }
199    }
200}