1use std::fmt;
4use std::path::{Component, Path, PathBuf};
5
6use serde_json::Value as JsonValue;
7use sha2::{Digest, Sha256};
8
9use super::types::{RunRecord, RunTranscriptArtifactDescriptor, RunTranscriptPointerRecord};
10
11const DESCRIPTOR_SCHEMA_VERSION: &str = "harn.llm_transcript_artifact.v1";
12const ARTIFACT_KIND: &str = "llm_jsonl";
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct LlmTranscriptDescriptorError {
16 pub kind: &'static str,
17 pub message: String,
18}
19
20impl LlmTranscriptDescriptorError {
21 fn new(kind: &'static str, message: impl Into<String>) -> Self {
22 Self {
23 kind,
24 message: message.into(),
25 }
26 }
27}
28
29impl fmt::Display for LlmTranscriptDescriptorError {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(f, "{}: {}", self.kind, self.message)
32 }
33}
34
35impl std::error::Error for LlmTranscriptDescriptorError {}
36
37#[derive(Default)]
38struct DescriptorScan {
39 event_count: usize,
40 first_event_type: Option<String>,
41 first_event_id: Option<String>,
42 last_event_type: Option<String>,
43 last_event_id: Option<String>,
44 complete: bool,
45 terminal_status: Option<String>,
46 session_id: Option<String>,
47 effective_tool_format: Option<String>,
48 tool_schema_hash: Option<String>,
49}
50
51pub fn describe_llm_transcript_sidecar(
52 run: &RunRecord,
53 run_record_path: &Path,
54 transcript_path: &Path,
55) -> Result<RunTranscriptArtifactDescriptor, LlmTranscriptDescriptorError> {
56 let bytes = std::fs::read(transcript_path).map_err(|error| {
57 LlmTranscriptDescriptorError::new(
58 "unavailable",
59 format!("failed to read {}: {error}", transcript_path.display()),
60 )
61 })?;
62 let scan = scan_jsonl(&bytes, transcript_path)?;
63 let sha256 = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
64 let relative_path = relative_to_run_record(run_record_path, transcript_path);
65 Ok(RunTranscriptArtifactDescriptor {
66 schema_version: DESCRIPTOR_SCHEMA_VERSION.to_string(),
67 artifact_kind: ARTIFACT_KIND.to_string(),
68 run_id: run.id.clone(),
69 session_id: scan.session_id,
70 path: transcript_path.to_string_lossy().into_owned(),
71 relative_path,
72 sha256,
73 byte_len: bytes.len() as u64,
74 event_count: scan.event_count,
75 first_event_type: scan.first_event_type,
76 first_event_id: scan.first_event_id,
77 last_event_type: scan.last_event_type,
78 last_event_id: scan.last_event_id,
79 complete: scan.complete,
80 terminal_status: scan.terminal_status,
81 effective_tool_format: scan.effective_tool_format,
82 tool_schema_hash: scan.tool_schema_hash,
83 })
84}
85
86pub fn pointer_for_llm_transcript_sidecar(
87 run: &RunRecord,
88 run_record_path: &Path,
89 transcript_path: &Path,
90) -> RunTranscriptPointerRecord {
91 match describe_llm_transcript_sidecar(run, run_record_path, transcript_path) {
92 Ok(descriptor) => RunTranscriptPointerRecord {
93 id: "run:llm_transcript".to_string(),
94 label: "LLM transcript sidecar".to_string(),
95 kind: ARTIFACT_KIND.to_string(),
96 location: "run sidecar".to_string(),
97 path: Some(transcript_path.to_string_lossy().into_owned()),
98 available: true,
99 verification_status: if descriptor.complete {
100 "verified".to_string()
101 } else {
102 "incomplete".to_string()
103 },
104 verification_error: None,
105 descriptor: Some(descriptor),
106 },
107 Err(error) => RunTranscriptPointerRecord {
108 id: "run:llm_transcript".to_string(),
109 label: "LLM transcript sidecar".to_string(),
110 kind: ARTIFACT_KIND.to_string(),
111 location: "run sidecar".to_string(),
112 path: Some(transcript_path.to_string_lossy().into_owned()),
113 available: false,
114 verification_status: error.kind.to_string(),
115 verification_error: Some(error.message),
116 descriptor: None,
117 },
118 }
119}
120
121pub fn verified_llm_transcript_pointer_path(
122 run: &RunRecord,
123 run_record_path: &Path,
124) -> Result<PathBuf, LlmTranscriptDescriptorError> {
125 let pointer = run
126 .observability
127 .as_ref()
128 .and_then(|observability| {
129 observability
130 .transcript_pointers
131 .iter()
132 .find(|pointer| pointer.kind == ARTIFACT_KIND)
133 })
134 .ok_or_else(|| {
135 LlmTranscriptDescriptorError::new(
136 "missing_descriptor",
137 "no llm_jsonl transcript pointer",
138 )
139 })?;
140 if pointer.verification_status != "verified" && pointer.verification_status != "incomplete" {
141 return Err(LlmTranscriptDescriptorError::new(
142 "unverified_descriptor",
143 format!(
144 "llm_jsonl transcript pointer status is {}",
145 pointer.verification_status
146 ),
147 ));
148 }
149 let descriptor = pointer.descriptor.as_ref().ok_or_else(|| {
150 LlmTranscriptDescriptorError::new(
151 "missing_descriptor",
152 "llm_jsonl pointer has no descriptor",
153 )
154 })?;
155 validate_descriptor_identity(run, descriptor)?;
156 let path = resolve_descriptor_path(run_record_path, descriptor)?;
157 let actual = describe_llm_transcript_sidecar(run, run_record_path, &path)?;
158 if actual.sha256 != descriptor.sha256 || actual.byte_len != descriptor.byte_len {
159 return Err(LlmTranscriptDescriptorError::new(
160 "digest_mismatch",
161 format!("{} changed since descriptor was written", path.display()),
162 ));
163 }
164 Ok(path)
165}
166
167fn validate_descriptor_identity(
168 run: &RunRecord,
169 descriptor: &RunTranscriptArtifactDescriptor,
170) -> Result<(), LlmTranscriptDescriptorError> {
171 if descriptor.schema_version != DESCRIPTOR_SCHEMA_VERSION {
172 return Err(LlmTranscriptDescriptorError::new(
173 "schema_mismatch",
174 format!(
175 "llm_jsonl descriptor schema is {}",
176 descriptor.schema_version
177 ),
178 ));
179 }
180 if descriptor.artifact_kind != ARTIFACT_KIND {
181 return Err(LlmTranscriptDescriptorError::new(
182 "artifact_kind_mismatch",
183 format!(
184 "llm_jsonl descriptor artifact kind is {}",
185 descriptor.artifact_kind
186 ),
187 ));
188 }
189 if descriptor.run_id != run.id {
190 return Err(LlmTranscriptDescriptorError::new(
191 "run_id_mismatch",
192 format!(
193 "llm_jsonl descriptor is for run {}, not {}",
194 descriptor.run_id, run.id
195 ),
196 ));
197 }
198 Ok(())
199}
200
201fn scan_jsonl(
202 bytes: &[u8],
203 transcript_path: &Path,
204) -> Result<DescriptorScan, LlmTranscriptDescriptorError> {
205 let text = std::str::from_utf8(bytes).map_err(|error| {
206 LlmTranscriptDescriptorError::new(
207 "invalid_utf8",
208 format!("{} is not UTF-8 JSONL: {error}", transcript_path.display()),
209 )
210 })?;
211 let mut scan = DescriptorScan::default();
212 for (idx, raw_line) in text.lines().enumerate() {
213 let line = raw_line.trim();
214 if line.is_empty() {
215 continue;
216 }
217 let event: JsonValue = serde_json::from_str(line).map_err(|error| {
218 LlmTranscriptDescriptorError::new(
219 "malformed_jsonl",
220 format!(
221 "{}:{} failed to parse JSONL row: {error}",
222 transcript_path.display(),
223 idx + 1
224 ),
225 )
226 })?;
227 let event_type = event
228 .get("type")
229 .and_then(|value| value.as_str())
230 .unwrap_or("")
231 .to_string();
232 let event_id = event_identity(&event, scan.event_count + 1);
233 if scan.first_event_type.is_none() {
234 scan.first_event_type = Some(event_type.clone());
235 scan.first_event_id = Some(event_id.clone());
236 }
237 scan.last_event_type = Some(event_type.clone());
238 scan.last_event_id = Some(event_id);
239 scan.event_count += 1;
240 update_scan_facts(&mut scan, &event_type, &event);
241 }
242 if scan.event_count == 0 {
243 return Err(LlmTranscriptDescriptorError::new(
244 "empty_transcript",
245 format!("{} contains no JSONL events", transcript_path.display()),
246 ));
247 }
248 Ok(scan)
249}
250
251fn event_identity(event: &JsonValue, ordinal: usize) -> String {
252 event
253 .get("id")
254 .or_else(|| event.get("event_id"))
255 .or_else(|| event.get("call_id"))
256 .and_then(|value| value.as_str())
257 .map(str::to_string)
258 .unwrap_or_else(|| format!("row:{ordinal}"))
259}
260
261fn update_scan_facts(scan: &mut DescriptorScan, event_type: &str, event: &JsonValue) {
262 if scan.session_id.is_none() {
263 scan.session_id = event
264 .get("session_id")
265 .and_then(|value| value.as_str())
266 .map(str::to_string);
267 }
268 if scan.effective_tool_format.is_none() {
269 scan.effective_tool_format = event
270 .get("tool_format")
271 .and_then(|value| value.as_str())
272 .filter(|value| !value.is_empty())
273 .map(str::to_string);
274 }
275 if event_type == "tool_schemas" {
276 scan.tool_schema_hash = event
277 .get("hash")
278 .map(|value| match value {
279 JsonValue::String(value) => value.clone(),
280 other => other.to_string(),
281 })
282 .filter(|value| !value.is_empty());
283 }
284 if is_terminal_event(event_type, event) {
285 scan.complete = true;
286 scan.terminal_status = event
287 .get("final_status")
288 .or_else(|| event.get("status"))
289 .or_else(|| event.get("stop_reason"))
290 .and_then(|value| value.as_str())
291 .map(str::to_string)
292 .or_else(|| Some(event_type.to_string()));
293 }
294}
295
296fn is_terminal_event(event_type: &str, event: &JsonValue) -> bool {
297 matches!(
298 event_type,
299 "agent_session_finalized" | "agent_session_terminal" | "run_terminal" | "terminal"
300 ) || event
301 .get("terminal")
302 .and_then(|value| value.as_bool())
303 .unwrap_or(false)
304}
305
306fn relative_to_run_record(run_record_path: &Path, transcript_path: &Path) -> Option<String> {
307 let parent = run_record_path.parent()?;
308 transcript_path
309 .strip_prefix(parent)
310 .ok()
311 .map(|path| path.to_string_lossy().into_owned())
312}
313
314fn resolve_descriptor_path(
315 run_record_path: &Path,
316 descriptor: &RunTranscriptArtifactDescriptor,
317) -> Result<PathBuf, LlmTranscriptDescriptorError> {
318 let parent = run_record_path.parent().ok_or_else(|| {
319 LlmTranscriptDescriptorError::new("invalid_run_path", "run record path has no parent")
320 })?;
321 if let Some(relative) = descriptor
322 .relative_path
323 .as_deref()
324 .filter(|value| !value.is_empty())
325 {
326 let relative_path = Path::new(relative);
327 if relative_path.is_absolute()
328 || relative_path
329 .components()
330 .any(|component| matches!(component, Component::ParentDir))
331 {
332 return Err(LlmTranscriptDescriptorError::new(
333 "path_traversal",
334 "descriptor relative_path must stay inside the run directory",
335 ));
336 }
337 return Ok(parent.join(relative_path));
338 }
339 let fallback_path = Path::new(&descriptor.path);
340 if descriptor.path.is_empty() {
341 return Err(LlmTranscriptDescriptorError::new(
342 "missing_path",
343 "llm_jsonl descriptor has no path",
344 ));
345 }
346 if fallback_path.is_absolute() {
347 if fallback_path.strip_prefix(parent).is_err() {
348 return Err(LlmTranscriptDescriptorError::new(
349 "path_traversal",
350 "descriptor path must stay inside the run directory",
351 ));
352 }
353 return Ok(fallback_path.to_path_buf());
354 }
355 if fallback_path
356 .components()
357 .any(|component| matches!(component, Component::ParentDir))
358 {
359 return Err(LlmTranscriptDescriptorError::new(
360 "path_traversal",
361 "descriptor path must stay inside the run directory",
362 ));
363 }
364 Ok(parent.join(fallback_path))
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 fn run(id: &str) -> RunRecord {
372 RunRecord {
373 id: id.to_string(),
374 ..Default::default()
375 }
376 }
377
378 #[test]
379 fn descriptor_hashes_and_scans_strict_jsonl() {
380 let temp = tempfile::tempdir().unwrap();
381 let run_path = temp.path().join("run.json");
382 let transcript_path = temp.path().join("agent-llm/llm_transcript.jsonl");
383 std::fs::create_dir_all(transcript_path.parent().unwrap()).unwrap();
384 std::fs::write(
385 &transcript_path,
386 concat!(
387 "{\"type\":\"system_prompt\",\"content\":\"x\",\"hash\":1,\"session_id\":\"s1\"}\n",
388 "{\"type\":\"tool_schemas\",\"hash\":\"schema-1\",\"schemas\":[]}\n",
389 "{\"type\":\"provider_call_request\",\"call_id\":\"call-1\",\"tool_format\":\"native\"}\n",
390 "{\"type\":\"agent_session_finalized\",\"final_status\":\"done\",\"terminal\":true}\n",
391 ),
392 )
393 .unwrap();
394
395 let descriptor =
396 describe_llm_transcript_sidecar(&run("run-1"), &run_path, &transcript_path).unwrap();
397 assert_eq!(descriptor.schema_version, DESCRIPTOR_SCHEMA_VERSION);
398 assert_eq!(descriptor.run_id, "run-1");
399 assert_eq!(descriptor.session_id.as_deref(), Some("s1"));
400 assert_eq!(
401 descriptor.relative_path.as_deref(),
402 Some("agent-llm/llm_transcript.jsonl")
403 );
404 assert_eq!(descriptor.event_count, 4);
405 assert_eq!(
406 descriptor.first_event_type.as_deref(),
407 Some("system_prompt")
408 );
409 assert_eq!(
410 descriptor.last_event_type.as_deref(),
411 Some("agent_session_finalized")
412 );
413 assert_eq!(descriptor.effective_tool_format.as_deref(), Some("native"));
414 assert_eq!(descriptor.tool_schema_hash.as_deref(), Some("schema-1"));
415 assert!(descriptor.complete);
416 assert!(descriptor.sha256.starts_with("sha256:"));
417 assert_eq!(
418 descriptor.byte_len,
419 std::fs::metadata(&transcript_path).unwrap().len()
420 );
421 }
422
423 #[test]
424 fn descriptor_rejects_malformed_jsonl() {
425 let temp = tempfile::tempdir().unwrap();
426 let run_path = temp.path().join("run.json");
427 let transcript_path = temp.path().join("agent-llm/llm_transcript.jsonl");
428 std::fs::create_dir_all(transcript_path.parent().unwrap()).unwrap();
429 std::fs::write(&transcript_path, "{\"type\":\"system_prompt\"}\nnot-json\n").unwrap();
430
431 let error = describe_llm_transcript_sidecar(&run("run-1"), &run_path, &transcript_path)
432 .unwrap_err();
433 assert_eq!(error.kind, "malformed_jsonl");
434 }
435
436 #[test]
437 fn verified_pointer_rejects_mutated_sidecar() {
438 let temp = tempfile::tempdir().unwrap();
439 let run_path = temp.path().join("run.json");
440 let transcript_path = temp.path().join("agent-llm/llm_transcript.jsonl");
441 std::fs::create_dir_all(transcript_path.parent().unwrap()).unwrap();
442 std::fs::write(
443 &transcript_path,
444 "{\"type\":\"agent_session_finalized\",\"terminal\":true}\n",
445 )
446 .unwrap();
447 let descriptor =
448 describe_llm_transcript_sidecar(&run("run-1"), &run_path, &transcript_path).unwrap();
449 let mut run = run("run-1");
450 run.observability = Some(super::super::types::RunObservabilityRecord {
451 transcript_pointers: vec![RunTranscriptPointerRecord {
452 kind: ARTIFACT_KIND.to_string(),
453 verification_status: "verified".to_string(),
454 descriptor: Some(descriptor),
455 ..Default::default()
456 }],
457 ..Default::default()
458 });
459 std::fs::write(
460 &transcript_path,
461 "{\"type\":\"agent_session_finalized\",\"terminal\":true} \n",
462 )
463 .unwrap();
464
465 let error = verified_llm_transcript_pointer_path(&run, &run_path).unwrap_err();
466 assert_eq!(error.kind, "digest_mismatch");
467 }
468
469 #[test]
470 fn verified_pointer_accepts_legacy_absolute_path_inside_run_dir() {
471 let temp = tempfile::tempdir().unwrap();
472 let run_path = temp.path().join("run.json");
473 let transcript_path = temp.path().join("agent-llm/llm_transcript.jsonl");
474 std::fs::create_dir_all(transcript_path.parent().unwrap()).unwrap();
475 std::fs::write(
476 &transcript_path,
477 "{\"type\":\"agent_session_finalized\",\"terminal\":true}\n",
478 )
479 .unwrap();
480 let mut descriptor =
481 describe_llm_transcript_sidecar(&run("run-1"), &run_path, &transcript_path).unwrap();
482 descriptor.relative_path = None;
483 let mut run = run("run-1");
484 run.observability = Some(super::super::types::RunObservabilityRecord {
485 transcript_pointers: vec![RunTranscriptPointerRecord {
486 kind: ARTIFACT_KIND.to_string(),
487 verification_status: "verified".to_string(),
488 descriptor: Some(descriptor),
489 ..Default::default()
490 }],
491 ..Default::default()
492 });
493
494 let path = verified_llm_transcript_pointer_path(&run, &run_path).unwrap();
495 assert_eq!(path, transcript_path);
496 }
497
498 #[test]
499 fn verified_pointer_rejects_fallback_path_outside_run_dir() {
500 let run_dir = tempfile::tempdir().unwrap();
501 let outside = tempfile::tempdir().unwrap();
502 let run_path = run_dir.path().join("run.json");
503 let transcript_path = outside.path().join("llm_transcript.jsonl");
504 std::fs::write(
505 &transcript_path,
506 "{\"type\":\"agent_session_finalized\",\"terminal\":true}\n",
507 )
508 .unwrap();
509 let mut descriptor =
510 describe_llm_transcript_sidecar(&run("run-1"), &run_path, &transcript_path).unwrap();
511 descriptor.relative_path = None;
512 let mut run = run("run-1");
513 run.observability = Some(super::super::types::RunObservabilityRecord {
514 transcript_pointers: vec![RunTranscriptPointerRecord {
515 kind: ARTIFACT_KIND.to_string(),
516 verification_status: "verified".to_string(),
517 descriptor: Some(descriptor),
518 ..Default::default()
519 }],
520 ..Default::default()
521 });
522
523 let error = verified_llm_transcript_pointer_path(&run, &run_path).unwrap_err();
524 assert_eq!(error.kind, "path_traversal");
525 }
526}