返回 CodeWhale
artifacts.rs
根目录 / crates / tui / src / artifacts.rs
1 //! Session-scoped artifact metadata.
2 //!
3 //! Large tool outputs are written under the owning session directory and saved
4 //! sessions keep a durable metadata index for resume/listing flows.
5
6 use std::io;
7 use std::path::Component;
8 use std::path::Path;
9 use std::path::PathBuf;
10
11 use chrono::{DateTime, Utc};
12 use serde::{Deserialize, Serialize};
13
14 pub const ARTIFACTS_DIR_NAME: &str = "artifacts";
15
16 #[cfg(test)]
17 static TEST_ARTIFACT_SESSIONS_ROOT: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
18
19 #[cfg(test)]
20 pub(crate) static TEST_ARTIFACT_SESSIONS_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
21
22 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23 #[serde(rename_all = "snake_case")]
24 pub enum ArtifactKind {
25 ToolOutput,
26 }
27
28 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29 pub struct ArtifactRecord {
30 pub id: String,
31 pub kind: ArtifactKind,
32 #[serde(default)]
33 pub session_id: String,
34 pub tool_call_id: String,
35 pub tool_name: String,
36 pub created_at: DateTime<Utc>,
37 pub byte_size: u64,
38 pub preview: String,
39 pub storage_path: PathBuf,
40 }
41
42 fn sanitize_id_component(input: &str) -> String {
43 input
44 .chars()
45 .map(|c| {
46 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
47 c
48 } else {
49 '_'
50 }
51 })
52 .collect()
53 }
54
55 pub(crate) fn is_valid_session_id(session_id: &str) -> bool {
56 !session_id.is_empty()
57 && session_id
58 .chars()
59 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
60 }
61
62 #[must_use]
63 pub fn artifact_id_for_tool_call(tool_call_id: &str) -> String {
64 format!("art_{}", sanitize_id_component(tool_call_id))
65 }
66
67 #[must_use]
68 pub fn session_artifact_relative_path(artifact_id: &str) -> PathBuf {
69 PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.txt"))
70 }
71
72 fn session_artifact_relative_path_with_extension(
73 artifact_id: &str,
74 extension: &str,
75 ) -> io::Result<PathBuf> {
76 let artifact_id = sanitize_id_component(artifact_id);
77 let extension = extension.trim_start_matches('.').to_ascii_lowercase();
78 if artifact_id.is_empty()
79 || extension.is_empty()
80 || !extension
81 .chars()
82 .all(|character| character.is_ascii_alphanumeric())
83 {
84 return Err(io::Error::new(
85 io::ErrorKind::InvalidInput,
86 "artifact id and extension must contain safe ASCII characters",
87 ));
88 }
89 Ok(PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.{extension}")))
90 }
91
92 fn artifact_sessions_root() -> Option<PathBuf> {
93 #[cfg(test)]
94 if let Some(root) = TEST_ARTIFACT_SESSIONS_ROOT
95 .lock()
96 .unwrap_or_else(|err| err.into_inner())
97 .clone()
98 {
99 return Some(root);
100 }
101
102 // Use the same state-root authority as saved sessions, including an explicit
103 // CODEWHALE_HOME and legacy read fallback.
104 codewhale_config::resolve_state_dir("sessions").ok()
105 }
106
107 #[cfg(test)]
108 pub(crate) fn set_test_artifact_sessions_root(root: Option<PathBuf>) -> Option<PathBuf> {
109 let mut guard = TEST_ARTIFACT_SESSIONS_ROOT
110 .lock()
111 .unwrap_or_else(|err| err.into_inner());
112 std::mem::replace(&mut *guard, root)
113 }
114
115 #[must_use]
116 pub fn session_artifact_absolute_path(session_id: &str, relative_path: &Path) -> Option<PathBuf> {
117 if !is_valid_session_id(session_id) {
118 return None;
119 }
120 if relative_path.is_absolute()
121 || relative_path
122 .components()
123 .any(|component| matches!(component, Component::ParentDir))
124 {
125 return None;
126 }
127 Some(
128 artifact_sessions_root()?
129 .join(session_id)
130 .join(relative_path),
131 )
132 }
133
134 pub fn write_session_artifact(
135 session_id: &str,
136 artifact_id: &str,
137 content: &str,
138 ) -> io::Result<(PathBuf, PathBuf)> {
139 let relative_path = session_artifact_relative_path(artifact_id);
140 let absolute_path =
141 session_artifact_absolute_path(session_id, &relative_path).ok_or_else(|| {
142 io::Error::new(
143 io::ErrorKind::InvalidInput,
144 "could not resolve session artifact path (missing home directory)",
145 )
146 })?;
147 if let Some(parent) = absolute_path.parent() {
148 std::fs::create_dir_all(parent)?;
149 }
150 crate::utils::write_atomic(&absolute_path, content.as_bytes())?;
151 Ok((absolute_path, relative_path))
152 }
153
154 /// Publish immutable session-owned bytes without replacing an earlier handle.
155 /// A duplicate replay with identical bytes is idempotent; a different payload
156 /// for the same relative path fails closed.
157 pub fn write_session_relative_immutable(
158 session_id: &str,
159 relative_path: &Path,
160 content: &[u8],
161 ) -> io::Result<PathBuf> {
162 let absolute_path =
163 session_artifact_absolute_path(session_id, relative_path).ok_or_else(|| {
164 io::Error::new(io::ErrorKind::InvalidInput, "invalid session artifact path")
165 })?;
166 let destination = open_session_relative(session_id, relative_path, true)?;
167 match destination.publish(content) {
168 Ok(()) => {}
169 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
170 use std::io::Read;
171 let mut existing = Vec::new();
172 destination
173 .open_file()?
174 .take(content.len() as u64 + 1)
175 .read_to_end(&mut existing)?;
176 if existing != content {
177 return Err(io::Error::new(
178 io::ErrorKind::AlreadyExists,
179 "immutable artifact handle already contains different bytes",
180 ));
181 }
182 }
183 Err(err) => return Err(err),
184 }
185 Ok(absolute_path)
186 }
187
188 /// The same confined session directory for mutable sidecars and immutable
189 /// artifacts. The saved-session owner remains responsible for session state.
190 pub(crate) fn open_session_relative(
191 session_id: &str,
192 relative_path: &Path,
193 create: bool,
194 ) -> io::Result<crate::fleet::files::WorkspaceFile> {
195 session_artifact_absolute_path(session_id, relative_path).ok_or_else(|| {
196 io::Error::new(io::ErrorKind::InvalidInput, "invalid session artifact path")
197 })?;
198 let root = artifact_sessions_root()
199 .ok_or_else(|| io::Error::other("session artifact root unavailable"))?;
200 if create {
201 std::fs::create_dir_all(&root)?;
202 }
203 crate::fleet::files::WorkspaceFile::open(
204 &root,
205 &PathBuf::from(session_id).join(relative_path),
206 create,
207 )
208 }
209
210 pub fn write_session_artifact_immutable(
211 session_id: &str,
212 artifact_id: &str,
213 content: &[u8],
214 ) -> io::Result<(PathBuf, PathBuf)> {
215 let relative_path = session_artifact_relative_path(artifact_id);
216 let absolute_path = write_session_relative_immutable(session_id, &relative_path, content)?;
217 Ok((absolute_path, relative_path))
218 }
219
220 /// Write arbitrary fetched bytes into a session artifact with a validated
221 /// extension. Media fetches use this after magic-byte validation.
222 pub fn write_session_artifact_bytes(
223 session_id: &str,
224 artifact_id: &str,
225 extension: &str,
226 content: &[u8],
227 ) -> io::Result<(PathBuf, PathBuf)> {
228 let relative_path = session_artifact_relative_path_with_extension(artifact_id, extension)?;
229 let absolute_path =
230 session_artifact_absolute_path(session_id, &relative_path).ok_or_else(|| {
231 io::Error::new(
232 io::ErrorKind::InvalidInput,
233 "could not resolve session artifact path (missing home directory)",
234 )
235 })?;
236 if let Some(parent) = absolute_path.parent() {
237 std::fs::create_dir_all(parent)?;
238 }
239 crate::utils::write_atomic(&absolute_path, content)?;
240 Ok((absolute_path, relative_path))
241 }
242
243 fn preview_text(content: &str, max_chars: usize) -> String {
244 let mut preview: String = content.chars().take(max_chars).collect();
245 if content.chars().count() > max_chars {
246 preview.push_str("...");
247 }
248 preview
249 }
250
251 pub fn record_tool_output_artifact(
252 session_id: &str,
253 tool_call_id: &str,
254 tool_name: &str,
255 storage_path: impl Into<PathBuf>,
256 content: &str,
257 ) -> ArtifactRecord {
258 let storage_path = storage_path.into();
259 let byte_size = std::fs::metadata(&storage_path)
260 .map(|metadata| metadata.len())
261 .unwrap_or_else(|_| content.len() as u64);
262 record_tool_output_artifact_with_size(
263 session_id,
264 tool_call_id,
265 tool_name,
266 storage_path,
267 byte_size,
268 &preview_text(content, 200),
269 )
270 }
271
272 pub fn record_tool_output_artifact_with_size(
273 session_id: &str,
274 tool_call_id: &str,
275 tool_name: &str,
276 storage_path: impl Into<PathBuf>,
277 byte_size: u64,
278 preview: &str,
279 ) -> ArtifactRecord {
280 ArtifactRecord {
281 id: artifact_id_for_tool_call(tool_call_id),
282 kind: ArtifactKind::ToolOutput,
283 session_id: session_id.to_string(),
284 tool_call_id: tool_call_id.to_string(),
285 tool_name: tool_name.to_string(),
286 created_at: Utc::now(),
287 byte_size,
288 preview: preview_text(preview, 200),
289 storage_path: storage_path.into(),
290 }
291 }
292
293 #[must_use]
294 pub fn format_artifact_relative_path(path: &Path) -> String {
295 path.display().to_string().replace('\\', "/")
296 }
297
298 #[must_use]
299 pub fn format_byte_size(bytes: u64) -> String {
300 const KIB: u64 = 1024;
301 const MIB: u64 = KIB * 1024;
302 if bytes >= MIB {
303 format!("{} MB", bytes.div_ceil(MIB))
304 } else if bytes >= KIB {
305 format!("{} KB", bytes.div_ceil(KIB))
306 } else {
307 format!("{bytes} B")
308 }
309 }
310
311 #[cfg(test)]
312 mod tests {
313 use super::*;
314
315 struct TestArtifactSessionsRoot {
316 prior: Option<PathBuf>,
317 }
318
319 impl Drop for TestArtifactSessionsRoot {
320 fn drop(&mut self) {
321 set_test_artifact_sessions_root(self.prior.take());
322 }
323 }
324
325 fn set_test_sessions_root(root: PathBuf) -> TestArtifactSessionsRoot {
326 TestArtifactSessionsRoot {
327 prior: set_test_artifact_sessions_root(Some(root)),
328 }
329 }
330
331 #[test]
332 fn session_artifact_absolute_path_uses_test_sessions_root() {
333 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
334 .lock()
335 .unwrap_or_else(|err| err.into_inner());
336 let tmp = tempfile::tempdir().unwrap();
337 let _root = set_test_sessions_root(tmp.path().join("sessions"));
338
339 let path = session_artifact_absolute_path(
340 "session-123",
341 &PathBuf::from("artifacts").join("art_call-big.txt"),
342 )
343 .expect("path");
344
345 assert_eq!(
346 path,
347 tmp.path()
348 .join("sessions")
349 .join("session-123")
350 .join("artifacts")
351 .join("art_call-big.txt")
352 );
353 }
354
355 #[test]
356 fn binary_session_artifact_uses_validated_extension_and_exact_bytes() {
357 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
358 .lock()
359 .unwrap_or_else(|err| err.into_inner());
360 let tmp = tempfile::tempdir().unwrap();
361 let _root = set_test_sessions_root(tmp.path().join("sessions"));
362 let bytes = b"\x89PNG\r\n\x1a\nfixture";
363
364 let (absolute, relative) =
365 write_session_artifact_bytes("session-123", "web/media", ".PNG", bytes)
366 .expect("write binary artifact");
367
368 assert_eq!(relative, PathBuf::from("artifacts/web_media.png"));
369 assert_eq!(std::fs::read(absolute).unwrap(), bytes);
370 assert!(write_session_artifact_bytes("session-123", "bad", "../png", bytes).is_err());
371 }
372
373 #[test]
374 fn adaptive_evidence_publication_is_immutable_and_replay_safe() {
375 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
376 .lock()
377 .unwrap_or_else(|err| err.into_inner());
378 let tmp = tempfile::tempdir().unwrap();
379 let _root = set_test_sessions_root(tmp.path().join("sessions"));
380 let relative = PathBuf::from("artifacts/art_call.txt");
381 let bytes = b"first exact payload";
382
383 let first = write_session_relative_immutable("session-a", &relative, bytes).unwrap();
384 let replay = write_session_relative_immutable("session-a", &relative, bytes).unwrap();
385 assert_eq!(first, replay);
386 let err = write_session_relative_immutable("session-a", &relative, b"different")
387 .expect_err("handle aliasing must fail");
388 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
389 assert_eq!(std::fs::read(first).unwrap(), bytes);
390 }
391
392 #[cfg(unix)]
393 #[test]
394 fn immutable_session_artifact_rejects_symlinked_parent() {
395 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
396 .lock()
397 .unwrap_or_else(|err| err.into_inner());
398 let tmp = tempfile::tempdir().unwrap();
399 let sessions = tmp.path().join("sessions");
400 let _root = set_test_sessions_root(sessions.clone());
401 std::fs::create_dir_all(&sessions).unwrap();
402 let outside = tempfile::tempdir().unwrap();
403 std::os::unix::fs::symlink(outside.path(), sessions.join("session-a")).unwrap();
404 assert!(
405 write_session_relative_immutable(
406 "session-a",
407 Path::new("artifacts/handoff.json"),
408 b"private"
409 )
410 .is_err()
411 );
412 assert!(!outside.path().join("artifacts").exists());
413 }
414
415 #[test]
416 fn adaptive_evidence_failed_publication_creates_no_handle() {
417 let _guard = TEST_ARTIFACT_SESSIONS_GUARD
418 .lock()
419 .unwrap_or_else(|err| err.into_inner());
420 let tmp = tempfile::tempdir().unwrap();
421 let sessions = tmp.path().join("sessions");
422 let _root = set_test_sessions_root(sessions.clone());
423 std::fs::create_dir_all(sessions.join("session-a")).unwrap();
424 std::fs::write(sessions.join("session-a/artifacts"), b"block directory").unwrap();
425 let relative = PathBuf::from("artifacts/art_failed.txt");
426
427 assert!(write_session_relative_immutable("session-a", &relative, b"payload").is_err());
428 assert!(!sessions.join("session-a/artifacts/art_failed.txt").exists());
429 }
430 }
431
431 lines RUST