1use 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#[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
60pub 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()
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}