cookie_agent_engine/runtime/
artifact_reads.rs1use cookie_agent_protocol::{ArtifactReadPath, PersistedToolResult, ToolOutputManifest};
2
3use std::sync::Arc;
4
5use super::{Engine, artifacts::ArtifactRouter, blocking_io};
6use crate::ToolError;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct ArtifactReadPage {
10 pub content: String,
11 pub next_offset_lines: Option<u64>,
12 pub source: String,
13}
14
15impl Engine {
16 pub fn read_artifact(
19 &self,
20 path: &str,
21 offset: u64,
22 limit: u64,
23 ) -> Result<ArtifactReadPage, ToolError> {
24 read_artifact(&self.inner.artifacts, path, offset, limit)
25 }
26}
27
28pub(crate) fn read_artifact(
29 store: &ArtifactRouter,
30 path: &str,
31 offset: u64,
32 limit: u64,
33) -> Result<ArtifactReadPage, ToolError> {
34 let target = ArtifactReadPath::parse(path).map_err(ToolError::execution)?;
35 if limit == 0 {
36 return Err(ToolError::execution("artifact page limit must be positive"));
37 }
38 let (digest, source) = if let Some(name) = target.stream {
39 let page = store
40 .read_paged(target.digest.as_str(), 0, 1)
41 .map_err(read_error)?;
42 let manifest: ToolOutputManifest = serde_json::from_str(&page.content)
43 .map_err(|_| ToolError::execution("artifact is not a named-stream manifest"))?;
44 manifest.validate().map_err(ToolError::execution)?;
45 let stream = manifest
46 .streams
47 .into_iter()
48 .find(|stream| stream.name.as_deref() == Some(&name))
49 .ok_or_else(|| {
50 ToolError::execution(format!("artifact has no declared stream {name}"))
51 })?;
52 (stream.sha256, format!("artifact.{name}"))
53 } else {
54 (target.digest, "artifact".into())
55 };
56 let page = store
57 .read_paged(digest.as_str(), offset, limit.min(2_000))
58 .map_err(read_error)?;
59 if page.content.len() > PersistedToolResult::MAX_OUTPUT_BYTES {
60 return Err(ToolError::resource_limit(
61 "artifact page exceeds the 2 MiB output limit; request a smaller page",
62 ));
63 }
64 Ok(ArtifactReadPage {
65 content: page.content,
66 next_offset_lines: page.next_offset_lines,
67 source,
68 })
69}
70
71pub(crate) async fn read_artifact_async(
72 store: Arc<ArtifactRouter>,
73 path: &str,
74 offset: u64,
75 limit: u64,
76) -> Result<ArtifactReadPage, ToolError> {
77 let path = path.to_owned();
78 blocking_io::run(move || read_artifact(&store, &path, offset, limit)).await?
79}
80
81fn read_error(error: std::io::Error) -> ToolError {
82 if error.kind() == std::io::ErrorKind::FileTooLarge {
83 ToolError::resource_limit(error.to_string())
84 } else {
85 ToolError::execution(error.to_string())
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[tokio::test(flavor = "current_thread")]
94 async fn blocked_artifact_verification_leaves_async_tasks_and_other_readers_responsive() {
95 let directory = tempfile::tempdir().unwrap();
96 let store = ArtifactRouter::open_flat(directory.path().join("artifacts")).unwrap();
97 let (_, first) = store.retain(crate::test_session_id(), b"first\n").unwrap();
98 let (_, second) = store.retain(crate::test_session_id(), b"second\n").unwrap();
99 let (entered, release) =
100 blocking_io::gate(store.io_test_hook(), "read", Some(first.clone()));
101 let reader = store.clone();
102 let blocked = tokio::spawn(async move {
103 read_artifact_async(reader, &format!("artifact://{first}"), 0, 1).await
104 });
105 tokio::time::timeout(std::time::Duration::from_secs(10), entered)
106 .await
107 .unwrap()
108 .unwrap();
109 assert!(!blocked.is_finished());
110 assert_eq!(tokio::spawn(async { 7 }).await.unwrap(), 7);
111 let other = tokio::time::timeout(
112 std::time::Duration::from_secs(10),
113 read_artifact_async(store, &format!("artifact://{second}"), 0, 1),
114 )
115 .await
116 .unwrap()
117 .unwrap();
118 assert_eq!(other.content, "second\n");
119 assert!(
120 !blocked.is_finished(),
121 "first reader must remain behind its I/O gate"
122 );
123 release.send(()).unwrap();
124 assert_eq!(blocked.await.unwrap().unwrap().content, "first\n");
125 }
126
127 #[tokio::test(flavor = "current_thread")]
128 async fn in_flight_read_does_not_reinsert_a_handle_invalidated_by_gc() {
129 let directory = tempfile::tempdir().unwrap();
130 let sessions = directory.path().join("sessions");
131 std::fs::create_dir(&sessions).unwrap();
132 let store = ArtifactRouter::open_flat(directory.path().join("artifacts")).unwrap();
133 let (_, digest) = store
134 .retain(crate::test_session_id(), b"verified\n")
135 .unwrap();
136 let path = format!("artifact://{digest}");
137 read_artifact_async(store.clone(), &path, 0, 1)
138 .await
139 .unwrap();
140 let (entered, release) = blocking_io::gate(store.io_test_hook(), "read", Some(digest));
141 let reader = store.clone();
142 let reading_path = path.clone();
143 let reading =
144 tokio::spawn(async move { read_artifact_async(reader, &reading_path, 0, 1).await });
145 tokio::time::timeout(std::time::Duration::from_secs(10), entered)
146 .await
147 .unwrap()
148 .unwrap();
149 assert_eq!(
150 store
151 .collect_garbage(std::time::Duration::ZERO)
152 .unwrap()
153 .deleted,
154 1
155 );
156 release.send(()).unwrap();
157 assert_eq!(reading.await.unwrap().unwrap().content, "verified\n");
158 assert!(
159 read_artifact_async(store, &path, 0, 1)
160 .await
161 .unwrap_err()
162 .to_string()
163 .contains("artifact missing")
164 );
165 }
166}