kernel/artifacts/
store.rs1use 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#[derive(Debug, thiserror::Error)]
17pub enum ArtifactStoreError {
18 #[error("no artifact with id {0} is stored")]
20 NotFound(String),
21 #[error("artifact store io error: {0}")]
23 Io(#[from] std::io::Error),
24 #[error("artifact sidecar error: {0}")]
26 Sidecar(#[from] persistence::StoreError),
27}
28
29pub struct ArtifactStore {
33 root: PathBuf,
34 artifacts: HashMap<String, Artifact>,
35 loaded: bool,
36}
37
38impl ArtifactStore {
39 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 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 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 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 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 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 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 _ => 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
245fn year_of(millis: i64) -> i64 {
251 crate::time::civil_from_days(millis.div_euclid(86_400_000)).0
252}