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