1use serde::{Deserialize, Serialize};
8use sha2::{Sha256, Digest};
9
10use crate::merkle::{MerkleTree, InclusionProof};
11
12use super::event::SessionEvent;
13use super::graph::AgentGraph;
14use super::manifest::{
15 HostInfo, LifecycleMode, Participants, SessionManifest, SessionStatus, ToolInfo,
16};
17use super::render::RenderConfig;
18use super::side_effects::SideEffects;
19
20pub const RECEIPT_TYPE: &str = "treeship/session-receipt/v1";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SessionReceipt {
28 #[serde(rename = "type")]
30 pub type_: String,
31
32 pub session: SessionSection,
33 pub participants: Participants,
34 pub hosts: Vec<HostInfo>,
35 pub tools: Vec<ToolInfo>,
36 pub agent_graph: AgentGraph,
37 pub timeline: Vec<TimelineEntry>,
38 pub side_effects: SideEffects,
39 pub artifacts: Vec<ArtifactEntry>,
40 pub proofs: ProofsSection,
41 pub merkle: MerkleSection,
42 pub render: RenderConfig,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SessionSection {
48 pub id: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub name: Option<String>,
51 pub mode: LifecycleMode,
52 pub started_at: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub ended_at: Option<String>,
55 pub status: SessionStatus,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub duration_ms: Option<u64>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TimelineEntry {
63 pub sequence_no: u64,
64 pub timestamp: String,
65 pub event_id: String,
66 pub event_type: String,
67 pub agent_instance_id: String,
68 pub agent_name: String,
69 pub host_id: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub summary: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ArtifactEntry {
77 pub artifact_id: String,
78 pub payload_type: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub digest: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub signed_at: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct ProofsSection {
88 #[serde(default)]
89 pub signature_count: u32,
90 #[serde(default)]
91 pub signatures_valid: bool,
92 #[serde(default)]
93 pub merkle_root_valid: bool,
94 #[serde(default)]
95 pub inclusion_proofs_count: u32,
96 #[serde(default)]
97 pub zk_proofs_present: bool,
98}
99
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct MerkleSection {
103 pub leaf_count: usize,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub root: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub checkpoint_id: Option<String>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub inclusion_proofs: Vec<InclusionProofEntry>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct InclusionProofEntry {
115 pub artifact_id: String,
116 pub leaf_index: usize,
117 pub proof: InclusionProof,
118}
119
120pub struct ReceiptComposer;
124
125impl ReceiptComposer {
126 pub fn compose(
128 manifest: &SessionManifest,
129 events: &[SessionEvent],
130 artifact_entries: Vec<ArtifactEntry>,
131 ) -> SessionReceipt {
132 let agent_graph = AgentGraph::from_events(events);
134
135 let side_effects = SideEffects::from_events(events);
137
138 let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
140 TimelineEntry {
141 sequence_no: e.sequence_no,
142 timestamp: e.timestamp.clone(),
143 event_id: e.event_id.clone(),
144 event_type: event_type_label(&e.event_type),
145 agent_instance_id: e.agent_instance_id.clone(),
146 agent_name: e.agent_name.clone(),
147 host_id: e.host_id.clone(),
148 summary: event_summary(&e.event_type),
149 }
150 }).collect();
151
152 timeline.sort_by(|a, b| {
154 a.timestamp.cmp(&b.timestamp)
155 .then(a.sequence_no.cmp(&b.sequence_no))
156 .then(a.event_id.cmp(&b.event_id))
157 });
158
159 let participants = compute_participants(&agent_graph, manifest);
161
162 let hosts = compute_hosts(events, &manifest.hosts);
164 let tools = compute_tools(events, &manifest.tools);
165
166 let duration_ms = events.iter().find_map(|e| {
168 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
169 *duration_ms
170 } else {
171 None
172 }
173 });
174
175 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
177
178 let proofs = ProofsSection {
180 signature_count: artifact_entries.len() as u32,
181 signatures_valid: true, merkle_root_valid: merkle_tree.is_some(),
183 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
184 zk_proofs_present: false,
185 };
186
187 let session = SessionSection {
189 id: manifest.session_id.clone(),
190 name: manifest.name.clone(),
191 mode: manifest.mode.clone(),
192 started_at: manifest.started_at.clone(),
193 ended_at: manifest.closed_at.clone(),
194 status: manifest.status.clone(),
195 duration_ms,
196 };
197
198 let render = RenderConfig {
200 title: manifest.name.clone(),
201 theme: None,
202 sections: RenderConfig::default_sections(),
203 generate_preview: true,
204 };
205
206 SessionReceipt {
207 type_: RECEIPT_TYPE.into(),
208 session,
209 participants,
210 hosts,
211 tools,
212 agent_graph,
213 timeline,
214 side_effects,
215 artifacts: artifact_entries,
216 proofs,
217 merkle: merkle_section,
218 render,
219 }
220 }
221
222 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
227 serde_json::to_vec(receipt)
228 }
229
230 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
232 let bytes = Self::to_canonical_json(receipt)?;
233 let hash = Sha256::digest(&bytes);
234 Ok(format!("sha256:{}", hex::encode(hash)))
235 }
236}
237
238fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
241 use std::collections::BTreeSet;
242
243 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
244 let total_agents = graph.nodes.len() as u32;
246 let spawned_subagents = graph.spawn_count();
247 let handoffs = graph.handoff_count();
248 let max_depth = graph.max_depth();
249 let host_ids = graph.host_ids();
250
251 for tool in &manifest.tools {
253 if let Some(ref rt) = tool.tool_runtime_id {
254 tool_runtimes.insert(rt.clone());
255 }
256 }
257
258 let root = graph.nodes.iter()
260 .filter(|n| n.depth == 0)
261 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
262 .map(|n| n.agent_instance_id.clone());
263
264 let final_output = graph.nodes.iter()
266 .filter(|n| n.completed_at.is_some())
267 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
268 .map(|n| n.agent_instance_id.clone());
269
270 Participants {
271 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
272 final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
273 total_agents,
274 spawned_subagents,
275 handoffs,
276 max_depth,
277 hosts: host_ids.len() as u32,
278 tool_runtimes: tool_runtimes.len() as u32,
279 }
280}
281
282fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
283 use std::collections::BTreeMap;
284
285 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
286
287 for h in manifest_hosts {
289 hosts.insert(h.host_id.clone(), h.clone());
290 }
291
292 for e in events {
294 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
295 host_id: e.host_id.clone(),
296 hostname: None,
297 os: None,
298 arch: None,
299 });
300 }
301
302 hosts.into_values().collect()
303}
304
305fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
306 use std::collections::BTreeMap;
307
308 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
309
310 for t in manifest_tools {
312 tools.insert(t.tool_id.clone(), t.clone());
313 }
314
315 for e in events {
317 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
318 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
319 tool_id: tool_name.clone(),
320 tool_name: tool_name.clone(),
321 tool_runtime_id: e.tool_runtime_id.clone(),
322 invocation_count: 0,
323 });
324 entry.invocation_count += 1;
325 }
326 }
327
328 tools.into_values().collect()
329}
330
331fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
332 if artifacts.is_empty() {
333 return (MerkleSection::default(), None);
334 }
335
336 let mut tree = MerkleTree::new();
337 for art in artifacts {
338 tree.append(&art.artifact_id);
339 }
340
341 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
342
343 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
345 .filter_map(|(i, art)| {
346 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
347 artifact_id: art.artifact_id.clone(),
348 leaf_index: i,
349 proof,
350 })
351 })
352 .collect();
353
354 let section = MerkleSection {
355 leaf_count: artifacts.len(),
356 root,
357 checkpoint_id: None,
358 inclusion_proofs,
359 };
360
361 (section, Some(tree))
362}
363
364fn event_type_label(et: &super::event::EventType) -> String {
366 use super::event::EventType::*;
367 match et {
368 SessionStarted => "session.started",
369 SessionClosed { .. } => "session.closed",
370 AgentStarted { .. } => "agent.started",
371 AgentSpawned { .. } => "agent.spawned",
372 AgentHandoff { .. } => "agent.handoff",
373 AgentCollaborated { .. } => "agent.collaborated",
374 AgentReturned { .. } => "agent.returned",
375 AgentCompleted { .. } => "agent.completed",
376 AgentFailed { .. } => "agent.failed",
377 AgentCalledTool { .. } => "agent.called_tool",
378 AgentReadFile { .. } => "agent.read_file",
379 AgentWroteFile { .. } => "agent.wrote_file",
380 AgentOpenedPort { .. } => "agent.opened_port",
381 AgentConnectedNetwork { .. } => "agent.connected_network",
382 AgentStartedProcess { .. } => "agent.started_process",
383 AgentCompletedProcess { .. } => "agent.completed_process",
384 }.into()
385}
386
387fn event_summary(et: &super::event::EventType) -> Option<String> {
389 use super::event::EventType::*;
390 match et {
391 SessionStarted => Some("Session started".into()),
392 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
393 AgentSpawned { reason, .. } => reason.clone(),
394 AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
395 Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
396 }
397 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
398 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
399 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
400 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
401 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
402 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
403 AgentCompletedProcess { process_name, exit_code, .. } => {
404 Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
405 }
406 AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
407 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
408 _ => None,
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::session::event::*;
416
417 fn make_manifest() -> SessionManifest {
418 SessionManifest::new(
419 "ssn_001".into(),
420 "agent://test".into(),
421 "2026-04-05T08:00:00Z".into(),
422 1743843600000,
423 )
424 }
425
426 fn make_events() -> Vec<SessionEvent> {
427 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
428 SessionEvent {
429 session_id: "ssn_001".into(),
430 event_id: format!("evt_{:016x}", seq),
431 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
432 sequence_no: seq,
433 trace_id: "trace_1".into(),
434 span_id: format!("span_{seq}"),
435 parent_span_id: None,
436 agent_id: format!("agent://{inst}"),
437 agent_instance_id: inst.into(),
438 agent_name: inst.into(),
439 agent_role: None,
440 host_id: "host_1".into(),
441 tool_runtime_id: None,
442 event_type: et,
443 artifact_ref: None,
444 meta: None,
445 }
446 };
447
448 vec![
449 mk(0, "root", EventType::SessionStarted),
450 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
451 mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
452 mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
453 mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None }),
454 mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
455 mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
456 ]
457 }
458
459 #[test]
460 fn compose_receipt() {
461 let manifest = make_manifest();
462 let events = make_events();
463 let artifacts = vec![
464 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
465 ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
466 ];
467
468 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
469
470 assert_eq!(receipt.type_, RECEIPT_TYPE);
471 assert_eq!(receipt.session.id, "ssn_001");
472 assert_eq!(receipt.timeline.len(), 7);
473 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
475 assert_eq!(receipt.merkle.leaf_count, 2);
476 assert!(receipt.merkle.root.is_some());
477 }
478
479 #[test]
480 fn canonical_json_is_deterministic() {
481 let manifest = make_manifest();
482 let events = make_events();
483 let artifacts = vec![
484 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
485 ];
486
487 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
488 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
489
490 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
491 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
492 assert_eq!(j1, j2);
493
494 let d1 = ReceiptComposer::digest(&r1).unwrap();
495 let d2 = ReceiptComposer::digest(&r2).unwrap();
496 assert_eq!(d1, d2);
497 }
498}