Skip to main content

systemprompt_loader/bundle/
cache.rs

1//! On-disk layout for fetched and composed bundles.
2//!
3//! Everything under the cache is content-addressed, so a re-fetch of an
4//! unchanged bundle is a no-op and a rollback is a re-point rather than a
5//! download. `current` is a symlink swapped by `rename`, which is atomic on
6//! the same filesystem: a reader either sees the whole previous composition
7//! or the whole new one, never a half-copied tree. State is written the same
8//! way — temp file, then rename.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use systemprompt_models::services::bundle::{
17    BUNDLE_MANIFEST_FILE, ServicesBundleState, SignedBundleManifest,
18};
19
20use super::error::{BundleError, BundleResult};
21
22const STATE_FILE: &str = "state.json";
23const CURRENT_LINK: &str = "current";
24const KEEP_PER_BUNDLE: usize = 2;
25const KEEP_COMPOSED: usize = 2;
26
27#[derive(Debug, Clone)]
28pub struct BundleCache {
29    root: PathBuf,
30}
31
32pub fn discard_staging(path: &Path) {
33    if let Err(error) = fs::remove_dir_all(path) {
34        tracing::warn!(path = %path.display(), error = %error, "Failed to remove staging directory");
35    }
36}
37
38impl BundleCache {
39    #[must_use]
40    pub fn new(root: impl Into<PathBuf>) -> Self {
41        Self { root: root.into() }
42    }
43
44    #[must_use]
45    pub fn root(&self) -> &Path {
46        &self.root
47    }
48
49    pub fn prepare(&self) -> BundleResult<()> {
50        fs::create_dir_all(self.root.join("bundles"))?;
51        fs::create_dir_all(self.root.join("composed"))?;
52        Ok(())
53    }
54
55    #[must_use]
56    pub fn bundle_dir(&self, name: &str, content_hash: &str) -> PathBuf {
57        self.root.join("bundles").join(name).join(content_hash)
58    }
59
60    #[must_use]
61    pub fn composed_dir(&self, composed_hash: &str) -> PathBuf {
62        self.root.join("composed").join(composed_hash)
63    }
64
65    #[must_use]
66    pub fn current_link(&self) -> PathBuf {
67        self.root.join(CURRENT_LINK)
68    }
69
70    #[must_use]
71    pub fn current_root(&self) -> Option<PathBuf> {
72        let link = self.current_link();
73        link.exists().then_some(link)
74    }
75
76    #[must_use]
77    pub fn state_path(&self) -> PathBuf {
78        self.root.join(STATE_FILE)
79    }
80
81    #[must_use]
82    pub fn read_state(&self) -> ServicesBundleState {
83        fs::read_to_string(self.state_path())
84            .ok()
85            .and_then(|raw| serde_json::from_str(&raw).ok())
86            .unwrap_or_default()
87    }
88
89    pub fn read_manifest(
90        &self,
91        name: &str,
92        content_hash: &str,
93    ) -> BundleResult<SignedBundleManifest> {
94        let path = self
95            .bundle_dir(name, content_hash)
96            .join(BUNDLE_MANIFEST_FILE);
97        let raw = fs::read_to_string(&path)?;
98        serde_json::from_str(&raw).map_err(|e| {
99            BundleError::policy(format!("cached bundle.json for {name} does not parse: {e}"))
100        })
101    }
102
103    pub fn write_state(&self, state: &ServicesBundleState) -> BundleResult<()> {
104        self.prepare()?;
105        let body = serde_json::to_vec_pretty(state)
106            .map_err(|e| BundleError::policy(format!("state is not serialisable: {e}")))?;
107        let tmp = self
108            .root
109            .join(format!("{STATE_FILE}.tmp-{}", std::process::id()));
110        fs::write(&tmp, body)?;
111        fs::rename(&tmp, self.state_path())?;
112        Ok(())
113    }
114
115    pub fn swap_current(&self, target: &Path) -> BundleResult<()> {
116        self.prepare()?;
117        let staging = self
118            .root
119            .join(format!("{CURRENT_LINK}.tmp-{}", std::process::id()));
120        if staging.exists() || fs::symlink_metadata(&staging).is_ok() {
121            fs::remove_file(&staging)?;
122        }
123        symlink(target, &staging)?;
124        fs::rename(&staging, self.current_link())?;
125        Ok(())
126    }
127
128    pub fn gc(&self, keep_composed: &str) -> BundleResult<()> {
129        let bundles = self.root.join("bundles");
130        if bundles.is_dir() {
131            for entry in fs::read_dir(&bundles)? {
132                let dir = entry?.path();
133                if dir.is_dir() {
134                    retain_newest(&dir, KEEP_PER_BUNDLE, "")?;
135                }
136            }
137        }
138        let composed = self.root.join("composed");
139        if composed.is_dir() {
140            retain_newest(&composed, KEEP_COMPOSED, keep_composed)?;
141        }
142        Ok(())
143    }
144}
145
146#[cfg(unix)]
147fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
148    std::os::unix::fs::symlink(target, link)
149}
150
151#[cfg(windows)]
152fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
153    std::os::windows::fs::symlink_dir(target, link)
154}
155
156fn retain_newest(dir: &Path, keep: usize, pinned: &str) -> BundleResult<()> {
157    let mut entries: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
158    for entry in fs::read_dir(dir)? {
159        let path = entry?.path();
160        if !path.is_dir() {
161            continue;
162        }
163        let modified = path
164            .metadata()
165            .and_then(|m| m.modified())
166            .unwrap_or(std::time::UNIX_EPOCH);
167        entries.push((modified, path));
168    }
169    entries.sort_by_key(|e| std::cmp::Reverse(e.0));
170
171    for (_time, path) in entries.into_iter().skip(keep) {
172        let is_pinned = path
173            .file_name()
174            .is_some_and(|n| !pinned.is_empty() && n == pinned);
175        if is_pinned {
176            continue;
177        }
178        if let Err(e) = fs::remove_dir_all(&path) {
179            tracing::warn!(path = %path.display(), error = %e, "Failed to prune cached bundle");
180        }
181    }
182    Ok(())
183}