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()
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
164pub 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}