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 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub tool_usage: Option<ToolUsage>,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ToolUsage {
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub declared: Vec<String>,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub actual: Vec<ToolUsageEntry>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub unauthorized: Vec<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ToolUsageEntry {
65 pub tool_name: String,
66 pub count: u32,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SessionSection {
72 pub id: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub name: Option<String>,
75 pub mode: LifecycleMode,
76 pub started_at: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub ended_at: Option<String>,
79 pub status: SessionStatus,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub duration_ms: Option<u64>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub narrative: Option<Narrative>,
85 #[serde(default)]
87 pub total_tokens_in: u64,
88 #[serde(default)]
90 pub total_tokens_out: u64,
91}
92
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct Narrative {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub headline: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub summary: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub review: Option<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct TimelineEntry {
111 pub sequence_no: u64,
112 pub timestamp: String,
113 pub event_id: String,
114 pub event_type: String,
115 pub agent_instance_id: String,
116 pub agent_name: String,
117 pub host_id: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub summary: Option<String>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ArtifactEntry {
125 pub artifact_id: String,
126 pub payload_type: String,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub digest: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub signed_at: Option<String>,
131}
132
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
135pub struct ProofsSection {
136 #[serde(default)]
137 pub signature_count: u32,
138 #[serde(default)]
139 pub signatures_valid: bool,
140 #[serde(default)]
141 pub merkle_root_valid: bool,
142 #[serde(default)]
143 pub inclusion_proofs_count: u32,
144 #[serde(default)]
145 pub zk_proofs_present: bool,
146}
147
148#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150pub struct MerkleSection {
151 pub leaf_count: usize,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub root: Option<String>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub checkpoint_id: Option<String>,
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub inclusion_proofs: Vec<InclusionProofEntry>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct InclusionProofEntry {
163 pub artifact_id: String,
164 pub leaf_index: usize,
165 pub proof: InclusionProof,
166}
167
168pub struct ReceiptComposer;
172
173impl ReceiptComposer {
174 pub fn compose(
176 manifest: &SessionManifest,
177 events: &[SessionEvent],
178 artifact_entries: Vec<ArtifactEntry>,
179 ) -> SessionReceipt {
180 let agent_graph = AgentGraph::from_events(events);
182
183 let side_effects = SideEffects::from_events(events);
185
186 let mut timeline: Vec<TimelineEntry> = events.iter().map(|e| {
188 TimelineEntry {
189 sequence_no: e.sequence_no,
190 timestamp: e.timestamp.clone(),
191 event_id: e.event_id.clone(),
192 event_type: event_type_label(&e.event_type),
193 agent_instance_id: e.agent_instance_id.clone(),
194 agent_name: e.agent_name.clone(),
195 host_id: e.host_id.clone(),
196 summary: event_summary(&e.event_type),
197 }
198 }).collect();
199
200 timeline.sort_by(|a, b| {
202 a.timestamp.cmp(&b.timestamp)
203 .then(a.sequence_no.cmp(&b.sequence_no))
204 .then(a.event_id.cmp(&b.event_id))
205 });
206
207 let participants = compute_participants(&agent_graph, manifest);
209
210 let hosts = compute_hosts(events, &manifest.hosts);
212 let tools = compute_tools(events, &manifest.tools);
213
214 let duration_ms = events.iter().find_map(|e| {
216 if let super::event::EventType::SessionClosed { duration_ms, .. } = &e.event_type {
217 *duration_ms
218 } else {
219 None
220 }
221 });
222
223 let (merkle_section, merkle_tree) = build_merkle(&artifact_entries);
225
226 let proofs = ProofsSection {
230 signature_count: artifact_entries.len() as u32,
231 signatures_valid: true, merkle_root_valid: merkle_tree.is_some(),
233 inclusion_proofs_count: merkle_section.inclusion_proofs.len() as u32,
234 zk_proofs_present: false,
235 };
236
237 let total_tokens_in: u64 = agent_graph.nodes.iter().map(|n| n.tokens_in).sum();
240 let total_tokens_out: u64 = agent_graph.nodes.iter().map(|n| n.tokens_out).sum();
241
242 let session = SessionSection {
244 id: manifest.session_id.clone(),
245 name: manifest.name.clone(),
246 mode: manifest.mode.clone(),
247 started_at: manifest.started_at.clone(),
248 ended_at: manifest.closed_at.clone(),
249 status: manifest.status.clone(),
250 duration_ms,
251 narrative: manifest.summary.as_ref().map(|s| Narrative {
252 headline: manifest.name.clone(),
253 summary: Some(s.clone()),
254 review: None,
255 }),
256 total_tokens_in,
257 total_tokens_out,
258 };
259
260 let render = RenderConfig {
262 title: manifest.name.clone(),
263 theme: None,
264 sections: RenderConfig::default_sections(),
265 generate_preview: true,
266 };
267
268 let tool_usage = derive_tool_usage(&side_effects, &manifest.authorized_tools);
270
271 SessionReceipt {
272 type_: RECEIPT_TYPE.into(),
273 session,
274 participants,
275 hosts,
276 tools,
277 agent_graph,
278 timeline,
279 side_effects,
280 artifacts: artifact_entries,
281 proofs,
282 merkle: merkle_section,
283 render,
284 tool_usage,
285 }
286 }
287
288 pub fn to_canonical_json(receipt: &SessionReceipt) -> Result<Vec<u8>, serde_json::Error> {
293 serde_json::to_vec(receipt)
294 }
295
296 pub fn digest(receipt: &SessionReceipt) -> Result<String, serde_json::Error> {
298 let bytes = Self::to_canonical_json(receipt)?;
299 let hash = Sha256::digest(&bytes);
300 Ok(format!("sha256:{}", hex::encode(hash)))
301 }
302}
303
304fn compute_participants(graph: &AgentGraph, manifest: &SessionManifest) -> Participants {
307 use std::collections::BTreeSet;
308
309 let mut tool_runtimes: BTreeSet<String> = BTreeSet::new();
310 let total_agents = graph.nodes.len() as u32;
312 let spawned_subagents = graph.spawn_count();
313 let handoffs = graph.handoff_count();
314 let max_depth = graph.max_depth();
315 let host_ids = graph.host_ids();
316
317 for tool in &manifest.tools {
319 if let Some(ref rt) = tool.tool_runtime_id {
320 tool_runtimes.insert(rt.clone());
321 }
322 }
323
324 let root = graph.nodes.iter()
326 .filter(|n| n.depth == 0)
327 .min_by_key(|n| n.started_at.as_deref().unwrap_or(""))
328 .map(|n| n.agent_instance_id.clone());
329
330 let final_output = graph.nodes.iter()
332 .filter(|n| n.completed_at.is_some())
333 .max_by_key(|n| n.completed_at.as_deref().unwrap_or(""))
334 .map(|n| n.agent_instance_id.clone());
335
336 Participants {
337 root_agent_instance_id: root.or(manifest.participants.root_agent_instance_id.clone()),
338 final_output_agent_instance_id: final_output.or(manifest.participants.final_output_agent_instance_id.clone()),
339 total_agents,
340 spawned_subagents,
341 handoffs,
342 max_depth,
343 hosts: host_ids.len() as u32,
344 tool_runtimes: tool_runtimes.len() as u32,
345 }
346}
347
348fn compute_hosts(events: &[SessionEvent], manifest_hosts: &[HostInfo]) -> Vec<HostInfo> {
349 use std::collections::BTreeMap;
350
351 let mut hosts: BTreeMap<String, HostInfo> = BTreeMap::new();
352
353 for h in manifest_hosts {
355 hosts.insert(h.host_id.clone(), h.clone());
356 }
357
358 for e in events {
360 hosts.entry(e.host_id.clone()).or_insert_with(|| HostInfo {
361 host_id: e.host_id.clone(),
362 hostname: None,
363 os: None,
364 arch: None,
365 });
366 }
367
368 hosts.into_values().collect()
369}
370
371fn compute_tools(events: &[SessionEvent], manifest_tools: &[ToolInfo]) -> Vec<ToolInfo> {
372 use std::collections::BTreeMap;
373
374 let mut tools: BTreeMap<String, ToolInfo> = BTreeMap::new();
375
376 for t in manifest_tools {
378 tools.insert(t.tool_id.clone(), t.clone());
379 }
380
381 for e in events {
383 if let super::event::EventType::AgentCalledTool { ref tool_name, .. } = e.event_type {
384 let entry = tools.entry(tool_name.clone()).or_insert_with(|| ToolInfo {
385 tool_id: tool_name.clone(),
386 tool_name: tool_name.clone(),
387 tool_runtime_id: e.tool_runtime_id.clone(),
388 invocation_count: 0,
389 });
390 entry.invocation_count += 1;
391 }
392 }
393
394 tools.into_values().collect()
395}
396
397fn build_merkle(artifacts: &[ArtifactEntry]) -> (MerkleSection, Option<MerkleTree>) {
398 if artifacts.is_empty() {
399 return (MerkleSection::default(), None);
400 }
401
402 let mut tree = MerkleTree::new();
403 for art in artifacts {
404 tree.append(&art.artifact_id);
405 }
406
407 let root = tree.root().map(|r| format!("mroot_{}", hex::encode(r)));
408
409 let inclusion_proofs: Vec<InclusionProofEntry> = artifacts.iter().enumerate()
411 .filter_map(|(i, art)| {
412 tree.inclusion_proof(i).map(|proof| InclusionProofEntry {
413 artifact_id: art.artifact_id.clone(),
414 leaf_index: i,
415 proof,
416 })
417 })
418 .collect();
419
420 let section = MerkleSection {
421 leaf_count: artifacts.len(),
422 root,
423 checkpoint_id: None,
424 inclusion_proofs,
425 };
426
427 (section, Some(tree))
428}
429
430fn derive_tool_usage(
433 side_effects: &SideEffects,
434 authorized_tools: &[String],
435) -> Option<ToolUsage> {
436 use std::collections::BTreeMap;
437
438 if side_effects.tool_invocations.is_empty() && authorized_tools.is_empty() {
439 return None;
440 }
441
442 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
444 for inv in &side_effects.tool_invocations {
445 *counts.entry(inv.tool_name.clone()).or_insert(0) += 1;
446 }
447
448 let actual: Vec<ToolUsageEntry> = counts.iter()
449 .map(|(name, &count)| ToolUsageEntry { tool_name: name.clone(), count })
450 .collect();
451
452 let unauthorized = if authorized_tools.is_empty() {
454 Vec::new()
455 } else {
456 let declared_set: std::collections::BTreeSet<&str> = authorized_tools.iter()
457 .map(|s| s.as_str())
458 .collect();
459 counts.keys()
460 .filter(|name| !declared_set.contains(name.as_str()))
461 .cloned()
462 .collect()
463 };
464
465 Some(ToolUsage {
466 declared: authorized_tools.to_vec(),
467 actual,
468 unauthorized,
469 })
470}
471
472fn event_type_label(et: &super::event::EventType) -> String {
473 use super::event::EventType::*;
474 match et {
475 SessionStarted => "session.started",
476 SessionClosed { .. } => "session.closed",
477 AgentStarted { .. } => "agent.started",
478 AgentSpawned { .. } => "agent.spawned",
479 AgentHandoff { .. } => "agent.handoff",
480 AgentCollaborated { .. } => "agent.collaborated",
481 AgentReturned { .. } => "agent.returned",
482 AgentCompleted { .. } => "agent.completed",
483 AgentFailed { .. } => "agent.failed",
484 AgentCalledTool { .. } => "agent.called_tool",
485 AgentReadFile { .. } => "agent.read_file",
486 AgentWroteFile { .. } => "agent.wrote_file",
487 AgentOpenedPort { .. } => "agent.opened_port",
488 AgentConnectedNetwork { .. } => "agent.connected_network",
489 AgentStartedProcess { .. } => "agent.started_process",
490 AgentCompletedProcess { .. } => "agent.completed_process",
491 AgentDecision { .. } => "agent.decision",
492 }.into()
493}
494
495fn event_summary(et: &super::event::EventType) -> Option<String> {
497 use super::event::EventType::*;
498 match et {
499 SessionStarted => Some("Session started".into()),
500 SessionClosed { summary, .. } => summary.clone().or(Some("Session closed".into())),
501 AgentSpawned { reason, .. } => reason.clone(),
502 AgentHandoff { from_agent_instance_id, to_agent_instance_id, .. } => {
503 Some(format!("{from_agent_instance_id} -> {to_agent_instance_id}"))
504 }
505 AgentCalledTool { tool_name, .. } => Some(format!("Called {tool_name}")),
506 AgentReadFile { file_path, .. } => Some(format!("Read {file_path}")),
507 AgentWroteFile { file_path, .. } => Some(format!("Wrote {file_path}")),
508 AgentOpenedPort { port, .. } => Some(format!("Opened port {port}")),
509 AgentConnectedNetwork { destination, .. } => Some(format!("Connected to {destination}")),
510 AgentStartedProcess { process_name, .. } => Some(format!("Started {process_name}")),
511 AgentCompletedProcess { process_name, exit_code, .. } => {
512 Some(format!("Completed {process_name} (exit {})", exit_code.unwrap_or(-1)))
513 }
514 AgentCompleted { termination_reason } => termination_reason.clone().or(Some("Agent completed".into())),
515 AgentFailed { reason } => reason.clone().or(Some("Agent failed".into())),
516 AgentDecision { model, summary, provider, .. } => {
517 let mut parts = Vec::new();
518 if let Some(s) = summary { parts.push(s.clone()); }
519 if let Some(m) = model { parts.push(format!("model: {m}")); }
520 if let Some(p) = provider { parts.push(format!("via {p}")); }
521 if parts.is_empty() { Some("LLM decision".into()) } else { Some(parts.join(" | ")) }
522 }
523 _ => None,
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use crate::session::event::*;
531
532 fn make_manifest() -> SessionManifest {
533 SessionManifest::new(
534 "ssn_001".into(),
535 "agent://test".into(),
536 "2026-04-05T08:00:00Z".into(),
537 1743843600000,
538 )
539 }
540
541 fn make_events() -> Vec<SessionEvent> {
542 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
543 SessionEvent {
544 session_id: "ssn_001".into(),
545 event_id: format!("evt_{:016x}", seq),
546 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
547 sequence_no: seq,
548 trace_id: "trace_1".into(),
549 span_id: format!("span_{seq}"),
550 parent_span_id: None,
551 agent_id: format!("agent://{inst}"),
552 agent_instance_id: inst.into(),
553 agent_name: inst.into(),
554 agent_role: None,
555 host_id: "host_1".into(),
556 tool_runtime_id: None,
557 event_type: et,
558 artifact_ref: None,
559 meta: None,
560 }
561 };
562
563 vec![
564 mk(0, "root", EventType::SessionStarted),
565 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
566 mk(2, "worker", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: Some("review".into()) }),
567 mk(3, "worker", EventType::AgentCalledTool { tool_name: "read_file".into(), tool_input_digest: None, tool_output_digest: None, duration_ms: Some(5) }),
568 mk(4, "worker", EventType::AgentWroteFile { file_path: "src/fix.rs".into(), digest: None, operation: None, additions: None, deletions: None }),
569 mk(5, "worker", EventType::AgentCompleted { termination_reason: None }),
570 mk(6, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(360000) }),
571 ]
572 }
573
574 #[test]
575 fn compose_receipt() {
576 let manifest = make_manifest();
577 let events = make_events();
578 let artifacts = vec![
579 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
580 ArtifactEntry { artifact_id: "art_002".into(), payload_type: "action".into(), digest: None, signed_at: None },
581 ];
582
583 let receipt = ReceiptComposer::compose(&manifest, &events, artifacts);
584
585 assert_eq!(receipt.type_, RECEIPT_TYPE);
586 assert_eq!(receipt.session.id, "ssn_001");
587 assert_eq!(receipt.timeline.len(), 7);
588 assert_eq!(receipt.agent_graph.nodes.len(), 2); assert_eq!(receipt.side_effects.files_written.len(), 1);
590 assert_eq!(receipt.merkle.leaf_count, 2);
591 assert!(receipt.merkle.root.is_some());
592 }
593
594 #[test]
595 fn canonical_json_is_deterministic() {
596 let manifest = make_manifest();
597 let events = make_events();
598 let artifacts = vec![
599 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
600 ];
601
602 let r1 = ReceiptComposer::compose(&manifest, &events, artifacts.clone());
603 let r2 = ReceiptComposer::compose(&manifest, &events, artifacts);
604
605 let j1 = ReceiptComposer::to_canonical_json(&r1).unwrap();
606 let j2 = ReceiptComposer::to_canonical_json(&r2).unwrap();
607 assert_eq!(j1, j2);
608
609 let d1 = ReceiptComposer::digest(&r1).unwrap();
610 let d2 = ReceiptComposer::digest(&r2).unwrap();
611 assert_eq!(d1, d2);
612 }
613}