1use crate::format::parse_timestamp_ms;
4use crate::state::types::{
5 SessionCapture, SessionCaptureStatus, SessionEntryRecord, SessionEventRecord,
6 SESSION_CAPTURE_SCHEMA, SESSION_EVENT_SCHEMA,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::{BTreeMap, HashMap, HashSet};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct TemporalContentBlock {
14 #[serde(rename = "contentIndex")]
15 pub content_index: u64,
16 pub kind: String,
17 pub text: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub value: Option<Value>,
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct TemporalMessage {
24 #[serde(rename = "messageId")]
25 pub message_id: String,
26 pub role: String,
27 pub status: String,
28 #[serde(rename = "entryId", skip_serializing_if = "Option::is_none")]
29 pub entry_id: Option<String>,
30 pub blocks: Vec<TemporalContentBlock>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct TemporalTool {
35 #[serde(rename = "toolCallId")]
36 pub tool_call_id: String,
37 #[serde(rename = "messageId")]
38 pub message_id: String,
39 #[serde(rename = "toolName")]
40 pub tool_name: String,
41 pub status: String,
42 pub updates: u64,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub args: Option<Value>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub result: Option<Value>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct TemporalSessionState {
51 #[serde(rename = "throughSeq")]
52 pub through_seq: u64,
53 pub messages: Vec<TemporalMessage>,
54 pub tools: Vec<TemporalTool>,
55 #[serde(rename = "settledEntryIds")]
56 pub settled_entry_ids: Vec<String>,
57 pub diagnostics: Vec<String>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct CaptureIntegrity {
62 pub status: &'static str,
63 pub diagnostics: Vec<String>,
64}
65
66fn event_integrity_diagnostics(
67 entries: &[SessionEntryRecord],
68 events: &[SessionEventRecord],
69) -> Vec<String> {
70 let entry_ids: HashSet<&str> = entries
71 .iter()
72 .filter_map(|record| record.entry.get("id")?.as_str())
73 .collect();
74 let mut turns: HashSet<&str> = HashSet::new();
75 let mut messages: HashSet<&str> = HashSet::new();
76 let mut tools: HashSet<&str> = HashSet::new();
77 let mut diagnostics = Vec::new();
78 for event in events {
79 if event.at.is_empty()
80 || event.node_id.is_empty()
81 || event.attempt_id.is_empty()
82 || event.event_type.is_empty()
83 || !event.payload.is_object()
84 {
85 diagnostics.push(format!(
86 "session event {} has an invalid envelope",
87 event.seq
88 ));
89 continue;
90 }
91 let known = matches!(
92 event.event_type.as_str(),
93 "turn_started"
94 | "turn_finished"
95 | "message_started"
96 | "assistant_event"
97 | "message_finished"
98 | "tool_execution_started"
99 | "tool_execution_updated"
100 | "tool_execution_finished"
101 );
102 if !known {
103 continue;
104 }
105 let Some(turn_id) = event.turn_id.as_deref().filter(|id| !id.is_empty()) else {
106 diagnostics.push(format!(
107 "{} {} requires turnId",
108 event.event_type, event.seq
109 ));
110 continue;
111 };
112 match event.event_type.as_str() {
113 "turn_started" => {
114 turns.insert(turn_id);
115 }
116 "turn_finished" => {
117 if !turns.contains(turn_id) {
118 diagnostics.push(format!("turn_finished {} precedes turn_started", event.seq));
119 }
120 }
121 event_type => {
122 let Some(message_id) = event.message_id.as_deref().filter(|id| !id.is_empty())
123 else {
124 diagnostics.push(format!("{event_type} {} requires messageId", event.seq));
125 continue;
126 };
127 match event_type {
128 "message_started" => {
129 if !turns.contains(turn_id) {
130 diagnostics.push(format!(
131 "message_started {} precedes turn_started",
132 event.seq
133 ));
134 }
135 messages.insert(message_id);
136 }
137 "assistant_event" => {
138 if !messages.contains(message_id) {
139 diagnostics.push(format!(
140 "assistant_event {} precedes message_started",
141 event.seq
142 ));
143 }
144 }
145 "message_finished" => {
146 if !messages.contains(message_id) {
147 diagnostics.push(format!(
148 "message_finished {} precedes message_started",
149 event.seq
150 ));
151 }
152 let settled = event
153 .payload
154 .get("settled")
155 .and_then(Value::as_bool)
156 .unwrap_or(false);
157 let entry_id = event.payload.get("entryId").and_then(Value::as_str);
158 if settled && entry_id.is_none_or(|id| !entry_ids.contains(id)) {
159 diagnostics.push(format!(
160 "message_finished {} references a missing entry",
161 event.seq
162 ));
163 } else if !settled && entry_id.is_some() {
164 diagnostics.push(format!(
165 "message_finished {} has entryId while unsettled",
166 event.seq
167 ));
168 }
169 }
170 "tool_execution_started" => {
171 if !messages.contains(message_id) {
172 diagnostics.push(format!(
173 "tool_execution_started {} precedes message_started",
174 event.seq
175 ));
176 }
177 if let Some(tool_id) =
178 event.tool_call_id.as_deref().filter(|id| !id.is_empty())
179 {
180 tools.insert(tool_id);
181 } else {
182 diagnostics.push(format!(
183 "tool_execution_started {} requires toolCallId",
184 event.seq
185 ));
186 }
187 }
188 "tool_execution_updated" | "tool_execution_finished" => {
189 let Some(tool_id) =
190 event.tool_call_id.as_deref().filter(|id| !id.is_empty())
191 else {
192 diagnostics
193 .push(format!("{event_type} {} requires toolCallId", event.seq));
194 continue;
195 };
196 if !tools.contains(tool_id) {
197 diagnostics.push(format!(
198 "{event_type} {} precedes tool_execution_started",
199 event.seq
200 ));
201 }
202 }
203 _ => {}
204 }
205 }
206 }
207 }
208 diagnostics
209}
210
211pub fn assess_capture(
212 session_bound: bool,
213 entries: &[SessionEntryRecord],
214 events: &[SessionEventRecord],
215 capture: Option<&SessionCapture>,
216 events_malformed: bool,
217 events_torn_tail: bool,
218 run_terminal: bool,
219) -> CaptureIntegrity {
220 if !session_bound {
221 return CaptureIntegrity {
222 status: "unavailable",
223 diagnostics: Vec::new(),
224 };
225 }
226 let Some(capture) = capture else {
227 return CaptureIntegrity {
228 status: "invalid",
229 diagnostics: vec!["missing session capture status".into()],
230 };
231 };
232 let mut diagnostics = Vec::new();
233 if capture.schema != SESSION_CAPTURE_SCHEMA || capture.event_schema != SESSION_EVENT_SCHEMA {
234 diagnostics.push("unsupported session capture schema".into());
235 }
236 if capture.status == SessionCaptureStatus::Failed && capture.failure.is_none() {
237 diagnostics.push("failed session capture requires failure details".into());
238 } else if capture.status != SessionCaptureStatus::Failed && capture.failure.is_some() {
239 diagnostics.push("only failed session capture may contain failure details".into());
240 }
241 if events_malformed {
242 diagnostics.push("malformed NDJSON line before the journal tail".into());
243 }
244 if events_torn_tail && capture.status != SessionCaptureStatus::Recording {
245 diagnostics.push("terminal session event journal has a torn tail".into());
246 }
247 for (index, event) in events.iter().enumerate() {
248 if event.seq != index as u64 + 1 {
249 diagnostics.push(format!("session event sequence gap at {}", index + 1));
250 break;
251 }
252 }
253 diagnostics.extend(event_integrity_diagnostics(entries, events));
254 if capture.status != SessionCaptureStatus::Recording {
255 let last_seq = events.last().map_or(0, |event| event.seq);
256 if capture.event_count != events.len() as u64
257 || capture.entry_count != entries.len() as u64
258 || capture.last_event_seq != last_seq
259 {
260 diagnostics.push("session capture counts do not match durable files".into());
261 }
262 } else if run_terminal {
263 diagnostics.push("terminal run still reports recording capture".into());
264 }
265 if !diagnostics.is_empty() {
266 return CaptureIntegrity {
267 status: "invalid",
268 diagnostics,
269 };
270 }
271 match capture.status {
272 SessionCaptureStatus::Recording => CaptureIntegrity {
273 status: "recording",
274 diagnostics,
275 },
276 SessionCaptureStatus::Complete => CaptureIntegrity {
277 status: "complete",
278 diagnostics,
279 },
280 SessionCaptureStatus::Failed => CaptureIntegrity {
281 status: "failed",
282 diagnostics: vec![capture
283 .failure
284 .as_ref()
285 .map(|failure| failure.message.clone())
286 .unwrap_or_else(|| "session capture failed".into())],
287 },
288 }
289}
290
291#[derive(Clone)]
292struct MutableMessage {
293 message_id: String,
294 role: String,
295 status: String,
296 entry_id: Option<String>,
297 blocks: BTreeMap<u64, TemporalContentBlock>,
298}
299
300fn string(payload: &Value, key: &str) -> Option<String> {
301 payload.get(key)?.as_str().map(str::to_string)
302}
303
304fn index(payload: &Value) -> Option<u64> {
305 payload.get("contentIndex")?.as_u64()
306}
307
308fn ensure_block<'a>(
309 message: &'a mut MutableMessage,
310 content_index: u64,
311 kind: &str,
312) -> &'a mut TemporalContentBlock {
313 message
314 .blocks
315 .entry(content_index)
316 .or_insert_with(|| TemporalContentBlock {
317 content_index,
318 kind: kind.to_string(),
319 text: String::new(),
320 value: None,
321 })
322}
323
324fn fold_session_events(
325 entries: &[SessionEntryRecord],
326 events: &[SessionEventRecord],
327 through_seq: u64,
328 initial: Option<&TemporalSessionState>,
329) -> TemporalSessionState {
330 let known_entries: HashSet<String> = entries
331 .iter()
332 .filter_map(|record| record.entry.get("id")?.as_str().map(str::to_string))
333 .collect();
334 let mut messages: HashMap<String, MutableMessage> = HashMap::new();
335 let mut message_order: Vec<String> = Vec::new();
336 for message in initial.into_iter().flat_map(|state| &state.messages) {
337 messages.insert(
338 message.message_id.clone(),
339 MutableMessage {
340 message_id: message.message_id.clone(),
341 role: message.role.clone(),
342 status: message.status.clone(),
343 entry_id: message.entry_id.clone(),
344 blocks: message
345 .blocks
346 .iter()
347 .map(|block| (block.content_index, block.clone()))
348 .collect(),
349 },
350 );
351 message_order.push(message.message_id.clone());
352 }
353 let mut tools: HashMap<String, TemporalTool> = HashMap::new();
354 let mut tool_order: Vec<String> = Vec::new();
355 for tool in initial.into_iter().flat_map(|state| &state.tools) {
356 tools.insert(tool.tool_call_id.clone(), tool.clone());
357 tool_order.push(tool.tool_call_id.clone());
358 }
359 let mut settled_entry_ids = initial
360 .map(|state| state.settled_entry_ids.clone())
361 .unwrap_or_default();
362 let mut diagnostics = initial
363 .map(|state| state.diagnostics.clone())
364 .unwrap_or_default();
365 let mut expected_seq = initial.map_or(1, |state| state.through_seq + 1);
366 let mut last_seq = initial.map_or(0, |state| state.through_seq);
367
368 for event in events {
369 if event.seq <= last_seq {
370 continue;
371 }
372 if event.seq > through_seq {
373 break;
374 }
375 if event.seq != expected_seq {
376 diagnostics.push(format!("session event sequence gap at {expected_seq}"));
377 expected_seq = event.seq;
378 }
379 expected_seq += 1;
380 last_seq = event.seq;
381
382 match event.event_type.as_str() {
383 "message_started" => {
384 let Some(message_id) = event.message_id.as_ref() else {
385 diagnostics.push(format!("message_started {} has no messageId", event.seq));
386 continue;
387 };
388 if !messages.contains_key(message_id) {
389 messages.insert(
390 message_id.clone(),
391 MutableMessage {
392 message_id: message_id.clone(),
393 role: string(&event.payload, "role")
394 .unwrap_or_else(|| "unknown".into()),
395 status: "streaming".into(),
396 entry_id: None,
397 blocks: BTreeMap::new(),
398 },
399 );
400 message_order.push(message_id.clone());
401 }
402 }
403 "assistant_event" => {
404 let Some(message_id) = event.message_id.as_ref() else {
405 diagnostics.push(format!("assistant_event {} has no messageId", event.seq));
406 continue;
407 };
408 let Some(message) = messages.get_mut(message_id) else {
409 diagnostics.push(format!(
410 "assistant_event {} precedes message_started",
411 event.seq
412 ));
413 continue;
414 };
415 let assistant_type = string(&event.payload, "type").unwrap_or_default();
416 let content_index = index(&event.payload);
417 match (assistant_type.as_str(), content_index) {
418 ("text_start", Some(i)) => {
419 ensure_block(message, i, "text");
420 }
421 ("thinking_start", Some(i)) => {
422 ensure_block(message, i, "thinking");
423 }
424 ("toolcall_start", Some(i)) => {
425 ensure_block(message, i, "toolCall");
426 }
427 ("text_delta", Some(i))
428 | ("thinking_delta", Some(i))
429 | ("toolcall_delta", Some(i)) => {
430 let kind = match assistant_type.as_str() {
431 "text_delta" => "text",
432 "thinking_delta" => "thinking",
433 _ => "toolCall",
434 };
435 ensure_block(message, i, kind)
436 .text
437 .push_str(&string(&event.payload, "delta").unwrap_or_default());
438 }
439 ("text_end", Some(i)) | ("thinking_end", Some(i)) => {
440 let kind = if assistant_type == "text_end" {
441 "text"
442 } else {
443 "thinking"
444 };
445 let block = ensure_block(message, i, kind);
446 let content = string(&event.payload, "content").unwrap_or_default();
447 if block.text != content {
448 diagnostics
449 .push(format!("{assistant_type} mismatch for {message_id}:{i}"));
450 block.text = content;
451 }
452 }
453 ("toolcall_end", Some(i)) => {
454 ensure_block(message, i, "toolCall").value =
455 event.payload.get("toolCall").cloned();
456 }
457 ("done", _) => message.status = "finished".into(),
458 ("error", _) => message.status = "error".into(),
459 _ => {}
460 }
461 }
462 "message_finished" => {
463 let Some(message_id) = event.message_id.as_ref() else {
464 diagnostics.push(format!("message_finished {} has no messageId", event.seq));
465 continue;
466 };
467 let Some(message) = messages.get_mut(message_id) else {
468 diagnostics.push(format!(
469 "message_finished {} precedes message_started",
470 event.seq
471 ));
472 continue;
473 };
474 let settled = event
475 .payload
476 .get("settled")
477 .and_then(Value::as_bool)
478 .unwrap_or(false);
479 let entry_id = string(&event.payload, "entryId");
480 if settled {
481 if let Some(entry_id) = entry_id {
482 message.status = "settled".into();
483 message.entry_id = Some(entry_id.clone());
484 settled_entry_ids.push(entry_id.clone());
485 if !known_entries.contains(&entry_id) {
486 diagnostics.push(format!("settled entry {entry_id} is missing"));
487 }
488 } else {
489 message.status = "unsettled".into();
490 }
491 } else {
492 message.status = "unsettled".into();
493 }
494 }
495 "tool_execution_started" => {
496 let (Some(tool_call_id), Some(message_id)) =
497 (event.tool_call_id.as_ref(), event.message_id.as_ref())
498 else {
499 diagnostics.push(format!(
500 "tool_execution_started {} is uncorrelated",
501 event.seq
502 ));
503 continue;
504 };
505 tools.insert(
506 tool_call_id.clone(),
507 TemporalTool {
508 tool_call_id: tool_call_id.clone(),
509 message_id: message_id.clone(),
510 tool_name: string(&event.payload, "toolName")
511 .unwrap_or_else(|| "tool".into()),
512 status: "running".into(),
513 updates: 0,
514 args: event.payload.get("args").cloned(),
515 result: None,
516 },
517 );
518 tool_order.push(tool_call_id.clone());
519 }
520 "tool_execution_updated" => {
521 if let Some(tool) = event
522 .tool_call_id
523 .as_ref()
524 .and_then(|tool_call_id| tools.get_mut(tool_call_id))
525 {
526 tool.updates += 1;
527 } else {
528 diagnostics.push(format!(
529 "tool_execution_updated {} precedes start",
530 event.seq
531 ));
532 }
533 }
534 "tool_execution_finished" => {
535 if let Some(tool) = event
536 .tool_call_id
537 .as_ref()
538 .and_then(|tool_call_id| tools.get_mut(tool_call_id))
539 {
540 tool.status = if event
541 .payload
542 .get("isError")
543 .and_then(Value::as_bool)
544 .unwrap_or(false)
545 {
546 "failed".into()
547 } else {
548 "finished".into()
549 };
550 tool.result = event.payload.get("result").cloned();
551 } else {
552 diagnostics.push(format!(
553 "tool_execution_finished {} precedes start",
554 event.seq
555 ));
556 }
557 }
558 _ => {}
559 }
560 }
561
562 TemporalSessionState {
563 through_seq: last_seq,
564 messages: message_order
565 .iter()
566 .map(|message_id| {
567 let message = messages.remove(message_id).unwrap();
568 TemporalMessage {
569 message_id: message.message_id,
570 role: message.role,
571 status: message.status,
572 entry_id: message.entry_id,
573 blocks: message.blocks.into_values().collect(),
574 }
575 })
576 .collect(),
577 tools: tool_order
578 .iter()
579 .filter_map(|tool_call_id| tools.remove(tool_call_id))
580 .collect(),
581 settled_entry_ids,
582 diagnostics,
583 }
584}
585
586pub fn reduce_session_events(
587 entries: &[SessionEntryRecord],
588 events: &[SessionEventRecord],
589 through_seq: u64,
590) -> TemporalSessionState {
591 fold_session_events(entries, events, through_seq, None)
592}
593
594pub fn reduce_session_events_from_checkpoint(
596 entries: &[SessionEntryRecord],
597 events: &[SessionEventRecord],
598 through_seq: u64,
599 checkpoint: &TemporalSessionState,
600) -> TemporalSessionState {
601 fold_session_events(entries, events, through_seq, Some(checkpoint))
602}
603
604pub struct SessionReplayIndex<'a> {
605 entries: &'a [SessionEntryRecord],
606 events: &'a [SessionEventRecord],
607 checkpoints: Vec<(u64, TemporalSessionState)>,
608 timestamps: Vec<(i64, u64)>,
609}
610
611impl<'a> SessionReplayIndex<'a> {
612 pub fn new(
613 entries: &'a [SessionEntryRecord],
614 events: &'a [SessionEventRecord],
615 checkpoint_interval: usize,
616 ) -> Self {
617 let interval = checkpoint_interval.max(1);
618 let mut checkpoints = Vec::new();
619 let mut state = fold_session_events(entries, events, 0, None);
620 checkpoints.push((0, state.clone()));
621 for end in ((interval - 1)..events.len()).step_by(interval) {
622 let seq = events[end].seq;
623 state = fold_session_events(entries, events, seq, Some(&state));
624 checkpoints.push((seq, state.clone()));
625 }
626 let mut last_timestamp = i64::MIN;
627 let timestamps = events
628 .iter()
629 .map(|event| {
630 let parsed = parse_timestamp_ms(&event.at).unwrap_or(last_timestamp);
631 last_timestamp = last_timestamp.max(parsed);
632 (last_timestamp, event.seq)
633 })
634 .collect();
635 Self {
636 entries,
637 events,
638 checkpoints,
639 timestamps,
640 }
641 }
642
643 pub fn state_at_seq(&self, through_seq: u64) -> TemporalSessionState {
644 let initial = self
645 .checkpoints
646 .iter()
647 .rev()
648 .find(|(seq, _)| *seq <= through_seq)
649 .map(|(_, state)| state);
650 fold_session_events(self.entries, self.events, through_seq, initial)
651 }
652
653 pub fn seq_at_or_before(&self, timestamp_ms: i64) -> u64 {
654 let index = self
655 .timestamps
656 .partition_point(|(at, _)| *at <= timestamp_ms);
657 index
658 .checked_sub(1)
659 .and_then(|index| self.timestamps.get(index))
660 .map_or(0, |(_, seq)| *seq)
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use serde_json::json;
668
669 fn capture() -> SessionCapture {
670 SessionCapture {
671 schema: SESSION_CAPTURE_SCHEMA.into(),
672 event_schema: SESSION_EVENT_SCHEMA.into(),
673 status: SessionCaptureStatus::Complete,
674 event_count: 1,
675 entry_count: 0,
676 last_event_seq: 1,
677 failure: None,
678 }
679 }
680
681 fn event(event_type: &str) -> SessionEventRecord {
682 SessionEventRecord {
683 seq: 1,
684 at: "2026-01-01T00:00:00.000Z".into(),
685 node_id: "node".into(),
686 attempt_id: "attempt".into(),
687 turn_id: None,
688 message_id: None,
689 tool_call_id: None,
690 event_type: event_type.into(),
691 payload: json!({}),
692 }
693 }
694
695 #[test]
696 fn capture_accepts_unknown_events_but_rejects_invalid_known_events() {
697 let unknown = event("future_event");
698 assert_eq!(
699 assess_capture(true, &[], &[unknown], Some(&capture()), false, false, true).status,
700 "complete"
701 );
702
703 let invalid = event("message_finished");
704 let integrity = assess_capture(true, &[], &[invalid], Some(&capture()), false, false, true);
705 assert_eq!(integrity.status, "invalid");
706 assert!(integrity
707 .diagnostics
708 .iter()
709 .any(|diagnostic| diagnostic.contains("requires turnId")));
710 }
711
712 #[test]
713 fn capture_rejects_malformed_or_terminal_torn_event_journals() {
714 let unknown = event("future_event");
715 assert_eq!(
716 assess_capture(
717 true,
718 &[],
719 std::slice::from_ref(&unknown),
720 Some(&capture()),
721 true,
722 false,
723 true,
724 )
725 .status,
726 "invalid"
727 );
728 assert_eq!(
729 assess_capture(
730 true,
731 &[],
732 std::slice::from_ref(&unknown),
733 Some(&capture()),
734 false,
735 true,
736 true,
737 )
738 .status,
739 "invalid"
740 );
741 let recording = SessionCapture {
742 status: SessionCaptureStatus::Recording,
743 ..capture()
744 };
745 assert_eq!(
746 assess_capture(true, &[], &[unknown], Some(&recording), false, true, false,).status,
747 "recording"
748 );
749 }
750
751 #[test]
752 fn capture_rejects_failure_details_in_the_wrong_status() {
753 let unknown = event("future_event");
754 let missing_failure = SessionCapture {
755 status: SessionCaptureStatus::Failed,
756 ..capture()
757 };
758 assert_eq!(
759 assess_capture(
760 true,
761 &[],
762 std::slice::from_ref(&unknown),
763 Some(&missing_failure),
764 false,
765 false,
766 true,
767 )
768 .status,
769 "invalid"
770 );
771
772 let unexpected_failure = SessionCapture {
773 failure: Some(crate::state::types::SessionCaptureFailure {
774 failed_at: "2026-01-01T00:00:00.000Z".into(),
775 code: "failed".into(),
776 message: "failed".into(),
777 }),
778 ..capture()
779 };
780 assert_eq!(
781 assess_capture(
782 true,
783 &[],
784 &[unknown],
785 Some(&unexpected_failure),
786 false,
787 false,
788 true,
789 )
790 .status,
791 "invalid"
792 );
793 }
794}