Skip to main content

kernel/artifacts/
store.rs

1//! The content-addressed artifact store: `<year>/<slug>_<hash12>.<ext>` outputs
2//! (deduplicated by content), `blobs/<hash>` previews, and `<year>/<id>.json`
3//! provenance sidecars. The in-memory index is rebuilt by scanning the sidecars.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use sha2::{Digest, Sha256};
9
10use crate::persistence;
11use crate::time::now_millis;
12
13use super::artifact::{Artifact, ArtifactDraft};
14
15/// Errors from the artifact store.
16#[derive(Debug, thiserror::Error)]
17pub enum ArtifactStoreError {
18    /// No artifact with the given id is stored.
19    #[error("no artifact with id {0} is stored")]
20    NotFound(String),
21    /// A filesystem operation failed.
22    #[error("artifact store io error: {0}")]
23    Io(#[from] std::io::Error),
24    /// Writing a provenance sidecar failed.
25    #[error("artifact sidecar error: {0}")]
26    Sidecar(#[from] persistence::StoreError),
27}
28
29/// A content-addressed store of generated outputs, rooted at a directory. The
30/// in-memory index is populated lazily on first access, so read methods take
31/// `&mut self`.
32pub struct ArtifactStore {
33    root: PathBuf,
34    artifacts: HashMap<String, Artifact>,
35    loaded: bool,
36}
37
38impl ArtifactStore {
39    /// A store rooted at `root` (created lazily on first write).
40    pub fn new(root: &Path) -> Self {
41        Self {
42            root: root.to_path_buf(),
43            artifacts: HashMap::new(),
44            loaded: false,
45        }
46    }
47
48    /// Store `draft`, writing its bytes (deduplicated), optional preview, and a
49    /// provenance sidecar, and return the resulting record.
50    pub fn store(&mut self, draft: ArtifactDraft) -> Result<Artifact, ArtifactStoreError> {
51        self.load_if_needed()?;
52        let created_at = now_millis();
53        let hash = hex::encode(Sha256::digest(&draft.data));
54        let slug = slug(&draft.model);
55        let year = year_of(created_at);
56        let path = format!("{year}/{slug}_{}.{}", &hash[..12], draft.file_extension);
57        self.write_if_absent(&draft.data, &path)?;
58        let preview_path = match &draft.preview {
59            Some(preview) => Some(self.spill(preview)?),
60            None => None,
61        };
62        let artifact = Artifact {
63            id: self.unique_id(&slug, &hash, &draft.job_id),
64            path,
65            content_hash: hash,
66            preview_path,
67            model: draft.model,
68            model_id: draft.model_id,
69            runtime: draft.runtime,
70            capability: draft.capability,
71            params: draft.params,
72            created_at,
73            duration_ms: draft.duration_ms,
74            job_id: draft.job_id,
75            session_id: draft.session_id,
76        };
77        self.write_sidecar(&artifact)?;
78        self.artifacts.insert(artifact.id.clone(), artifact.clone());
79        Ok(artifact)
80    }
81
82    /// Every artifact, newest first by `(created_at, id)`.
83    pub fn list(&mut self) -> Result<Vec<Artifact>, ArtifactStoreError> {
84        self.load_if_needed()?;
85        let mut artifacts: Vec<Artifact> = self.artifacts.values().cloned().collect();
86        artifacts.sort_by(super::gallery::newest);
87        Ok(artifacts)
88    }
89
90    /// The artifact with `id`, if stored.
91    pub fn get(&mut self, id: &str) -> Result<Option<Artifact>, ArtifactStoreError> {
92        self.load_if_needed()?;
93        Ok(self.artifacts.get(id).cloned())
94    }
95
96    /// The absolute path to `id`'s output file, if stored.
97    pub fn url(&mut self, id: &str) -> Result<Option<PathBuf>, ArtifactStoreError> {
98        self.load_if_needed()?;
99        Ok(self
100            .artifacts
101            .get(id)
102            .map(|artifact| self.root.join(&artifact.path)))
103    }
104
105    /// The bytes of `id`'s preview, if it has one.
106    pub fn preview_data(&mut self, id: &str) -> Result<Option<Vec<u8>>, ArtifactStoreError> {
107        self.load_if_needed()?;
108        let Some(preview_path) = self.artifacts.get(id).and_then(|a| a.preview_path.clone()) else {
109            return Ok(None);
110        };
111        Ok(Some(std::fs::read(self.root.join(preview_path))?))
112    }
113
114    /// Delete `id`: remove its sidecar always, and its output/preview files only
115    /// when no surviving artifact still references them (deduplicated files stay
116    /// until their last owner is gone). Files are unlinked permanently.
117    pub fn delete(&mut self, id: &str) -> Result<(), ArtifactStoreError> {
118        self.load_if_needed()?;
119        let Some(artifact) = self.artifacts.remove(id) else {
120            return Err(ArtifactStoreError::NotFound(id.to_owned()));
121        };
122        remove_if_present(&self.sidecar_path(&artifact))?;
123        if !self
124            .artifacts
125            .values()
126            .any(|other| other.path == artifact.path)
127        {
128            remove_if_present(&self.root.join(&artifact.path))?;
129        }
130        if let Some(preview_path) = &artifact.preview_path
131            && !self
132                .artifacts
133                .values()
134                .any(|other| other.preview_path.as_deref() == Some(preview_path))
135        {
136            remove_if_present(&self.root.join(preview_path))?;
137        }
138        Ok(())
139    }
140
141    fn write_if_absent(&self, data: &[u8], path: &str) -> Result<(), ArtifactStoreError> {
142        let url = self.root.join(path);
143        if url.exists() {
144            return Ok(());
145        }
146        persistence::write_atomic(&url, data)?;
147        Ok(())
148    }
149
150    fn spill(&self, preview: &[u8]) -> Result<String, ArtifactStoreError> {
151        let hash = hex::encode(Sha256::digest(preview));
152        let path = format!("blobs/{hash}");
153        self.write_if_absent(preview, &path)?;
154        Ok(path)
155    }
156
157    fn unique_id(&self, slug: &str, hash: &str, job_id: &str) -> String {
158        let job_prefix: String = job_id.chars().take(8).collect::<String>().to_lowercase();
159        let base = format!("{slug}_{}_{}", &hash[..12], job_prefix);
160        if !self.artifacts.contains_key(&base) {
161            return base;
162        }
163        let mut counter = 2;
164        while self.artifacts.contains_key(&format!("{base}-{counter}")) {
165            counter += 1;
166        }
167        format!("{base}-{counter}")
168    }
169
170    fn sidecar_path(&self, artifact: &Artifact) -> PathBuf {
171        self.root
172            .join(year_of(artifact.created_at).to_string())
173            .join(format!("{}.json", artifact.id))
174    }
175
176    fn write_sidecar(&self, artifact: &Artifact) -> Result<(), ArtifactStoreError> {
177        persistence::write_json_atomic(&self.sidecar_path(artifact), artifact)?;
178        Ok(())
179    }
180
181    fn load_if_needed(&mut self) -> Result<(), ArtifactStoreError> {
182        if self.loaded {
183            return Ok(());
184        }
185        self.loaded = true;
186        let Ok(entries) = std::fs::read_dir(&self.root) else {
187            return Ok(());
188        };
189        let mut scanned = HashMap::new();
190        for entry in entries.flatten() {
191            let path = entry.path();
192            if !path.is_dir() || !is_year_dir(&path) {
193                continue;
194            }
195            let Ok(sidecars) = std::fs::read_dir(&path) else {
196                continue;
197            };
198            for sidecar in sidecars.flatten() {
199                let sidecar_path = sidecar.path();
200                if sidecar_path.extension().and_then(|ext| ext.to_str()) != Some("json") {
201                    continue;
202                }
203                match persistence::read_json::<Artifact>(&sidecar_path) {
204                    Ok(Some(artifact)) => {
205                        scanned.insert(artifact.id.clone(), artifact);
206                    }
207                    // Missing/corrupt sidecar (quarantined inside read_json) is skipped.
208                    _ => continue,
209                }
210            }
211        }
212        self.artifacts = scanned;
213        Ok(())
214    }
215}
216
217fn remove_if_present(path: &Path) -> Result<(), ArtifactStoreError> {
218    match std::fs::remove_file(path) {
219        Ok(()) => Ok(()),
220        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
221        Err(err) => Err(err.into()),
222    }
223}
224
225fn is_year_dir(path: &Path) -> bool {
226    path.file_name()
227        .and_then(|name| name.to_str())
228        .is_some_and(|name| name.parse::<i64>().is_ok())
229}
230
231fn slug(name: &str) -> String {
232    let kept: String = name
233        .to_lowercase()
234        .chars()
235        .filter(|c| c.is_alphanumeric())
236        .take(24)
237        .collect();
238    if kept.is_empty() {
239        "artifact".to_owned()
240    } else {
241        kept
242    }
243}
244
245/// The Gregorian year (UTC) for an epoch-millisecond timestamp. The year is in
246/// UTC, so an artifact created near midnight in an offset zone can land in a
247/// different year directory than the machine's local calendar would pick. This is
248/// cosmetic — the store stays self-consistent and `load_if_needed` scans every
249/// year dir.
250fn year_of(millis: i64) -> i64 {
251    crate::time::civil_from_days(millis.div_euclid(86_400_000)).0
252}