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_manifest::services::bundle::{
17    BUNDLE_MANIFEST_FILE, ServicesBundleState, SignedBundleManifest,
18};
19
20use super::error::{BundleError, BundleResult};
21use systemprompt_identifiers::ContentHash;
22
23const STATE_FILE: &str = "state.json";
24const CURRENT_LINK: &str = "current";
25const KEEP_PER_BUNDLE: usize = 2;
26const KEEP_COMPOSED: usize = 2;
27
28#[derive(Debug, Clone)]
29pub struct BundleCache {
30    root: PathBuf,
31}
32
33pub fn discard_staging(path: &Path) {
34    if let Err(error) = fs::remove_dir_all(path) {
35        tracing::warn!(path = %path.display(), error = %error, "Failed to remove staging directory");
36    }
37}
38
39impl BundleCache {
40    #[must_use]
41    pub fn new(root: impl Into<PathBuf>) -> Self {
42        Self { root: root.into() }
43    }
44
45    #[must_use]
46    pub fn root(&self) -> &Path {
47        &self.root
48    }
49
50    pub fn prepare(&self) -> BundleResult<()> {
51        fs::create_dir_all(self.root.join("bundles"))?;
52        fs::create_dir_all(self.root.join("composed"))?;
53        Ok(())
54    }
55
56    #[must_use]
57    pub fn bundle_dir(&self, name: &str, content_hash: &ContentHash) -> PathBuf {
58        self.root
59            .join("bundles")
60            .join(name)
61            .join(content_hash.as_str())
62    }
63
64    #[must_use]
65    pub fn staging_dir(&self, name: &str, content_hash: &ContentHash) -> PathBuf {
66        self.root
67            .join("bundles")
68            .join(name)
69            .join(format!("{content_hash}.tmp-{}", std::process::id()))
70    }
71
72    #[must_use]
73    pub fn composed_dir(&self, composed_hash: &str) -> PathBuf {
74        self.root.join("composed").join(composed_hash)
75    }
76
77    #[must_use]
78    pub fn current_link(&self) -> PathBuf {
79        self.root.join(CURRENT_LINK)
80    }
81
82    #[must_use]
83    pub fn current_root(&self) -> Option<PathBuf> {
84        let link = self.current_link();
85        link.exists().then_some(link)
86    }
87
88    #[must_use]
89    pub fn state_path(&self) -> PathBuf {
90        self.root.join(STATE_FILE)
91    }
92
93    #[must_use]
94    pub fn read_state(&self) -> ServicesBundleState {
95        fs::read_to_string(self.state_path())
96            .ok()
97            .and_then(|raw| serde_json::from_str(&raw).ok())
98            .unwrap_or_default()
99    }
100
101    pub fn read_manifest(
102        &self,
103        name: &str,
104        content_hash: &ContentHash,
105    ) -> BundleResult<SignedBundleManifest> {
106        let path = self
107            .bundle_dir(name, content_hash)
108            .join(BUNDLE_MANIFEST_FILE);
109        let raw = fs::read_to_string(&path)?;
110        serde_json::from_str(&raw).map_err(|e| {
111            BundleError::policy_context(format!("cached bundle.json for {name} does not parse"), e)
112        })
113    }
114
115    pub fn write_state(&self, state: &ServicesBundleState) -> BundleResult<()> {
116        self.prepare()?;
117        let body = serde_json::to_vec_pretty(state)
118            .map_err(|e| BundleError::policy_context("state is not serialisable", e))?;
119        let tmp = self
120            .root
121            .join(format!("{STATE_FILE}.tmp-{}", std::process::id()));
122        fs::write(&tmp, body)?;
123        fs::rename(&tmp, self.state_path())?;
124        Ok(())
125    }
126
127    pub fn swap_current(&self, target: &Path) -> BundleResult<()> {
128        self.prepare()?;
129        let staging = self
130            .root
131            .join(format!("{CURRENT_LINK}.tmp-{}", std::process::id()));
132        if staging.exists() || fs::symlink_metadata(&staging).is_ok() {
133            fs::remove_file(&staging)?;
134        }
135        symlink(target, &staging)?;
136        fs::rename(&staging, self.current_link())?;
137        Ok(())
138    }
139
140    pub fn gc(&self, keep_composed: &str) -> BundleResult<()> {
141        let bundles = self.root.join("bundles");
142        if bundles.is_dir() {
143            for entry in fs::read_dir(&bundles)? {
144                let dir = entry?.path();
145                if dir.is_dir() {
146                    retain_newest(&dir, KEEP_PER_BUNDLE, "")?;
147                }
148            }
149        }
150        let composed = self.root.join("composed");
151        if composed.is_dir() {
152            retain_newest(&composed, KEEP_COMPOSED, keep_composed)?;
153        }
154        Ok(())
155    }
156}
157
158#[cfg(unix)]
159fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
160    std::os::unix::fs::symlink(target, link)
161}
162
163#[cfg(windows)]
164fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
165    std::os::windows::fs::symlink_dir(target, link)
166}
167
168fn retain_newest(dir: &Path, keep: usize, pinned: &str) -> BundleResult<()> {
169    let mut entries: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
170    for entry in fs::read_dir(dir)? {
171        let path = entry?.path();
172        if !path.is_dir() {
173            continue;
174        }
175        let modified = path
176            .metadata()
177            .and_then(|m| m.modified())
178            .unwrap_or(std::time::UNIX_EPOCH);
179        entries.push((modified, path));
180    }
181    entries.sort_by_key(|e| std::cmp::Reverse(e.0));
182
183    for (_time, path) in entries.into_iter().skip(keep) {
184        let is_pinned = path
185            .file_name()
186            .is_some_and(|n| !pinned.is_empty() && n == pinned);
187        if is_pinned {
188            continue;
189        }
190        if let Err(e) = fs::remove_dir_all(&path) {
191            tracing::warn!(path = %path.display(), error = %e, "Failed to prune cached bundle");
192        }
193    }
194    Ok(())
195}