Skip to main content

treeship_core/session/
side_effects.rs

1//! Side-effect aggregation from session events.
2//!
3//! Groups file, network, port, and process side effects for the
4//! side-effect ledger in the Session Report.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10use super::event::{EventType, SessionEvent};
11
12/// A file access event.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FileAccess {
15    pub file_path: String,
16    pub agent_instance_id: String,
17    pub timestamp: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub digest: Option<String>,
20}
21
22/// A port opened by an agent.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PortAccess {
25    pub port: u16,
26    pub agent_instance_id: String,
27    pub timestamp: String,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub protocol: Option<String>,
30}
31
32/// A network connection made by an agent.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct NetworkConnection {
35    pub destination: String,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub port: Option<u16>,
38    pub agent_instance_id: String,
39    pub timestamp: String,
40}
41
42/// A process execution by an agent.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ProcessExecution {
45    pub process_name: String,
46    pub agent_instance_id: String,
47    pub started_at: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub exit_code: Option<i32>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub duration_ms: Option<u64>,
52}
53
54/// A tool invocation by an agent.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ToolInvocation {
57    pub tool_name: String,
58    pub agent_instance_id: String,
59    pub timestamp: String,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub duration_ms: Option<u64>,
62}
63
64/// Aggregated side effects from a session.
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct SideEffects {
67    pub files_read: Vec<FileAccess>,
68    pub files_written: Vec<FileAccess>,
69    pub ports_opened: Vec<PortAccess>,
70    pub network_connections: Vec<NetworkConnection>,
71    pub processes: Vec<ProcessExecution>,
72    pub tool_invocations: Vec<ToolInvocation>,
73}
74
75impl SideEffects {
76    /// Build side effects from a sequence of session events.
77    pub fn from_events(events: &[SessionEvent]) -> Self {
78        let mut se = SideEffects::default();
79
80        // Track started processes so we can match with completed events.
81        // Key: (agent_instance_id, process_name)
82        let mut started_processes: BTreeMap<(String, String), usize> = BTreeMap::new();
83
84        for event in events {
85            match &event.event_type {
86                EventType::AgentReadFile { file_path, digest } => {
87                    se.files_read.push(FileAccess {
88                        file_path: file_path.clone(),
89                        agent_instance_id: event.agent_instance_id.clone(),
90                        timestamp: event.timestamp.clone(),
91                        digest: digest.clone(),
92                    });
93                }
94
95                EventType::AgentWroteFile { file_path, digest } => {
96                    se.files_written.push(FileAccess {
97                        file_path: file_path.clone(),
98                        agent_instance_id: event.agent_instance_id.clone(),
99                        timestamp: event.timestamp.clone(),
100                        digest: digest.clone(),
101                    });
102                }
103
104                EventType::AgentOpenedPort { port, protocol } => {
105                    se.ports_opened.push(PortAccess {
106                        port: *port,
107                        agent_instance_id: event.agent_instance_id.clone(),
108                        timestamp: event.timestamp.clone(),
109                        protocol: protocol.clone(),
110                    });
111                }
112
113                EventType::AgentConnectedNetwork { destination, port } => {
114                    se.network_connections.push(NetworkConnection {
115                        destination: destination.clone(),
116                        port: *port,
117                        agent_instance_id: event.agent_instance_id.clone(),
118                        timestamp: event.timestamp.clone(),
119                    });
120                }
121
122                EventType::AgentStartedProcess { process_name, pid: _ } => {
123                    let idx = se.processes.len();
124                    se.processes.push(ProcessExecution {
125                        process_name: process_name.clone(),
126                        agent_instance_id: event.agent_instance_id.clone(),
127                        started_at: event.timestamp.clone(),
128                        exit_code: None,
129                        duration_ms: None,
130                    });
131                    started_processes.insert(
132                        (event.agent_instance_id.clone(), process_name.clone()),
133                        idx,
134                    );
135                }
136
137                EventType::AgentCompletedProcess { process_name, exit_code, duration_ms } => {
138                    let key = (event.agent_instance_id.clone(), process_name.clone());
139                    if let Some(&idx) = started_processes.get(&key) {
140                        if let Some(proc) = se.processes.get_mut(idx) {
141                            proc.exit_code = *exit_code;
142                            proc.duration_ms = *duration_ms;
143                        }
144                    } else {
145                        // Completed without a started event (e.g., joined mid-session)
146                        se.processes.push(ProcessExecution {
147                            process_name: process_name.clone(),
148                            agent_instance_id: event.agent_instance_id.clone(),
149                            started_at: event.timestamp.clone(),
150                            exit_code: *exit_code,
151                            duration_ms: *duration_ms,
152                        });
153                    }
154                }
155
156                EventType::AgentCalledTool { tool_name, duration_ms, .. } => {
157                    se.tool_invocations.push(ToolInvocation {
158                        tool_name: tool_name.clone(),
159                        agent_instance_id: event.agent_instance_id.clone(),
160                        timestamp: event.timestamp.clone(),
161                        duration_ms: *duration_ms,
162                    });
163                }
164
165                _ => {}
166            }
167        }
168
169        se
170    }
171
172    /// Summary counts for display.
173    pub fn summary(&self) -> SideEffectSummary {
174        SideEffectSummary {
175            files_read: self.files_read.len() as u32,
176            files_written: self.files_written.len() as u32,
177            ports_opened: self.ports_opened.len() as u32,
178            network_connections: self.network_connections.len() as u32,
179            processes: self.processes.len() as u32,
180            tool_invocations: self.tool_invocations.len() as u32,
181        }
182    }
183}
184
185/// Summary counts of side effects.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SideEffectSummary {
188    pub files_read: u32,
189    pub files_written: u32,
190    pub ports_opened: u32,
191    pub network_connections: u32,
192    pub processes: u32,
193    pub tool_invocations: u32,
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::session::event::*;
200
201    fn evt(event_type: EventType) -> SessionEvent {
202        SessionEvent {
203            session_id: "ssn_001".into(),
204            event_id: generate_event_id(),
205            timestamp: "2026-04-05T08:00:00Z".into(),
206            sequence_no: 0,
207            trace_id: "t".into(),
208            span_id: "s".into(),
209            parent_span_id: None,
210            agent_id: "agent://test".into(),
211            agent_instance_id: "ai_1".into(),
212            agent_name: "test".into(),
213            agent_role: None,
214            host_id: "h".into(),
215            tool_runtime_id: None,
216            event_type,
217            artifact_ref: None,
218            meta: None,
219        }
220    }
221
222    #[test]
223    fn aggregates_file_and_tool_events() {
224        let events = vec![
225            evt(EventType::AgentReadFile { file_path: "src/main.rs".into(), digest: None }),
226            evt(EventType::AgentWroteFile { file_path: "src/lib.rs".into(), digest: Some("sha256:abc".into()) }),
227            evt(EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(10) }),
228            evt(EventType::AgentCalledTool { tool_name: "write_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: None }),
229        ];
230
231        let se = SideEffects::from_events(&events);
232        assert_eq!(se.files_read.len(), 1);
233        assert_eq!(se.files_written.len(), 1);
234        assert_eq!(se.tool_invocations.len(), 2);
235        let summary = se.summary();
236        assert_eq!(summary.tool_invocations, 2);
237    }
238
239    #[test]
240    fn matches_process_start_and_complete() {
241        let events = vec![
242            evt(EventType::AgentStartedProcess { process_name: "npm test".into(), pid: Some(1234) }),
243            evt(EventType::AgentCompletedProcess { process_name: "npm test".into(), exit_code: Some(0), duration_ms: Some(5000) }),
244        ];
245
246        let se = SideEffects::from_events(&events);
247        assert_eq!(se.processes.len(), 1);
248        assert_eq!(se.processes[0].exit_code, Some(0));
249        assert_eq!(se.processes[0].duration_ms, Some(5000));
250    }
251}