Skip to main content

piw/
session.rs

1//! Deterministic reducer for `session/events.ndjson`.
2
3use 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
594/// Viewer-only checkpoint and timestamp index for efficient temporal seeks.
595pub struct SessionReplayIndex<'a> {
596    entries: &'a [SessionEntryRecord],
597    events: &'a [SessionEventRecord],
598    checkpoints: Vec<(u64, TemporalSessionState)>,
599    timestamps: Vec<(i64, u64)>,
600}
601
602impl<'a> SessionReplayIndex<'a> {
603    pub fn new(
604        entries: &'a [SessionEntryRecord],
605        events: &'a [SessionEventRecord],
606        checkpoint_interval: usize,
607    ) -> Self {
608        let interval = checkpoint_interval.max(1);
609        let mut checkpoints = Vec::new();
610        let mut state = fold_session_events(entries, events, 0, None);
611        checkpoints.push((0, state.clone()));
612        for end in ((interval - 1)..events.len()).step_by(interval) {
613            let seq = events[end].seq;
614            state = fold_session_events(entries, events, seq, Some(&state));
615            checkpoints.push((seq, state.clone()));
616        }
617        let mut last_timestamp = i64::MIN;
618        let timestamps = events
619            .iter()
620            .map(|event| {
621                let parsed = parse_timestamp_ms(&event.at).unwrap_or(last_timestamp);
622                last_timestamp = last_timestamp.max(parsed);
623                (last_timestamp, event.seq)
624            })
625            .collect();
626        Self {
627            entries,
628            events,
629            checkpoints,
630            timestamps,
631        }
632    }
633
634    pub fn state_at_seq(&self, through_seq: u64) -> TemporalSessionState {
635        let initial = self
636            .checkpoints
637            .iter()
638            .rev()
639            .find(|(seq, _)| *seq <= through_seq)
640            .map(|(_, state)| state);
641        fold_session_events(self.entries, self.events, through_seq, initial)
642    }
643
644    pub fn seq_at_or_before(&self, timestamp_ms: i64) -> u64 {
645        let index = self
646            .timestamps
647            .partition_point(|(at, _)| *at <= timestamp_ms);
648        index
649            .checked_sub(1)
650            .and_then(|index| self.timestamps.get(index))
651            .map_or(0, |(_, seq)| *seq)
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use serde_json::json;
659
660    fn capture() -> SessionCapture {
661        SessionCapture {
662            schema: SESSION_CAPTURE_SCHEMA.into(),
663            event_schema: SESSION_EVENT_SCHEMA.into(),
664            status: SessionCaptureStatus::Complete,
665            event_count: 1,
666            entry_count: 0,
667            last_event_seq: 1,
668            failure: None,
669        }
670    }
671
672    fn event(event_type: &str) -> SessionEventRecord {
673        SessionEventRecord {
674            seq: 1,
675            at: "2026-01-01T00:00:00.000Z".into(),
676            node_id: "node".into(),
677            attempt_id: "attempt".into(),
678            turn_id: None,
679            message_id: None,
680            tool_call_id: None,
681            event_type: event_type.into(),
682            payload: json!({}),
683        }
684    }
685
686    #[test]
687    fn capture_accepts_unknown_events_but_rejects_invalid_known_events() {
688        let unknown = event("future_event");
689        assert_eq!(
690            assess_capture(true, &[], &[unknown], Some(&capture()), false, false, true).status,
691            "complete"
692        );
693
694        let invalid = event("message_finished");
695        let integrity = assess_capture(true, &[], &[invalid], Some(&capture()), false, false, true);
696        assert_eq!(integrity.status, "invalid");
697        assert!(integrity
698            .diagnostics
699            .iter()
700            .any(|diagnostic| diagnostic.contains("requires turnId")));
701    }
702
703    #[test]
704    fn capture_rejects_malformed_or_terminal_torn_event_journals() {
705        let unknown = event("future_event");
706        assert_eq!(
707            assess_capture(
708                true,
709                &[],
710                std::slice::from_ref(&unknown),
711                Some(&capture()),
712                true,
713                false,
714                true,
715            )
716            .status,
717            "invalid"
718        );
719        assert_eq!(
720            assess_capture(
721                true,
722                &[],
723                std::slice::from_ref(&unknown),
724                Some(&capture()),
725                false,
726                true,
727                true,
728            )
729            .status,
730            "invalid"
731        );
732        let recording = SessionCapture {
733            status: SessionCaptureStatus::Recording,
734            ..capture()
735        };
736        assert_eq!(
737            assess_capture(true, &[], &[unknown], Some(&recording), false, true, false,).status,
738            "recording"
739        );
740    }
741
742    #[test]
743    fn capture_rejects_failure_details_in_the_wrong_status() {
744        let unknown = event("future_event");
745        let missing_failure = SessionCapture {
746            status: SessionCaptureStatus::Failed,
747            ..capture()
748        };
749        assert_eq!(
750            assess_capture(
751                true,
752                &[],
753                std::slice::from_ref(&unknown),
754                Some(&missing_failure),
755                false,
756                false,
757                true,
758            )
759            .status,
760            "invalid"
761        );
762
763        let unexpected_failure = SessionCapture {
764            failure: Some(crate::state::types::SessionCaptureFailure {
765                failed_at: "2026-01-01T00:00:00.000Z".into(),
766                code: "failed".into(),
767                message: "failed".into(),
768            }),
769            ..capture()
770        };
771        assert_eq!(
772            assess_capture(
773                true,
774                &[],
775                &[unknown],
776                Some(&unexpected_failure),
777                false,
778                false,
779                true,
780            )
781            .status,
782            "invalid"
783        );
784    }
785}