1use std::path::Path;
28
29use kernel::layout::TRANSCRIPT_EXT;
30use kernel::{Address, AxCode, AxError, Locator, RunId};
31use serde_json::Value;
32use storage::Cas;
33
34use crate::conversation::Conversation;
35use crate::redact;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Transcript {
42 run: RunId,
43 lines: Vec<String>,
44 redacted: u32,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct TranscriptRecord {
50 pub address: Address,
51 pub original: Locator,
52 pub redacted: u32,
53}
54
55impl Transcript {
56 pub fn of(run: RunId, conversation: &Conversation) -> Result<Transcript, AxError> {
62 let mut lines = Vec::with_capacity(conversation.messages().len());
63 let mut redacted: u32 = 0;
64 for message in conversation.messages() {
65 let value = serde_json::to_value(message).map_err(|err| {
66 AxError::failure(
67 AxCode::InvalidArgs,
68 "render a transcript line",
69 err.to_string(),
70 )
71 .with_recovery(
72 "report this against runtime::transcript: a conversation message is text \
73 and content blocks, and JSON refuses neither",
74 )
75 })?;
76 let Value::Object(map) = value else {
77 return Err(AxError::failure(
78 AxCode::InvalidArgs,
79 "render a transcript line",
80 "a message is an object",
81 )
82 .with_recovery(
83 "report this against runtime::transcript: a conversation message encodes \
84 as a JSON object and this one encoded as something else",
85 ));
86 };
87 let (scanned, hits) = redact::redact(&map);
88 redacted = redacted.saturating_add(hits);
89 let line = serde_json::to_string(&Value::Object(scanned)).map_err(|err| {
90 AxError::failure(
91 AxCode::InvalidArgs,
92 "render a transcript line",
93 err.to_string(),
94 )
95 .with_recovery(
96 "report this against runtime::transcript: redaction returns the \
97 same JSON object with some strings replaced",
98 )
99 })?;
100 lines.push(line);
101 }
102 Ok(Transcript {
103 run,
104 lines,
105 redacted,
106 })
107 }
108
109 pub fn address(room: &Address, run: RunId) -> Result<Address, AxError> {
115 Address::parse(&format!("{}/{run}.{TRANSCRIPT_EXT}", room.as_str()))
116 }
117
118 pub fn run(&self) -> RunId {
119 self.run
120 }
121
122 pub fn lines(&self) -> &[String] {
123 &self.lines
124 }
125
126 pub fn redacted(&self) -> u32 {
128 self.redacted
129 }
130
131 pub fn bytes(&self) -> Vec<u8> {
133 let mut out = Vec::new();
134 for line in &self.lines {
135 out.extend_from_slice(line.as_bytes());
136 out.push(b'\n');
137 }
138 out
139 }
140
141 pub fn materialise(
152 &self,
153 cas: &mut Cas,
154 city_root: &Path,
155 room: &Address,
156 ) -> Result<TranscriptRecord, AxError> {
157 let bytes = self.bytes();
158 let origin = storage::BlockOrigin {
159 run: self.run,
160 building: room.clone(),
161 };
162 let hash = cas
163 .put_for(&bytes, &origin)
164 .map_err(storage::StorageError::into_ax)?;
165 let original = Locator::cas(hash);
166 let address = Transcript::address(room, self.run)?;
167 let path = city_root.join(address.as_str());
168 let io = |err: std::io::Error| {
169 AxError::failure(
170 AxCode::StorageFatal,
171 "materialise a transcript",
172 format!("{}: {err}", path.display()),
173 )
174 .with_recovery("check the room is writable; the transcript is still in the store")
175 };
176 if !path.exists() {
177 std::fs::write(&path, &bytes).map_err(io)?;
178 let mut perms = std::fs::metadata(&path).map_err(io)?.permissions();
179 perms.set_readonly(true);
180 std::fs::set_permissions(&path, perms).map_err(io)?;
181 }
182 Ok(TranscriptRecord {
183 address,
184 original,
185 redacted: self.redacted,
186 })
187 }
188}
189
190#[cfg(test)]
191#[allow(
192 clippy::unwrap_used,
193 clippy::expect_used,
194 clippy::panic,
195 clippy::indexing_slicing,
196 reason = "test code"
197)]
198mod tests {
199 use super::*;
200 use kernel::ContentBlock;
201
202 fn run() -> RunId {
203 RunId::parse("0198f6a2-7c4a-7bbb-9d1e-000000000009").unwrap()
204 }
205
206 fn window_with_a_call() -> Conversation {
207 let mut conversation = Conversation::new();
208 conversation.push_task_lines("look", "one call", crate::conversation::Opening::FromJob);
209 conversation.push_assistant(vec![ContentBlock::ToolUse {
210 id: "tu_1".to_owned(),
211 name: kernel::ToolName::parse("status").unwrap(),
212 input: kernel::Payload::empty(),
213 }]);
214 conversation.push_tool_results(vec![ContentBlock::ToolResult {
215 tool_use_id: "tu_1".to_owned(),
216 content: "{\"ok\":true}".to_owned(),
217 is_error: false,
218 attachments: Vec::new(),
219 }]);
220 conversation
221 }
222
223 #[test]
224 fn one_line_per_message_in_the_wire_shape() {
225 let transcript = Transcript::of(run(), &window_with_a_call()).unwrap();
226 assert_eq!(transcript.lines().len(), 3);
227 let first: Value = serde_json::from_str(&transcript.lines()[0]).unwrap();
228 assert_eq!(first["role"], "user");
229 assert!(transcript.lines()[1].contains("tu_1"));
230 assert!(transcript.lines()[2].contains("tu_1"));
231 assert_eq!(transcript.redacted(), 0);
232 }
233
234 #[test]
235 fn a_key_the_model_saw_does_not_reach_the_file() {
236 let key = format!("sk-ant-api03-{}", "A".repeat(80));
237 let mut conversation = Conversation::new();
238 conversation.push_tool_results(vec![ContentBlock::ToolResult {
239 tool_use_id: "tu_1".to_owned(),
240 content: format!("token={key}"),
241 is_error: false,
242 attachments: Vec::new(),
243 }]);
244 let transcript = Transcript::of(run(), &conversation).unwrap();
245 assert!(
246 !transcript.lines()[0].contains(&key),
247 "{:?}",
248 transcript.lines()
249 );
250 assert!(transcript.redacted() >= 1);
251 }
252
253 #[test]
254 fn the_address_is_the_room_and_the_run_and_never_the_reserved_subtree() {
255 let room = Address::parse("lab/room1").unwrap();
256 let address = Transcript::address(&room, run()).unwrap();
257 assert_eq!(
258 address.as_str(),
259 "lab/room1/0198f6a2-7c4a-7bbb-9d1e-000000000009.jsonl"
260 );
261 assert!(!address.is_reserved());
262 }
263
264 #[test]
265 fn materialising_pins_then_writes_read_only_and_repeats_are_idempotent() {
266 let dir = tempfile::tempdir().unwrap();
267 let mut cas = Cas::open(&dir.path().join("cas")).unwrap();
268 let room = Address::parse("lab/room1").unwrap();
269 std::fs::create_dir_all(dir.path().join("lab/room1")).unwrap();
270 let transcript = Transcript::of(run(), &window_with_a_call()).unwrap();
271 let record = transcript.materialise(&mut cas, dir.path(), &room).unwrap();
272 let path = dir.path().join(record.address.as_str());
273 assert_eq!(std::fs::read(&path).unwrap(), transcript.bytes());
274 assert!(std::fs::metadata(&path).unwrap().permissions().readonly());
275 assert!(record.original.to_string().starts_with("cas:b3-"));
276 let again = transcript.materialise(&mut cas, dir.path(), &room).unwrap();
277 assert_eq!(again, record);
278 }
279}