Skip to main content

harn_vm/orchestration/records/
persistence.rs

1//! Run-record I/O, child-run materialization, sidecar extraction, and trigger/trace helpers.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use super::super::{
7    default_run_dir, new_id, now_unix_seconds_text, parse_json_payload, sync_run_handoffs,
8    CompactionReceipt, RecapMetrics,
9};
10use super::action_graph::{publish_action_graph_event, refresh_run_observability};
11use super::eval_pack::replay_fixture_from_run;
12use super::json::json_usize;
13use super::types::{
14    run_child_record_from_worker_metadata, CompactionEventRecord, DaemonEventRecord,
15    RunChildRecord, RunHitlQuestionRecord, RunRecord, RunStageRecord,
16};
17use crate::agent_events::AgentEvent;
18use crate::event_log::{
19    active_event_log, sanitize_topic_component, AnyEventLog, EventId, EventLog,
20    LogEvent as EventLogRecord, Topic,
21};
22use crate::llm::vm_value_to_json;
23use crate::triggers::{SignatureStatus, TriggerEvent};
24use crate::value::{VmError, VmValue};
25
26pub(super) fn run_child_from_stage_metadata(stage: &RunStageRecord) -> Option<RunChildRecord> {
27    let parent_stage_id = if stage.id.is_empty() {
28        None
29    } else {
30        Some(stage.id.clone())
31    };
32    run_child_record_from_worker_metadata(parent_stage_id, stage.metadata.get("worker")?)
33}
34
35pub(super) fn fill_missing_child_run_fields(existing: &mut RunChildRecord, child: RunChildRecord) {
36    if existing.worker_name.is_empty() {
37        existing.worker_name = child.worker_name;
38    }
39    if existing.parent_stage_id.is_none() {
40        existing.parent_stage_id = child.parent_stage_id;
41    }
42    if existing.session_id.is_none() {
43        existing.session_id = child.session_id;
44    }
45    if existing.parent_session_id.is_none() {
46        existing.parent_session_id = child.parent_session_id;
47    }
48    if existing.mutation_scope.is_none() {
49        existing.mutation_scope = child.mutation_scope;
50    }
51    if existing.approval_policy.is_none() {
52        existing.approval_policy = child.approval_policy;
53    }
54    if existing.task.is_empty() {
55        existing.task = child.task;
56    }
57    if existing.request.is_none() {
58        existing.request = child.request;
59    }
60    if existing.provenance.is_none() {
61        existing.provenance = child.provenance;
62    }
63    if existing.status.is_empty() {
64        existing.status = child.status;
65    }
66    if existing.started_at.is_empty() {
67        existing.started_at = child.started_at;
68    }
69    if existing.finished_at.is_none() {
70        existing.finished_at = child.finished_at;
71    }
72    if existing.run_id.is_none() {
73        existing.run_id = child.run_id;
74    }
75    if existing.run_path.is_none() {
76        existing.run_path = child.run_path;
77    }
78    if existing.snapshot_path.is_none() {
79        existing.snapshot_path = child.snapshot_path;
80    }
81    if existing.execution.is_none() {
82        existing.execution = child.execution;
83    }
84}
85
86pub(super) fn materialize_child_runs_from_stage_metadata(run: &mut RunRecord) {
87    for child in run.stages.iter().filter_map(run_child_from_stage_metadata) {
88        match run
89            .child_runs
90            .iter_mut()
91            .find(|existing| existing.worker_id == child.worker_id)
92        {
93            Some(existing) => fill_missing_child_run_fields(existing, child),
94            None => run.child_runs.push(child),
95        }
96    }
97}
98
99pub(super) fn read_topic_records(
100    log: &AnyEventLog,
101    topic: &Topic,
102) -> Vec<(crate::event_log::EventId, EventLogRecord)> {
103    let mut from = None;
104    let mut records = Vec::new();
105    loop {
106        let batch =
107            futures::executor::block_on(log.read_range(topic, from, 256)).unwrap_or_default();
108        if batch.is_empty() {
109            break;
110        }
111        from = batch.last().map(|(event_id, _)| *event_id);
112        records.extend(batch);
113    }
114    records
115}
116
117#[derive(Clone, Debug)]
118pub struct AgentSessionReplayEvent {
119    pub event_id: EventId,
120    pub kind: String,
121    pub occurred_at_ms: i64,
122    pub event: AgentEvent,
123}
124
125pub async fn load_agent_session_replay_events(
126    session_id: &str,
127) -> Result<Vec<AgentSessionReplayEvent>, VmError> {
128    let Some(log) = active_event_log() else {
129        return Ok(Vec::new());
130    };
131    load_agent_session_replay_events_from_log(log.as_ref(), session_id).await
132}
133
134pub async fn load_agent_session_replay_events_from_log(
135    log: &AnyEventLog,
136    session_id: &str,
137) -> Result<Vec<AgentSessionReplayEvent>, VmError> {
138    let topic = Topic::new(format!(
139        "observability.agent_events.{}",
140        sanitize_topic_component(session_id)
141    ))
142    .map_err(|error| VmError::Runtime(format!("failed to build agent event topic: {error}")))?;
143
144    let mut events = Vec::new();
145    let mut from = None;
146    loop {
147        let batch = log.read_range(&topic, from, 1024).await.map_err(|error| {
148            VmError::Runtime(format!(
149                "failed to read agent event replay topic {}: {error}",
150                topic.as_str()
151            ))
152        })?;
153        let batch_len = batch.len();
154        for (event_id, record) in batch {
155            from = Some(event_id);
156            if record.headers.get("session_id").map(String::as_str) != Some(session_id) {
157                continue;
158            }
159            let Some(event_value) = record.payload.get("event").cloned() else {
160                continue;
161            };
162            let event = serde_json::from_value::<AgentEvent>(event_value).map_err(|error| {
163                VmError::Runtime(format!(
164                    "failed to decode agent event replay record {event_id}: {error}"
165                ))
166            })?;
167            if event.session_id() == session_id {
168                events.push(AgentSessionReplayEvent {
169                    event_id,
170                    kind: record.kind,
171                    occurred_at_ms: record.occurred_at_ms,
172                    event,
173                });
174            }
175        }
176        if batch_len < 1024 {
177            break;
178        }
179    }
180    Ok(events)
181}
182
183pub(super) fn merge_hitl_questions_from_active_log(run: &mut RunRecord) {
184    let Some(log) = active_event_log() else {
185        return;
186    };
187    let topic = Topic::new(crate::HITL_QUESTIONS_TOPIC)
188        .expect("static hitl.questions topic should always be valid");
189    let mut merged = run
190        .hitl_questions
191        .iter()
192        .cloned()
193        .map(|question| (question.request_id.clone(), question))
194        .collect::<BTreeMap<_, _>>();
195
196    for (_, event) in read_topic_records(log.as_ref(), &topic) {
197        if event.kind != "hitl.question_asked" {
198            continue;
199        }
200        let payload = &event.payload;
201        let matches_run = event
202            .headers
203            .get("run_id")
204            .is_some_and(|value| value == &run.id)
205            || payload
206                .get("run_id")
207                .and_then(|value| value.as_str())
208                .is_some_and(|value| value == run.id);
209        if !matches_run {
210            continue;
211        }
212        let request_id = payload
213            .get("request_id")
214            .and_then(|value| value.as_str())
215            .or_else(|| event.headers.get("request_id").map(String::as_str))
216            .unwrap_or_default();
217        let prompt = payload
218            .get("payload")
219            .and_then(|value| value.get("prompt"))
220            .and_then(|value| value.as_str())
221            .unwrap_or_default();
222        if request_id.is_empty() || prompt.is_empty() {
223            continue;
224        }
225        merged.insert(
226            request_id.to_string(),
227            RunHitlQuestionRecord {
228                request_id: request_id.to_string(),
229                prompt: prompt.to_string(),
230                agent: payload
231                    .get("agent")
232                    .and_then(|value| value.as_str())
233                    .unwrap_or_default()
234                    .to_string(),
235                trace_id: payload
236                    .get("trace_id")
237                    .and_then(|value| value.as_str())
238                    .map(str::to_string),
239                asked_at: payload
240                    .get("requested_at")
241                    .and_then(|value| value.as_str())
242                    .unwrap_or_default()
243                    .to_string(),
244            },
245        );
246    }
247
248    run.hitl_questions = merged.into_values().collect();
249    run.hitl_questions.sort_by(|left, right| {
250        (left.asked_at.as_str(), left.request_id.as_str())
251            .cmp(&(right.asked_at.as_str(), right.request_id.as_str()))
252    });
253}
254
255pub(super) fn signature_status_label(status: &SignatureStatus) -> &'static str {
256    match status {
257        SignatureStatus::Verified => "verified",
258        SignatureStatus::Unsigned => "unsigned",
259        SignatureStatus::Failed { .. } => "failed",
260    }
261}
262
263pub(super) fn trigger_event_from_run(run: &RunRecord) -> Option<TriggerEvent> {
264    run.metadata
265        .get("trigger_event")
266        .cloned()
267        .and_then(|value| serde_json::from_value(value).ok())
268}
269
270pub(super) fn run_trace_id(
271    run: &RunRecord,
272    trigger_event: Option<&TriggerEvent>,
273) -> Option<String> {
274    trigger_event
275        .map(|event| event.trace_id.0.clone())
276        .or_else(|| {
277            run.metadata
278                .get("trace_id")
279                .and_then(|value| value.as_str())
280                .map(str::to_string)
281        })
282}
283
284pub(super) fn replay_of_event_id_from_run(run: &RunRecord) -> Option<String> {
285    run.metadata
286        .get("replay_of_event_id")
287        .and_then(|value| value.as_str())
288        .map(str::to_string)
289}
290
291pub(super) fn llm_transcript_sidecar_path(run_path: &Path) -> Option<PathBuf> {
292    let stem = run_path.file_stem()?.to_str()?;
293    let parent = run_path.parent().unwrap_or_else(|| Path::new("."));
294    Some(parent.join(format!("{stem}-llm/llm_transcript.jsonl")))
295}
296
297pub(super) fn compaction_events_from_transcript(
298    transcript: &serde_json::Value,
299    stage_id: Option<&str>,
300    node_id: Option<&str>,
301    location_prefix: &str,
302    persisted_path: Option<&Path>,
303) -> Vec<CompactionEventRecord> {
304    use std::collections::BTreeSet;
305    let transcript_id = transcript
306        .get("id")
307        .and_then(|value| value.as_str())
308        .map(str::to_string);
309    let asset_ids = transcript
310        .get("assets")
311        .and_then(|value| value.as_array())
312        .map(|assets| {
313            assets
314                .iter()
315                .filter_map(|asset| {
316                    asset
317                        .get("id")
318                        .and_then(|value| value.as_str())
319                        .map(str::to_string)
320                })
321                .collect::<BTreeSet<_>>()
322        })
323        .unwrap_or_default();
324    transcript
325        .get("events")
326        .and_then(|value| value.as_array())
327        .map(|events| {
328            events
329                .iter()
330                .filter(|event| {
331                    event.get("kind").and_then(|value| value.as_str()) == Some("compaction")
332                })
333                .map(|event| {
334                    let event_id = event
335                        .get("id")
336                        .and_then(|value| value.as_str())
337                        .unwrap_or_default()
338                        .to_string();
339                    compaction_event_record(
340                        event_id,
341                        event.get("metadata"),
342                        transcript_id.clone(),
343                        stage_id,
344                        node_id,
345                        location_prefix,
346                        persisted_path,
347                        &asset_ids,
348                    )
349                })
350                .collect()
351        })
352        .unwrap_or_default()
353}
354
355/// Read a compaction event's flat metadata string field, defaulting to empty.
356fn flat_meta_str(metadata: Option<&serde_json::Value>, key: &str) -> String {
357    metadata
358        .and_then(|value| value.get(key))
359        .and_then(|value| value.as_str())
360        .unwrap_or_default()
361        .to_string()
362}
363
364/// Project one transcript `compaction` event into a [`CompactionEventRecord`].
365///
366/// Prefers the canonical [`CompactionReceipt`] embedded under `metadata.receipt`
367/// (deserialized typed, not scraped key-by-key); when it is absent — a legacy
368/// transcript written before receipts existed — it falls back to reconstructing
369/// the record from the flat event metadata and marks the record
370/// `schema_version: 0` as the explicit migration signal (harn#4995).
371#[allow(clippy::too_many_arguments)]
372fn compaction_event_record(
373    event_id: String,
374    metadata: Option<&serde_json::Value>,
375    transcript_id: Option<String>,
376    stage_id: Option<&str>,
377    node_id: Option<&str>,
378    location_prefix: &str,
379    persisted_path: Option<&Path>,
380    asset_ids: &std::collections::BTreeSet<String>,
381) -> CompactionEventRecord {
382    let receipt = CompactionReceipt::from_event_metadata(metadata);
383    // Snapshot provenance is a persistence concern (it needs the transcript's
384    // asset set and persisted path), so it is computed here in both paths from
385    // whichever snapshot id is available.
386    let snapshot_asset_id = receipt
387        .as_ref()
388        .and_then(|receipt| receipt.snapshot_asset_id.clone())
389        .or_else(|| {
390            metadata
391                .and_then(|value| value.get("snapshot_asset_id"))
392                .and_then(|value| value.as_str())
393                .map(str::to_string)
394        });
395    let available = snapshot_asset_id
396        .as_ref()
397        .is_some_and(|asset_id| asset_ids.contains(asset_id));
398    let snapshot_location = snapshot_asset_id
399        .as_ref()
400        .map(|asset_id| format!("{location_prefix}.assets[{asset_id}]"))
401        .unwrap_or_else(|| location_prefix.to_string());
402    let snapshot_path = persisted_path.map(|path| path.to_string_lossy().into_owned());
403    let stage_id = stage_id.map(str::to_string);
404    let node_id = node_id.map(str::to_string);
405
406    if let Some(receipt) = receipt {
407        // Prefer the on-disk event id (it is the receipt id), falling back to the
408        // receipt's own id so identity is never lost.
409        let id = if event_id.is_empty() {
410            receipt.receipt_id
411        } else {
412            event_id
413        };
414        return CompactionEventRecord {
415            schema_version: receipt.schema_version,
416            id,
417            transcript_id,
418            stage_id,
419            node_id,
420            mode: receipt.mode,
421            reason: receipt.reason,
422            strategy: receipt.strategy,
423            archived_messages: receipt.archived_messages,
424            estimated_tokens_before: receipt.estimated_tokens_before,
425            estimated_tokens_after: receipt.estimated_tokens_after,
426            snapshot_asset_id,
427            snapshot_location,
428            snapshot_path,
429            available,
430            instruction_mode: receipt.instruction_mode.unwrap_or_default(),
431            instruction_source: receipt.instruction_source,
432            compaction_policy: receipt.compaction_policy,
433            recap: receipt.recap,
434        };
435    }
436
437    CompactionEventRecord {
438        schema_version: 0,
439        id: event_id,
440        transcript_id,
441        stage_id,
442        node_id,
443        mode: flat_meta_str(metadata, "mode"),
444        reason: flat_meta_str(metadata, "reason"),
445        strategy: flat_meta_str(metadata, "strategy"),
446        archived_messages: json_usize(metadata.and_then(|value| value.get("archived_messages"))),
447        estimated_tokens_before: json_usize(
448            metadata.and_then(|value| value.get("estimated_tokens_before")),
449        ),
450        estimated_tokens_after: json_usize(
451            metadata.and_then(|value| value.get("estimated_tokens_after")),
452        ),
453        snapshot_asset_id,
454        snapshot_location,
455        snapshot_path,
456        available,
457        instruction_mode: flat_meta_str(metadata, "instruction_mode"),
458        instruction_source: metadata
459            .and_then(|value| value.get("instruction_source"))
460            .and_then(|value| value.as_str())
461            .map(str::to_string),
462        compaction_policy: metadata
463            .and_then(|value| value.get("compaction_policy"))
464            .cloned(),
465        recap: metadata
466            .and_then(|value| value.get("recap"))
467            .and_then(|value| serde_json::from_value::<RecapMetrics>(value.clone()).ok()),
468    }
469}
470
471pub(super) fn daemon_events_from_sidecar(run_path: &Path) -> Vec<DaemonEventRecord> {
472    let Some(sidecar_path) = llm_transcript_sidecar_path(run_path) else {
473        return Vec::new();
474    };
475    let Ok(content) = std::fs::read_to_string(sidecar_path) else {
476        return Vec::new();
477    };
478
479    content
480        .lines()
481        .filter(|line| !line.trim().is_empty())
482        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
483        .filter(|event| event.get("type").and_then(|value| value.as_str()) == Some("daemon_event"))
484        .filter_map(|event| serde_json::from_value::<DaemonEventRecord>(event).ok())
485        .collect()
486}
487
488pub fn normalize_run_record(value: &VmValue) -> Result<RunRecord, VmError> {
489    let mut run: RunRecord = parse_json_payload(vm_value_to_json(value), "run_record")?;
490    if run.type_name.is_empty() {
491        run.type_name = "run_record".to_string();
492    }
493    if run.id.is_empty() {
494        run.id = new_id("run");
495    }
496    if run.started_at.is_empty() {
497        run.started_at = now_unix_seconds_text();
498    }
499    if run.status.is_empty() {
500        run.status = "running".to_string();
501    }
502    if run.root_run_id.is_none() {
503        run.root_run_id = Some(run.id.clone());
504    }
505    if run.replay_fixture.is_none() {
506        run.replay_fixture = Some(replay_fixture_from_run(&run));
507    }
508    merge_hitl_questions_from_active_log(&mut run);
509    materialize_child_runs_from_stage_metadata(&mut run);
510    sync_run_handoffs(&mut run);
511    if run.observability.is_none() {
512        let persisted_path = run.persisted_path.clone();
513        let persisted = persisted_path.as_deref().map(Path::new);
514        refresh_run_observability(&mut run, persisted);
515    }
516    Ok(run)
517}
518
519pub fn save_run_record(run: &RunRecord, path: Option<&str>) -> Result<String, VmError> {
520    let path = path
521        .map(PathBuf::from)
522        .unwrap_or_else(|| default_run_dir().join(format!("{}.json", run.id)));
523    let mut materialized = run.clone();
524    merge_hitl_questions_from_active_log(&mut materialized);
525    materialize_child_runs_from_stage_metadata(&mut materialized);
526    if materialized.replay_fixture.is_none() {
527        materialized.replay_fixture = Some(replay_fixture_from_run(&materialized));
528    }
529    materialized.persisted_path = Some(path.to_string_lossy().into_owned());
530    sync_run_handoffs(&mut materialized);
531    refresh_run_observability(&mut materialized, Some(&path));
532    if let Some(parent) = path.parent() {
533        std::fs::create_dir_all(parent)
534            .map_err(|e| VmError::Runtime(format!("failed to create run directory: {e}")))?;
535    }
536    let mut json_value = serde_json::to_value(&materialized)
537        .map_err(|e| VmError::Runtime(format!("failed to encode run record: {e}")))?;
538    crate::redact::current_policy().redact_json_in_place(&mut json_value);
539    let json = serde_json::to_string_pretty(&json_value)
540        .map_err(|e| VmError::Runtime(format!("failed to encode run record: {e}")))?;
541    crate::atomic_io::atomic_write(&path, json.as_bytes())
542        .map_err(|e| VmError::Runtime(format!("failed to persist run record: {e}")))?;
543    if let Some(observability) = materialized.observability.as_ref() {
544        publish_action_graph_event(&materialized, observability, &path);
545    }
546    Ok(path.to_string_lossy().into_owned())
547}
548
549pub fn load_run_record(path: &Path) -> Result<RunRecord, VmError> {
550    let content = std::fs::read_to_string(path)
551        .map_err(|e| VmError::Runtime(format!("failed to read run record: {e}")))?;
552    let mut run: RunRecord = serde_json::from_str(&content)
553        .map_err(|e| VmError::Runtime(format!("failed to parse run record: {e}")))?;
554    materialize_child_runs_from_stage_metadata(&mut run);
555    if run.replay_fixture.is_none() {
556        run.replay_fixture = Some(replay_fixture_from_run(&run));
557    }
558    run.persisted_path
559        .get_or_insert_with(|| path.to_string_lossy().into_owned());
560    sync_run_handoffs(&mut run);
561    refresh_run_observability(&mut run, Some(path));
562    Ok(run)
563}
564
565#[cfg(test)]
566mod compaction_projection_tests {
567    use super::*;
568
569    #[test]
570    fn projects_embedded_compaction_receipt_typed() {
571        // A transcript written by the unified path embeds the canonical receipt
572        // under `metadata.receipt`; the record is projected from it (typed), so
573        // reason, recap, policy, and the shared id all survive (harn#4995).
574        let transcript = serde_json::json!({
575            "_type": "transcript",
576            "id": "session-x",
577            "events": [{
578                "id": "compaction-shared-id",
579                "kind": "compaction",
580                "metadata": {
581                    // Flat sibling fields coexist, but the receipt is authoritative.
582                    "mode": "auto",
583                    "strategy": "hybrid",
584                    "receipt": {
585                        "schema_version": 1,
586                        "receipt_id": "compaction-shared-id",
587                        "mode": "auto",
588                        "reason": "threshold",
589                        "strategy": "hybrid",
590                        "engine_strategy": "observation_mask",
591                        "archived_messages": 5,
592                        "estimated_tokens_before": 900,
593                        "estimated_tokens_after": 300,
594                        "snapshot_asset_id": "snap-1",
595                        "instruction_mode": "extend",
596                        "instruction_source": "host",
597                        "compaction_policy": {"scope": "summary"},
598                        "recap": {
599                            "recap_bytes": 128,
600                            "budget_bytes": 16000,
601                            "kept_results_count": 2,
602                            "dropped_count": 1,
603                            "carried_prior_recap": true
604                        }
605                    }
606                }
607            }],
608            "assets": [{"id": "snap-1", "kind": "compaction_source_transcript"}]
609        });
610
611        let events =
612            compaction_events_from_transcript(&transcript, None, None, "run.transcript", None);
613        assert_eq!(events.len(), 1);
614        let event = &events[0];
615        // The record id is the shared receipt id, equal to the transcript event id.
616        assert_eq!(event.id, "compaction-shared-id");
617        assert_eq!(event.schema_version, 1);
618        assert_eq!(event.mode, "auto");
619        assert_eq!(event.reason, "threshold");
620        assert_eq!(event.strategy, "hybrid");
621        assert_eq!(event.estimated_tokens_after, 300);
622        assert_eq!(event.snapshot_asset_id.as_deref(), Some("snap-1"));
623        assert!(event.available);
624        assert_eq!(event.snapshot_location, "run.transcript.assets[snap-1]");
625        assert_eq!(event.instruction_mode, "extend");
626        assert_eq!(event.instruction_source.as_deref(), Some("host"));
627        assert_eq!(
628            event.compaction_policy,
629            Some(serde_json::json!({"scope": "summary"}))
630        );
631        let recap = event.recap.expect("recap survives projection");
632        assert_eq!(recap.recap_bytes, 128);
633        assert_eq!(recap.kept_results_count, 2);
634        assert!(recap.carried_prior_recap);
635    }
636
637    #[test]
638    fn migrates_legacy_transcript_without_embedded_receipt() {
639        // A transcript written before receipts existed carries only flat
640        // metadata. It must still project — reason/recap default and the record
641        // is marked `schema_version: 0` as the explicit migration signal.
642        let transcript = serde_json::json!({
643            "_type": "transcript",
644            "id": "session-legacy",
645            "events": [{
646                "id": "compaction-legacy",
647                "kind": "compaction",
648                "metadata": {
649                    "mode": "manual",
650                    "strategy": "truncate",
651                    "archived_messages": 3,
652                    "estimated_tokens_before": 120,
653                    "estimated_tokens_after": 48,
654                    "snapshot_asset_id": "snap-legacy"
655                }
656            }],
657            "assets": [{"id": "snap-legacy", "kind": "compaction_source_transcript"}]
658        });
659
660        let events =
661            compaction_events_from_transcript(&transcript, None, None, "run.transcript", None);
662        assert_eq!(events.len(), 1);
663        let event = &events[0];
664        assert_eq!(event.id, "compaction-legacy");
665        assert_eq!(event.schema_version, 0);
666        assert_eq!(event.mode, "manual");
667        assert_eq!(event.reason, "");
668        assert_eq!(event.strategy, "truncate");
669        assert_eq!(event.archived_messages, 3);
670        assert!(event.available);
671        assert!(event.recap.is_none());
672    }
673
674    #[test]
675    fn marks_unavailable_snapshot_from_receipt() {
676        // Manual compaction whose receipt names a snapshot asset that is not
677        // present on the transcript: the record still projects, snapshot
678        // provenance is recorded, and `available` is false.
679        let transcript = serde_json::json!({
680            "_type": "transcript",
681            "id": "session-y",
682            "events": [{
683                "id": "compaction-nosnap",
684                "kind": "compaction",
685                "metadata": {"receipt": {
686                    "schema_version": 1,
687                    "receipt_id": "compaction-nosnap",
688                    "mode": "manual",
689                    "reason": "manual",
690                    "strategy": "truncate",
691                    "engine_strategy": "truncate",
692                    "archived_messages": 2,
693                    "estimated_tokens_before": 100,
694                    "estimated_tokens_after": 50,
695                    "snapshot_asset_id": "missing-asset"
696                }}
697            }],
698            "assets": []
699        });
700
701        let events =
702            compaction_events_from_transcript(&transcript, None, None, "run.transcript", None);
703        assert_eq!(events.len(), 1);
704        let event = &events[0];
705        assert_eq!(event.id, "compaction-nosnap");
706        assert_eq!(event.mode, "manual");
707        assert_eq!(event.reason, "manual");
708        assert_eq!(event.snapshot_asset_id.as_deref(), Some("missing-asset"));
709        assert!(!event.available);
710        assert_eq!(
711            event.snapshot_location,
712            "run.transcript.assets[missing-asset]"
713        );
714        assert!(event.recap.is_none());
715    }
716}