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