Skip to main content

harn_vm/orchestration/records/
transcript_descriptor.rs

1//! Verified descriptors for LLM transcript sidecars.
2
3use 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}