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