Skip to main content

treeship_core/session/
event.rs

1//! Session event model for Session Receipt v1.
2//!
3//! Events are the raw building blocks of a session. They are emitted by
4//! SDKs, CLI wrappers, and daemons, then composed into the receipt.
5
6use serde::{Deserialize, Serialize};
7
8/// All supported session event types.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(tag = "type", rename_all = "snake_case")]
11pub enum EventType {
12    #[serde(rename = "session.started")]
13    SessionStarted,
14
15    #[serde(rename = "session.closed")]
16    SessionClosed {
17        #[serde(skip_serializing_if = "Option::is_none")]
18        summary: Option<String>,
19        #[serde(skip_serializing_if = "Option::is_none")]
20        duration_ms: Option<u64>,
21    },
22
23    #[serde(rename = "agent.started")]
24    AgentStarted {
25        #[serde(skip_serializing_if = "Option::is_none")]
26        parent_agent_instance_id: Option<String>,
27    },
28
29    #[serde(rename = "agent.spawned")]
30    AgentSpawned {
31        spawned_by_agent_instance_id: String,
32        #[serde(skip_serializing_if = "Option::is_none")]
33        reason: Option<String>,
34    },
35
36    #[serde(rename = "agent.handoff")]
37    AgentHandoff {
38        from_agent_instance_id: String,
39        to_agent_instance_id: String,
40        #[serde(default, skip_serializing_if = "Vec::is_empty")]
41        artifacts: Vec<String>,
42    },
43
44    #[serde(rename = "agent.collaborated")]
45    AgentCollaborated {
46        #[serde(default, skip_serializing_if = "Vec::is_empty")]
47        collaborator_agent_instance_ids: Vec<String>,
48    },
49
50    #[serde(rename = "agent.returned")]
51    AgentReturned {
52        returned_to_agent_instance_id: String,
53    },
54
55    #[serde(rename = "agent.completed")]
56    AgentCompleted {
57        #[serde(skip_serializing_if = "Option::is_none")]
58        termination_reason: Option<String>,
59    },
60
61    #[serde(rename = "agent.failed")]
62    AgentFailed {
63        #[serde(skip_serializing_if = "Option::is_none")]
64        reason: Option<String>,
65    },
66
67    #[serde(rename = "agent.called_tool")]
68    AgentCalledTool {
69        tool_name: String,
70        #[serde(skip_serializing_if = "Option::is_none")]
71        tool_input_digest: Option<String>,
72        #[serde(skip_serializing_if = "Option::is_none")]
73        tool_output_digest: Option<String>,
74        #[serde(skip_serializing_if = "Option::is_none")]
75        duration_ms: Option<u64>,
76    },
77
78    #[serde(rename = "agent.read_file")]
79    AgentReadFile {
80        file_path: String,
81        #[serde(skip_serializing_if = "Option::is_none")]
82        digest: Option<String>,
83    },
84
85    #[serde(rename = "agent.wrote_file")]
86    AgentWroteFile {
87        file_path: String,
88        #[serde(skip_serializing_if = "Option::is_none")]
89        digest: Option<String>,
90    },
91
92    #[serde(rename = "agent.opened_port")]
93    AgentOpenedPort {
94        port: u16,
95        #[serde(skip_serializing_if = "Option::is_none")]
96        protocol: Option<String>,
97    },
98
99    #[serde(rename = "agent.connected_network")]
100    AgentConnectedNetwork {
101        destination: String,
102        #[serde(skip_serializing_if = "Option::is_none")]
103        port: Option<u16>,
104    },
105
106    #[serde(rename = "agent.started_process")]
107    AgentStartedProcess {
108        process_name: String,
109        #[serde(skip_serializing_if = "Option::is_none")]
110        pid: Option<u32>,
111    },
112
113    #[serde(rename = "agent.completed_process")]
114    AgentCompletedProcess {
115        process_name: String,
116        #[serde(skip_serializing_if = "Option::is_none")]
117        exit_code: Option<i32>,
118        #[serde(skip_serializing_if = "Option::is_none")]
119        duration_ms: Option<u64>,
120    },
121}
122
123/// A single session event with full context.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct SessionEvent {
126    pub session_id: String,
127    pub event_id: String,
128    pub timestamp: String,
129    pub sequence_no: u64,
130    pub trace_id: String,
131    pub span_id: String,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub parent_span_id: Option<String>,
134    pub agent_id: String,
135    pub agent_instance_id: String,
136    pub agent_name: String,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub agent_role: Option<String>,
139    pub host_id: String,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub tool_runtime_id: Option<String>,
142    #[serde(flatten)]
143    pub event_type: EventType,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub artifact_ref: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub meta: Option<serde_json::Value>,
148}
149
150/// Generate a random event ID: `evt_<16 hex chars>`.
151pub fn generate_event_id() -> String {
152    let mut buf = [0u8; 8];
153    use rand::RngCore;
154    rand::thread_rng().fill_bytes(&mut buf);
155    format!("evt_{}", hex::encode(buf))
156}
157
158/// Generate a random span ID: 16 hex chars (8 bytes, W3C compatible).
159pub fn generate_span_id() -> String {
160    let mut buf = [0u8; 8];
161    use rand::RngCore;
162    rand::thread_rng().fill_bytes(&mut buf);
163    hex::encode(buf)
164}
165
166/// Generate a random trace ID: 32 hex chars (16 bytes, W3C compatible).
167pub fn generate_trace_id() -> String {
168    let mut buf = [0u8; 16];
169    use rand::RngCore;
170    rand::thread_rng().fill_bytes(&mut buf);
171    hex::encode(buf)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn event_type_serialization() {
180        let evt = EventType::AgentCalledTool {
181            tool_name: "read_file".into(),
182            tool_input_digest: None,
183            tool_output_digest: None,
184            duration_ms: Some(42),
185        };
186        let json = serde_json::to_string(&evt).unwrap();
187        assert!(json.contains("agent.called_tool"));
188        assert!(json.contains("read_file"));
189
190        let back: EventType = serde_json::from_str(&json).unwrap();
191        assert_eq!(back, evt);
192    }
193
194    #[test]
195    fn full_event_roundtrip() {
196        let event = SessionEvent {
197            session_id: "ssn_001".into(),
198            event_id: generate_event_id(),
199            timestamp: "2026-04-05T08:00:00Z".into(),
200            sequence_no: 1,
201            trace_id: generate_trace_id(),
202            span_id: generate_span_id(),
203            parent_span_id: None,
204            agent_id: "agent://claude-code".into(),
205            agent_instance_id: "ai_cc_1".into(),
206            agent_name: "claude-code".into(),
207            agent_role: Some("planner".into()),
208            host_id: "host_macbook".into(),
209            tool_runtime_id: Some("rt_cc_1".into()),
210            event_type: EventType::AgentStarted {
211                parent_agent_instance_id: None,
212            },
213            artifact_ref: None,
214            meta: None,
215        };
216
217        let json = serde_json::to_string_pretty(&event).unwrap();
218        let back: SessionEvent = serde_json::from_str(&json).unwrap();
219        assert_eq!(back.session_id, "ssn_001");
220        assert_eq!(back.agent_name, "claude-code");
221    }
222
223    #[test]
224    fn id_generation() {
225        let eid = generate_event_id();
226        assert!(eid.starts_with("evt_"));
227        assert_eq!(eid.len(), 4 + 16); // "evt_" + 16 hex
228
229        let sid = generate_span_id();
230        assert_eq!(sid.len(), 16);
231
232        let tid = generate_trace_id();
233        assert_eq!(tid.len(), 32);
234    }
235}