1use 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
355fn 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#[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 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 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 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 "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 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 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 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}