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)]
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        /// "created", "modified", or "deleted". Absent in legacy events.
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        operation: Option<String>,
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        additions: Option<u32>,
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        deletions: Option<u32>,
97    },
98
99    #[serde(rename = "agent.opened_port")]
100    AgentOpenedPort {
101        port: u16,
102        #[serde(skip_serializing_if = "Option::is_none")]
103        protocol: Option<String>,
104    },
105
106    #[serde(rename = "agent.connected_network")]
107    AgentConnectedNetwork {
108        destination: String,
109        #[serde(skip_serializing_if = "Option::is_none")]
110        port: Option<u16>,
111    },
112
113    #[serde(rename = "agent.started_process")]
114    AgentStartedProcess {
115        process_name: String,
116        #[serde(skip_serializing_if = "Option::is_none")]
117        pid: Option<u32>,
118        /// Full command string (e.g. "npm test --runInBand"). Absent in legacy events.
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        command: Option<String>,
121    },
122
123    #[serde(rename = "agent.completed_process")]
124    AgentCompletedProcess {
125        process_name: String,
126        #[serde(skip_serializing_if = "Option::is_none")]
127        exit_code: Option<i32>,
128        #[serde(skip_serializing_if = "Option::is_none")]
129        duration_ms: Option<u64>,
130        #[serde(default, skip_serializing_if = "Option::is_none")]
131        command: Option<String>,
132    },
133
134    /// LLM inference decision with model, token usage, and cost.
135    #[serde(rename = "agent.decision")]
136    AgentDecision {
137        #[serde(skip_serializing_if = "Option::is_none")]
138        model: Option<String>,
139        #[serde(default, skip_serializing_if = "Option::is_none")]
140        tokens_in: Option<u64>,
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        tokens_out: Option<u64>,
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        cost_usd: Option<f64>,
145        #[serde(skip_serializing_if = "Option::is_none")]
146        summary: Option<String>,
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        confidence: Option<f64>,
149    },
150}
151
152/// A single session event with full context.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct SessionEvent {
155    pub session_id: String,
156    pub event_id: String,
157    pub timestamp: String,
158    pub sequence_no: u64,
159    pub trace_id: String,
160    pub span_id: String,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub parent_span_id: Option<String>,
163    pub agent_id: String,
164    pub agent_instance_id: String,
165    pub agent_name: String,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub agent_role: Option<String>,
168    pub host_id: String,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub tool_runtime_id: Option<String>,
171    #[serde(flatten)]
172    pub event_type: EventType,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub artifact_ref: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub meta: Option<serde_json::Value>,
177}
178
179/// Generate a random event ID: `evt_<16 hex chars>`.
180pub fn generate_event_id() -> String {
181    let mut buf = [0u8; 8];
182    use rand::RngCore;
183    rand::thread_rng().fill_bytes(&mut buf);
184    format!("evt_{}", hex::encode(buf))
185}
186
187/// Generate a random span ID: 16 hex chars (8 bytes, W3C compatible).
188pub fn generate_span_id() -> String {
189    let mut buf = [0u8; 8];
190    use rand::RngCore;
191    rand::thread_rng().fill_bytes(&mut buf);
192    hex::encode(buf)
193}
194
195/// Generate a random trace ID: 32 hex chars (16 bytes, W3C compatible).
196pub fn generate_trace_id() -> String {
197    let mut buf = [0u8; 16];
198    use rand::RngCore;
199    rand::thread_rng().fill_bytes(&mut buf);
200    hex::encode(buf)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn event_type_serialization() {
209        let evt = EventType::AgentCalledTool {
210            tool_name: "read_file".into(),
211            tool_input_digest: None,
212            tool_output_digest: None,
213            duration_ms: Some(42),
214        };
215        let json = serde_json::to_string(&evt).unwrap();
216        assert!(json.contains("agent.called_tool"));
217        assert!(json.contains("read_file"));
218
219        let back: EventType = serde_json::from_str(&json).unwrap();
220        assert_eq!(back, evt);
221    }
222
223    #[test]
224    fn full_event_roundtrip() {
225        let event = SessionEvent {
226            session_id: "ssn_001".into(),
227            event_id: generate_event_id(),
228            timestamp: "2026-04-05T08:00:00Z".into(),
229            sequence_no: 1,
230            trace_id: generate_trace_id(),
231            span_id: generate_span_id(),
232            parent_span_id: None,
233            agent_id: "agent://claude-code".into(),
234            agent_instance_id: "ai_cc_1".into(),
235            agent_name: "claude-code".into(),
236            agent_role: Some("planner".into()),
237            host_id: "host_macbook".into(),
238            tool_runtime_id: Some("rt_cc_1".into()),
239            event_type: EventType::AgentStarted {
240                parent_agent_instance_id: None,
241            },
242            artifact_ref: None,
243            meta: None,
244        };
245
246        let json = serde_json::to_string_pretty(&event).unwrap();
247        let back: SessionEvent = serde_json::from_str(&json).unwrap();
248        assert_eq!(back.session_id, "ssn_001");
249        assert_eq!(back.agent_name, "claude-code");
250    }
251
252    #[test]
253    fn id_generation() {
254        let eid = generate_event_id();
255        assert!(eid.starts_with("evt_"));
256        assert_eq!(eid.len(), 4 + 16); // "evt_" + 16 hex
257
258        let sid = generate_span_id();
259        assert_eq!(sid.len(), 16);
260
261        let tid = generate_trace_id();
262        assert_eq!(tid.len(), 32);
263    }
264}