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 and token usage.
135    ///
136    // Cost is deliberately not captured. Pricing depends on provider,
137    // subscription tier, contract rates, and timing. Baking a dollar
138    // amount into a signed receipt would make old receipts falsely
139    // signed when pricing changes. Consumers (dashboards, billing tools)
140    // calculate cost from model + tokens + their pricing config.
141    #[serde(rename = "agent.decision")]
142    AgentDecision {
143        #[serde(skip_serializing_if = "Option::is_none")]
144        model: Option<String>,
145        #[serde(default, skip_serializing_if = "Option::is_none")]
146        tokens_in: Option<u64>,
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        tokens_out: Option<u64>,
149        /// Provider e.g. "anthropic", "openrouter", "bedrock", "openai"
150        #[serde(default, skip_serializing_if = "Option::is_none")]
151        provider: Option<String>,
152        #[serde(skip_serializing_if = "Option::is_none")]
153        summary: Option<String>,
154        #[serde(default, skip_serializing_if = "Option::is_none")]
155        confidence: Option<f64>,
156    },
157}
158
159/// A single session event with full context.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct SessionEvent {
162    pub session_id: String,
163    pub event_id: String,
164    pub timestamp: String,
165    pub sequence_no: u64,
166    pub trace_id: String,
167    pub span_id: String,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub parent_span_id: Option<String>,
170    pub agent_id: String,
171    pub agent_instance_id: String,
172    pub agent_name: String,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub agent_role: Option<String>,
175    pub host_id: String,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub tool_runtime_id: Option<String>,
178    #[serde(flatten)]
179    pub event_type: EventType,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub artifact_ref: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub meta: Option<serde_json::Value>,
184}
185
186/// Generate a random event ID: `evt_<16 hex chars>`.
187pub fn generate_event_id() -> String {
188    let mut buf = [0u8; 8];
189    use rand::RngCore;
190    rand::thread_rng().fill_bytes(&mut buf);
191    format!("evt_{}", hex::encode(buf))
192}
193
194/// Generate a random span ID: 16 hex chars (8 bytes, W3C compatible).
195pub fn generate_span_id() -> String {
196    let mut buf = [0u8; 8];
197    use rand::RngCore;
198    rand::thread_rng().fill_bytes(&mut buf);
199    hex::encode(buf)
200}
201
202/// Generate a random trace ID: 32 hex chars (16 bytes, W3C compatible).
203pub fn generate_trace_id() -> String {
204    let mut buf = [0u8; 16];
205    use rand::RngCore;
206    rand::thread_rng().fill_bytes(&mut buf);
207    hex::encode(buf)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn event_type_serialization() {
216        let evt = EventType::AgentCalledTool {
217            tool_name: "read_file".into(),
218            tool_input_digest: None,
219            tool_output_digest: None,
220            duration_ms: Some(42),
221        };
222        let json = serde_json::to_string(&evt).unwrap();
223        assert!(json.contains("agent.called_tool"));
224        assert!(json.contains("read_file"));
225
226        let back: EventType = serde_json::from_str(&json).unwrap();
227        assert_eq!(back, evt);
228    }
229
230    #[test]
231    fn full_event_roundtrip() {
232        let event = SessionEvent {
233            session_id: "ssn_001".into(),
234            event_id: generate_event_id(),
235            timestamp: "2026-04-05T08:00:00Z".into(),
236            sequence_no: 1,
237            trace_id: generate_trace_id(),
238            span_id: generate_span_id(),
239            parent_span_id: None,
240            agent_id: "agent://claude-code".into(),
241            agent_instance_id: "ai_cc_1".into(),
242            agent_name: "claude-code".into(),
243            agent_role: Some("planner".into()),
244            host_id: "host_macbook".into(),
245            tool_runtime_id: Some("rt_cc_1".into()),
246            event_type: EventType::AgentStarted {
247                parent_agent_instance_id: None,
248            },
249            artifact_ref: None,
250            meta: None,
251        };
252
253        let json = serde_json::to_string_pretty(&event).unwrap();
254        let back: SessionEvent = serde_json::from_str(&json).unwrap();
255        assert_eq!(back.session_id, "ssn_001");
256        assert_eq!(back.agent_name, "claude-code");
257    }
258
259    #[test]
260    fn id_generation() {
261        let eid = generate_event_id();
262        assert!(eid.starts_with("evt_"));
263        assert_eq!(eid.len(), 4 + 16); // "evt_" + 16 hex
264
265        let sid = generate_span_id();
266        assert_eq!(sid.len(), 16);
267
268        let tid = generate_trace_id();
269        assert_eq!(tid.len(), 32);
270    }
271}