Skip to main content

deepstrike_core/runtime/
replay.rs

1//! Read-only OS audit snapshot rebuilt from append-only session events (Phase 6).
2//!
3//! Does not reconstruct `LoopStateMachine` — only aggregates kernel OS events for
4//! introspection, tests, and tooling.
5
6use serde::{Deserialize, Serialize};
7
8use crate::runtime::session::SessionEvent;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct SignalDisposedRecord {
12    pub turn: u32,
13    pub signal_id: String,
14    pub disposition: String,
15    pub queue_depth: u32,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ProcessRecord {
20    pub turn: u32,
21    pub agent_id: String,
22    pub parent_session_id: String,
23    pub state: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct SuspendRecord {
28    pub turn: u32,
29    pub reason: String,
30    pub pending_calls: Vec<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct BudgetExceededRecord {
35    pub turn: u32,
36    pub budget: String,
37}
38
39/// Aggregated kernel OS state derived from session log (audit view).
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
41pub struct OsSnapshot {
42    pub last_suspend: Option<SuspendRecord>,
43    pub last_resumed_turn: Option<u32>,
44    pub process_by_agent: Vec<ProcessRecord>,
45    pub budget_exceeded: Vec<BudgetExceededRecord>,
46    pub signals: Vec<SignalDisposedRecord>,
47    pub page_out_count: u32,
48    pub page_in_count: u32,
49    pub tool_gated_count: u32,
50    #[serde(default)]
51    pub memory_written_count: u32,
52    #[serde(default)]
53    pub memory_queried_count: u32,
54    #[serde(default)]
55    pub memory_validation_failed_count: u32,
56    #[serde(default)]
57    pub memory_retrieval_result_count: u32,
58}
59
60/// Rebuild an OS audit snapshot from session events (newest process state wins per agent).
61pub fn rebuild_os_snapshot_from_events(events: &[SessionEvent]) -> OsSnapshot {
62    let mut snap = OsSnapshot::default();
63    let mut process_index: std::collections::HashMap<String, usize> =
64        std::collections::HashMap::new();
65
66    for event in events {
67        // `is_kernel_os_event` covers every counted OS event; `MemoryRetrievalResult` is an
68        // SDK-written acknowledgment counted here too (parity with the node/python rebuilds,
69        // which special-case it before their filters).
70        if !event.is_kernel_os_event()
71            && !matches!(event, SessionEvent::MemoryRetrievalResult { .. })
72        {
73            continue;
74        }
75
76        match event {
77            SessionEvent::Suspended {
78                turn,
79                reason,
80                pending_calls,
81                ..
82            } => {
83                snap.last_suspend = Some(SuspendRecord {
84                    turn: *turn,
85                    reason: reason.clone(),
86                    pending_calls: pending_calls.clone(),
87                });
88            }
89            SessionEvent::Resumed { turn, .. } => {
90                snap.last_resumed_turn = Some(*turn);
91            }
92            SessionEvent::ToolGated { .. } => {
93                snap.tool_gated_count += 1;
94            }
95            SessionEvent::AgentProcessChanged {
96                turn,
97                agent_id,
98                parent_session_id,
99                state,
100                ..
101            } => {
102                let record = ProcessRecord {
103                    turn: *turn,
104                    agent_id: agent_id.clone(),
105                    parent_session_id: parent_session_id.clone(),
106                    state: state.clone(),
107                };
108                if let Some(idx) = process_index.get(agent_id) {
109                    snap.process_by_agent[*idx] = record;
110                } else {
111                    process_index.insert(agent_id.clone(), snap.process_by_agent.len());
112                    snap.process_by_agent.push(record);
113                }
114            }
115            SessionEvent::BudgetExceeded { turn, budget, .. } => {
116                snap.budget_exceeded.push(BudgetExceededRecord {
117                    turn: *turn,
118                    budget: budget.clone(),
119                });
120            }
121            SessionEvent::SignalDisposed {
122                turn,
123                signal_id,
124                disposition,
125                queue_depth,
126                ..
127            } => {
128                snap.signals.push(SignalDisposedRecord {
129                    turn: *turn,
130                    signal_id: signal_id.clone(),
131                    disposition: disposition.clone(),
132                    queue_depth: *queue_depth,
133                });
134            }
135            SessionEvent::PageOut { .. } => {
136                snap.page_out_count += 1;
137            }
138            SessionEvent::PageIn { .. } => {
139                snap.page_in_count += 1;
140            }
141            SessionEvent::MemoryWritten { .. } => {
142                snap.memory_written_count += 1;
143            }
144            SessionEvent::MemoryQueried { .. } => {
145                snap.memory_queried_count += 1;
146            }
147            SessionEvent::MemoryValidationFailed { .. } => {
148                snap.memory_validation_failed_count += 1;
149            }
150            SessionEvent::MemoryRetrievalResult { .. } => {
151                snap.memory_retrieval_result_count += 1;
152            }
153            _ => {}
154        }
155    }
156
157    snap
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn rebuild_tracks_process_and_signals() {
166        let events = vec![
167            SessionEvent::AgentProcessChanged {
168                turn: 1,
169                agent_id: "child-1".into(),
170                parent_session_id: "parent".into(),
171                role: "worker".into(),
172                isolation: "shared".into(),
173                context_inheritance: "none".into(),
174                state: "running".into(),
175                permitted_capability_ids: vec![],
176                result_termination: None,
177            },
178            SessionEvent::SignalDisposed {
179                turn: 2,
180                signal_id: "sig-a".into(),
181                disposition: "queue".into(),
182                queue_depth: 1,
183            },
184            SessionEvent::Suspended {
185                turn: 3,
186                reason: "ask_user".into(),
187                pending_calls: vec!["c1".into()],
188            },
189            SessionEvent::AgentProcessChanged {
190                turn: 4,
191                agent_id: "child-1".into(),
192                parent_session_id: "parent".into(),
193                role: "worker".into(),
194                isolation: "shared".into(),
195                context_inheritance: "none".into(),
196                state: "joined".into(),
197                permitted_capability_ids: vec![],
198                result_termination: Some("completed".into()),
199            },
200        ];
201        let snap = rebuild_os_snapshot_from_events(&events);
202        assert_eq!(snap.process_by_agent.len(), 1);
203        assert_eq!(snap.process_by_agent[0].state, "joined");
204        assert_eq!(snap.signals.len(), 1);
205        assert_eq!(snap.last_suspend.as_ref().map(|s| s.reason.as_str()), Some("ask_user"));
206    }
207
208    fn load_fixture(name: &str) -> String {
209        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
210            .join("../../tests/fixtures/session")
211            .join(name);
212        std::fs::read_to_string(&path)
213            .unwrap_or_else(|e| panic!("read {}: {}", path.display(), e))
214    }
215
216    fn assert_golden(events_file: &str, snapshot_file: &str) {
217        let events: Vec<SessionEvent> =
218            serde_json::from_str(&load_fixture(events_file)).expect("events json");
219        let snap = rebuild_os_snapshot_from_events(&events);
220        let expected: OsSnapshot =
221            serde_json::from_str(&load_fixture(snapshot_file)).expect("snapshot json");
222        assert_eq!(snap, expected);
223    }
224
225    #[test]
226    fn golden_os_snapshot_spawn_lifecycle_fixture() {
227        assert_golden("events_spawn_lifecycle.json", "os_snapshot_spawn_lifecycle.json");
228    }
229
230    #[test]
231    fn golden_os_snapshot_ask_user_fixture() {
232        assert_golden("events_ask_user.json", "os_snapshot_ask_user.json");
233    }
234}