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        if !event.is_kernel_os_event()
68            && !matches!(
69                event,
70                SessionEvent::ToolGated { .. }
71                    | SessionEvent::SignalDisposed { .. }
72                    | SessionEvent::BudgetExceeded { .. }
73                    | SessionEvent::Suspended { .. }
74                    | SessionEvent::Resumed { .. }
75            )
76        {
77            continue;
78        }
79
80        match event {
81            SessionEvent::Suspended {
82                turn,
83                reason,
84                pending_calls,
85                ..
86            } => {
87                snap.last_suspend = Some(SuspendRecord {
88                    turn: *turn,
89                    reason: reason.clone(),
90                    pending_calls: pending_calls.clone(),
91                });
92            }
93            SessionEvent::Resumed { turn, .. } => {
94                snap.last_resumed_turn = Some(*turn);
95            }
96            SessionEvent::ToolGated { .. } => {
97                snap.tool_gated_count += 1;
98            }
99            SessionEvent::AgentProcessChanged {
100                turn,
101                agent_id,
102                parent_session_id,
103                state,
104                ..
105            } => {
106                let record = ProcessRecord {
107                    turn: *turn,
108                    agent_id: agent_id.clone(),
109                    parent_session_id: parent_session_id.clone(),
110                    state: state.clone(),
111                };
112                if let Some(idx) = process_index.get(agent_id) {
113                    snap.process_by_agent[*idx] = record;
114                } else {
115                    process_index.insert(agent_id.clone(), snap.process_by_agent.len());
116                    snap.process_by_agent.push(record);
117                }
118            }
119            SessionEvent::BudgetExceeded { turn, budget, .. } => {
120                snap.budget_exceeded.push(BudgetExceededRecord {
121                    turn: *turn,
122                    budget: budget.clone(),
123                });
124            }
125            SessionEvent::SignalDisposed {
126                turn,
127                signal_id,
128                disposition,
129                queue_depth,
130                ..
131            } => {
132                snap.signals.push(SignalDisposedRecord {
133                    turn: *turn,
134                    signal_id: signal_id.clone(),
135                    disposition: disposition.clone(),
136                    queue_depth: *queue_depth,
137                });
138            }
139            SessionEvent::PageOut { .. } => {
140                snap.page_out_count += 1;
141            }
142            SessionEvent::PageIn { .. } => {
143                snap.page_in_count += 1;
144            }
145            SessionEvent::MemoryWritten { .. } => {
146                snap.memory_written_count += 1;
147            }
148            SessionEvent::MemoryQueried { .. } => {
149                snap.memory_queried_count += 1;
150            }
151            SessionEvent::MemoryValidationFailed { .. } => {
152                snap.memory_validation_failed_count += 1;
153            }
154            SessionEvent::MemoryRetrievalResult { .. } => {
155                snap.memory_retrieval_result_count += 1;
156            }
157            _ => {}
158        }
159    }
160
161    snap
162}
163
164/// Returns true if every kernel OS event in the log carries an explicit category (Phase 6 native).
165pub fn session_log_has_required_categories(events: &[SessionEvent]) -> bool {
166    events.iter().all(|e| {
167        if !e.is_kernel_os_event() {
168            return true;
169        }
170        match e {
171            SessionEvent::Compressed { category, .. }
172            | SessionEvent::PageOut { category, .. }
173            | SessionEvent::PageIn { category, .. }
174            | SessionEvent::CapabilityChanged { category, .. }
175            | SessionEvent::ContextRenewed { category, .. }
176            | SessionEvent::Suspended { category, .. }
177            | SessionEvent::Resumed { category, .. }
178            | SessionEvent::ToolGated { category, .. }
179            | SessionEvent::SignalDisposed { category, .. }
180            | SessionEvent::BudgetExceeded { category, .. }
181            | SessionEvent::CheckpointTaken { category, .. }
182            | SessionEvent::Rollbacked { category, .. }
183            | SessionEvent::AgentProcessChanged { category, .. }
184            | SessionEvent::MilestoneAdvanced { category, .. }
185            | SessionEvent::MilestoneBlocked { category, .. }
186            | SessionEvent::MilestoneEvidence { category, .. } => category.is_some(),
187            _ => true,
188        }
189    })
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::runtime::event_log::KernelEventCategory;
196
197    #[test]
198    fn rebuild_tracks_process_and_signals() {
199        let events = vec![
200            SessionEvent::AgentProcessChanged {
201                turn: 1,
202                category: Some(KernelEventCategory::Proc),
203                primitive: None,
204                agent_id: "child-1".into(),
205                parent_session_id: "parent".into(),
206                role: "worker".into(),
207                isolation: "shared".into(),
208                context_inheritance: "none".into(),
209                state: "running".into(),
210                permitted_capability_ids: vec![],
211                result_termination: None,
212            },
213            SessionEvent::SignalDisposed {
214                turn: 2,
215                category: Some(KernelEventCategory::Ipc),
216                primitive: None,
217                signal_id: "sig-a".into(),
218                disposition: "queue".into(),
219                queue_depth: 1,
220            },
221            SessionEvent::Suspended {
222                turn: 3,
223                category: Some(KernelEventCategory::Sched),
224                primitive: None,
225                reason: "ask_user".into(),
226                pending_calls: vec!["c1".into()],
227            },
228            SessionEvent::AgentProcessChanged {
229                turn: 4,
230                category: Some(KernelEventCategory::Proc),
231                primitive: None,
232                agent_id: "child-1".into(),
233                parent_session_id: "parent".into(),
234                role: "worker".into(),
235                isolation: "shared".into(),
236                context_inheritance: "none".into(),
237                state: "joined".into(),
238                permitted_capability_ids: vec![],
239                result_termination: Some("completed".into()),
240            },
241        ];
242        let snap = rebuild_os_snapshot_from_events(&events);
243        assert_eq!(snap.process_by_agent.len(), 1);
244        assert_eq!(snap.process_by_agent[0].state, "joined");
245        assert_eq!(snap.signals.len(), 1);
246        assert_eq!(snap.last_suspend.as_ref().map(|s| s.reason.as_str()), Some("ask_user"));
247    }
248
249    fn load_fixture(name: &str) -> String {
250        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
251            .join("../../tests/fixtures/session")
252            .join(name);
253        std::fs::read_to_string(&path)
254            .unwrap_or_else(|e| panic!("read {}: {}", path.display(), e))
255    }
256
257    fn assert_golden(events_file: &str, snapshot_file: &str) {
258        let events: Vec<SessionEvent> =
259            serde_json::from_str(&load_fixture(events_file)).expect("events json");
260        assert!(session_log_has_required_categories(&events));
261        let snap = rebuild_os_snapshot_from_events(&events);
262        let expected: OsSnapshot =
263            serde_json::from_str(&load_fixture(snapshot_file)).expect("snapshot json");
264        assert_eq!(snap, expected);
265    }
266
267    #[test]
268    fn golden_os_snapshot_spawn_lifecycle_fixture() {
269        assert_golden("events_spawn_lifecycle.json", "os_snapshot_spawn_lifecycle.json");
270    }
271
272    #[test]
273    fn golden_os_snapshot_ask_user_fixture() {
274        assert_golden("events_ask_user.json", "os_snapshot_ask_user.json");
275    }
276}