Skip to main content

oris_kernel/kernel/
timeline.rs

1//! Run timeline: observable sequence of events for a run (audit, debugging).
2//!
3//! Built from EventStore; can be serialized to JSON for UI/CLI.
4
5use serde::{Deserialize, Serialize};
6
7use crate::kernel::event::{Event, EventStore};
8use crate::kernel::identity::{RunId, Seq};
9use crate::kernel::KernelError;
10
11/// One entry in a run timeline (summary of an event at a given seq).
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct TimelineEntry {
14    pub seq: Seq,
15    /// Event kind: StateUpdated, ActionRequested, ActionSucceeded, ActionFailed, Interrupted, Resumed, Completed.
16    pub kind: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub step_id: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub action_id: Option<String>,
21}
22
23/// Full timeline for a run: ordered events and final status.
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct RunTimeline {
26    pub run_id: String,
27    pub events: Vec<TimelineEntry>,
28    pub final_status: RunStatusSummary,
29}
30
31/// Summary of run outcome (for JSON/timeline; mirrors RunStatus).
32#[derive(Clone, Debug, Serialize, Deserialize)]
33#[serde(tag = "status")]
34pub enum RunStatusSummary {
35    Completed,
36    Blocked { interrupt: bool },
37    Failed { recoverable: bool },
38}
39
40/// Build a RunTimeline from an event store by scanning all events for the run
41/// and deriving final status from the last event(s).
42pub fn run_timeline(events: &dyn EventStore, run_id: &RunId) -> Result<RunTimeline, KernelError> {
43    const FROM_SEQ: Seq = 1;
44    let sequenced = events.scan(run_id, FROM_SEQ)?;
45    let mut entries = Vec::new();
46    let mut final_status = RunStatusSummary::Completed;
47
48    for se in sequenced {
49        let (kind, step_id, action_id) = match &se.event {
50            Event::StateUpdated { step_id, .. } => {
51                ("StateUpdated".to_string(), step_id.clone(), None)
52            }
53            Event::ActionRequested { action_id, .. } => {
54                ("ActionRequested".to_string(), None, Some(action_id.clone()))
55            }
56            Event::ActionSucceeded { action_id, .. } => {
57                ("ActionSucceeded".to_string(), None, Some(action_id.clone()))
58            }
59            Event::ActionFailed { action_id, .. } => {
60                final_status = RunStatusSummary::Failed { recoverable: false };
61                ("ActionFailed".to_string(), None, Some(action_id.clone()))
62            }
63            Event::Interrupted { .. } => {
64                final_status = RunStatusSummary::Blocked { interrupt: true };
65                ("Interrupted".to_string(), None, None)
66            }
67            Event::Resumed { .. } => ("Resumed".to_string(), None, None),
68            Event::Completed => {
69                final_status = RunStatusSummary::Completed;
70                ("Completed".to_string(), None, None)
71            }
72        };
73        entries.push(TimelineEntry {
74            seq: se.seq,
75            kind,
76            step_id,
77            action_id,
78        });
79    }
80
81    Ok(RunTimeline {
82        run_id: run_id.clone(),
83        events: entries,
84        final_status,
85    })
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::kernel::event::Event;
92    use crate::kernel::event_store::InMemoryEventStore;
93
94    #[test]
95    fn timeline_from_events_matches_order_and_final_status() {
96        let store = InMemoryEventStore::new();
97        let run_id = "timeline-test".to_string();
98        store
99            .append(
100                &run_id,
101                &[
102                    Event::StateUpdated {
103                        step_id: Some("n1".into()),
104                        payload: serde_json::json!({"x": 1}),
105                    },
106                    Event::Completed,
107                ],
108            )
109            .unwrap();
110        let tl = run_timeline(&store, &run_id).unwrap();
111        assert_eq!(tl.run_id, run_id);
112        assert_eq!(tl.events.len(), 2);
113        assert_eq!(tl.events[0].seq, 1);
114        assert_eq!(tl.events[0].kind, "StateUpdated");
115        assert_eq!(tl.events[0].step_id.as_deref(), Some("n1"));
116        assert_eq!(tl.events[1].kind, "Completed");
117        assert!(matches!(tl.final_status, RunStatusSummary::Completed));
118    }
119
120    #[test]
121    fn timeline_json_roundtrip() {
122        let store = InMemoryEventStore::new();
123        let run_id = "json-test".to_string();
124        store
125            .append(
126                &run_id,
127                &[
128                    Event::ActionRequested {
129                        action_id: "a1".into(),
130                        payload: serde_json::json!({"tool": "t1"}),
131                    },
132                    Event::ActionSucceeded {
133                        action_id: "a1".into(),
134                        output: serde_json::json!("ok"),
135                    },
136                    Event::Completed,
137                ],
138            )
139            .unwrap();
140        let tl = run_timeline(&store, &run_id).unwrap();
141        let json = serde_json::to_string(&tl).unwrap();
142        let _: RunTimeline = serde_json::from_str(&json).unwrap();
143    }
144}