systemprompt_loader/bundle/
cache.rs1use 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}