1use std::path::{Path, PathBuf};
13
14use sha2::{Digest, Sha256};
15
16use super::receipt::{SessionReceipt, RECEIPT_TYPE};
17
18#[derive(Debug)]
20pub enum PackageError {
21 Io(std::io::Error),
22 Json(serde_json::Error),
23 InvalidPackage(String),
24}
25
26impl std::fmt::Display for PackageError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 Self::Io(e) => write!(f, "package io: {e}"),
30 Self::Json(e) => write!(f, "package json: {e}"),
31 Self::InvalidPackage(msg) => write!(f, "invalid package: {msg}"),
32 }
33 }
34}
35
36impl std::error::Error for PackageError {}
37impl From<std::io::Error> for PackageError {
38 fn from(e: std::io::Error) -> Self { Self::Io(e) }
39}
40impl From<serde_json::Error> for PackageError {
41 fn from(e: serde_json::Error) -> Self { Self::Json(e) }
42}
43
44const RECEIPT_FILE: &str = "receipt.json";
46const MERKLE_FILE: &str = "merkle.json";
47const RENDER_FILE: &str = "render.json";
48const ARTIFACTS_DIR: &str = "artifacts";
49const PROOFS_DIR: &str = "proofs";
50const PREVIEW_FILE: &str = "preview.html";
51
52pub struct PackageOutput {
54 pub path: PathBuf,
56 pub receipt_digest: String,
58 pub merkle_root: Option<String>,
60 pub file_count: usize,
62}
63
64pub fn build_package(
69 receipt: &SessionReceipt,
70 output_dir: &Path,
71) -> Result<PackageOutput, PackageError> {
72 let session_id = &receipt.session.id;
73 let pkg_dir = output_dir.join(format!("{session_id}.treeship"));
74
75 std::fs::create_dir_all(&pkg_dir)?;
76 std::fs::create_dir_all(pkg_dir.join(ARTIFACTS_DIR))?;
77 std::fs::create_dir_all(pkg_dir.join(PROOFS_DIR))?;
78
79 let mut file_count = 0usize;
80
81 let receipt_bytes = serde_json::to_vec_pretty(receipt)?;
83 std::fs::write(pkg_dir.join(RECEIPT_FILE), &receipt_bytes)?;
84 file_count += 1;
85
86 let receipt_hash = Sha256::digest(&receipt_bytes);
87 let receipt_digest = format!("sha256:{}", hex::encode(receipt_hash));
88
89 let merkle_bytes = serde_json::to_vec_pretty(&receipt.merkle)?;
91 std::fs::write(pkg_dir.join(MERKLE_FILE), &merkle_bytes)?;
92 file_count += 1;
93
94 let render_bytes = serde_json::to_vec_pretty(&receipt.render)?;
96 std::fs::write(pkg_dir.join(RENDER_FILE), &render_bytes)?;
97 file_count += 1;
98
99 for proof_entry in &receipt.merkle.inclusion_proofs {
101 let proof_bytes = serde_json::to_vec_pretty(proof_entry)?;
102 let filename = format!("{}.proof.json", proof_entry.artifact_id);
103 std::fs::write(pkg_dir.join(PROOFS_DIR).join(filename), &proof_bytes)?;
104 file_count += 1;
105 }
106
107 if receipt.render.generate_preview {
109 let preview = generate_preview_html(receipt);
110 std::fs::write(pkg_dir.join(PREVIEW_FILE), preview.as_bytes())?;
111 file_count += 1;
112 }
113
114 Ok(PackageOutput {
115 path: pkg_dir,
116 receipt_digest,
117 merkle_root: receipt.merkle.root.clone(),
118 file_count,
119 })
120}
121
122pub fn read_package(pkg_dir: &Path) -> Result<SessionReceipt, PackageError> {
124 let receipt_path = pkg_dir.join(RECEIPT_FILE);
125 if !receipt_path.exists() {
126 return Err(PackageError::InvalidPackage(
127 format!("missing {RECEIPT_FILE} in {}", pkg_dir.display()),
128 ));
129 }
130 let bytes = std::fs::read(&receipt_path)?;
131 let receipt: SessionReceipt = serde_json::from_slice(&bytes)?;
132
133 if receipt.type_ != RECEIPT_TYPE {
134 return Err(PackageError::InvalidPackage(
135 format!("unexpected type: {} (expected {RECEIPT_TYPE})", receipt.type_),
136 ));
137 }
138
139 Ok(receipt)
140}
141
142pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
146 let mut checks = Vec::new();
147
148 let receipt = match read_package(pkg_dir) {
150 Ok(r) => {
151 checks.push(VerifyCheck::pass("receipt.json", "Parses as valid Session Receipt"));
152 r
153 }
154 Err(e) => {
155 checks.push(VerifyCheck::fail("receipt.json", &format!("Failed to parse: {e}")));
156 return Ok(checks);
157 }
158 };
159
160 if receipt.type_ == RECEIPT_TYPE {
162 checks.push(VerifyCheck::pass("type", "Correct receipt type"));
163 } else {
164 checks.push(VerifyCheck::fail("type", &format!("Expected {RECEIPT_TYPE}, got {}", receipt.type_)));
165 }
166
167 let receipt_path = pkg_dir.join(RECEIPT_FILE);
169 let on_disk = std::fs::read(&receipt_path)?;
170 let re_serialized = serde_json::to_vec_pretty(&receipt)?;
171 if on_disk == re_serialized {
172 checks.push(VerifyCheck::pass("determinism", "receipt.json round-trips identically"));
173 } else {
174 checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
176 }
177
178 if !receipt.artifacts.is_empty() {
180 let mut tree = crate::merkle::MerkleTree::new();
181 for art in &receipt.artifacts {
182 tree.append(&art.artifact_id);
183 }
184 let root_bytes = tree.root();
185 let recomputed_root = root_bytes
186 .map(|r| format!("mroot_{}", hex::encode(r)));
187 let root_hex = root_bytes
188 .map(|r| hex::encode(r))
189 .unwrap_or_default();
190
191 if recomputed_root == receipt.merkle.root {
192 checks.push(VerifyCheck::pass("merkle_root", "Merkle root matches recomputed value"));
193 } else {
194 checks.push(VerifyCheck::fail(
195 "merkle_root",
196 &format!(
197 "Mismatch: on-disk {:?} vs recomputed {:?}",
198 receipt.merkle.root, recomputed_root
199 ),
200 ));
201 }
202
203 for proof_entry in &receipt.merkle.inclusion_proofs {
205 let verified = crate::merkle::MerkleTree::verify_proof(
206 &root_hex,
207 &proof_entry.artifact_id,
208 &proof_entry.proof,
209 );
210 if verified {
211 checks.push(VerifyCheck::pass(
212 &format!("inclusion:{}", proof_entry.artifact_id),
213 "Inclusion proof valid",
214 ));
215 } else {
216 checks.push(VerifyCheck::fail(
217 &format!("inclusion:{}", proof_entry.artifact_id),
218 "Inclusion proof failed verification",
219 ));
220 }
221 }
222 } else {
223 checks.push(VerifyCheck::warn("merkle_root", "No artifacts to verify"));
224 }
225
226 if receipt.merkle.leaf_count == receipt.artifacts.len() {
228 checks.push(VerifyCheck::pass("leaf_count", "Leaf count matches artifact count"));
229 } else {
230 checks.push(VerifyCheck::fail(
231 "leaf_count",
232 &format!("leaf_count {} != artifact count {}", receipt.merkle.leaf_count, receipt.artifacts.len()),
233 ));
234 }
235
236 let ordered = receipt.timeline.windows(2).all(|w| {
238 (&w[0].timestamp, w[0].sequence_no, &w[0].event_id)
239 <= (&w[1].timestamp, w[1].sequence_no, &w[1].event_id)
240 });
241 if ordered {
242 checks.push(VerifyCheck::pass("timeline_order", "Timeline is correctly ordered"));
243 } else {
244 checks.push(VerifyCheck::fail("timeline_order", "Timeline entries are not in deterministic order"));
245 }
246
247 Ok(checks)
248}
249
250#[derive(Debug, Clone)]
252pub struct VerifyCheck {
253 pub name: String,
254 pub status: VerifyStatus,
255 pub detail: String,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum VerifyStatus {
261 Pass,
262 Fail,
263 Warn,
264}
265
266impl VerifyCheck {
267 fn pass(name: &str, detail: &str) -> Self {
268 Self { name: name.into(), status: VerifyStatus::Pass, detail: detail.into() }
269 }
270 fn fail(name: &str, detail: &str) -> Self {
271 Self { name: name.into(), status: VerifyStatus::Fail, detail: detail.into() }
272 }
273 fn warn(name: &str, detail: &str) -> Self {
274 Self { name: name.into(), status: VerifyStatus::Warn, detail: detail.into() }
275 }
276}
277
278impl VerifyCheck {
279 pub fn passed(&self) -> bool {
280 self.status == VerifyStatus::Pass
281 }
282}
283
284fn generate_preview_html(receipt: &SessionReceipt) -> String {
286 let session = &receipt.session;
287 let p = &receipt.participants;
288 let se_files = receipt.side_effects.files_written.len();
289 let se_tools = receipt.side_effects.tool_invocations.len();
290 let merkle_root = receipt.merkle.root.as_deref().unwrap_or("none");
291 let duration = session.duration_ms
292 .map(|ms| format_duration(ms))
293 .unwrap_or_else(|| "unknown".into());
294
295 let agents_html: String = receipt.agent_graph.nodes.iter()
296 .map(|n| format!(
297 "<li><strong>{}</strong> ({}){}</li>",
298 n.agent_name,
299 n.host_id,
300 n.agent_role.as_ref().map(|r| format!(" -- {r}")).unwrap_or_default(),
301 ))
302 .collect::<Vec<_>>()
303 .join("\n ");
304
305 let timeline_html: String = receipt.timeline.iter()
306 .map(|t| format!(
307 "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
308 t.timestamp, t.event_type, t.agent_name,
309 t.summary.as_deref().unwrap_or(""),
310 ))
311 .collect::<Vec<_>>()
312 .join("\n ");
313
314 format!(r#"<!DOCTYPE html>
315<html lang="en">
316<head>
317 <meta charset="utf-8">
318 <meta name="viewport" content="width=device-width, initial-scale=1">
319 <title>Session Report: {session_id}</title>
320 <style>
321 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
322 body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
323 background: #0a0a0a; color: #e0e0e0; padding: 2rem; max-width: 960px; margin: 0 auto; }}
324 h1 {{ color: #fff; margin-bottom: 0.25rem; font-size: 1.5rem; }}
325 .subtitle {{ color: #888; margin-bottom: 2rem; font-size: 0.875rem; }}
326 .badge {{ display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px;
327 font-size: 0.75rem; font-weight: 600; }}
328 .badge-pass {{ background: #1a3a1a; color: #4ade80; }}
329 .badge-status {{ background: #1a2a3a; color: #60a5fa; }}
330 .card {{ background: #141414; border: 1px solid #252525; border-radius: 8px;
331 padding: 1.25rem; margin-bottom: 1rem; }}
332 .card h2 {{ font-size: 1rem; color: #aaa; margin-bottom: 0.75rem; text-transform: uppercase;
333 letter-spacing: 0.05em; font-weight: 500; }}
334 .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }}
335 .stat {{ text-align: center; }}
336 .stat .value {{ font-size: 1.5rem; font-weight: 700; color: #fff; }}
337 .stat .label {{ font-size: 0.75rem; color: #888; margin-top: 0.15rem; }}
338 ul {{ list-style: none; }}
339 ul li {{ padding: 0.3rem 0; border-bottom: 1px solid #1a1a1a; font-size: 0.875rem; }}
340 table {{ width: 100%; border-collapse: collapse; font-size: 0.8rem; }}
341 th, td {{ text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #1a1a1a; }}
342 th {{ color: #888; font-weight: 500; }}
343 .mono {{ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.8rem; color: #60a5fa; }}
344 .footer {{ margin-top: 2rem; text-align: center; color: #555; font-size: 0.75rem; }}
345 </style>
346</head>
347<body>
348 <h1>{name}</h1>
349 <p class="subtitle">
350 <span class="badge badge-status">{status:?}</span>
351 <span class="mono">{session_id}</span>
352 </p>
353
354 <div class="card">
355 <h2>Session Summary</h2>
356 <div class="grid">
357 <div class="stat"><div class="value">{total_agents}</div><div class="label">Agents</div></div>
358 <div class="stat"><div class="value">{spawned}</div><div class="label">Spawned</div></div>
359 <div class="stat"><div class="value">{handoffs}</div><div class="label">Handoffs</div></div>
360 <div class="stat"><div class="value">{max_depth}</div><div class="label">Max Depth</div></div>
361 <div class="stat"><div class="value">{hosts}</div><div class="label">Hosts</div></div>
362 <div class="stat"><div class="value">{duration}</div><div class="label">Duration</div></div>
363 </div>
364 </div>
365
366 <div class="card">
367 <h2>Participants</h2>
368 <ul>
369 {agents_html}
370 </ul>
371 </div>
372
373 <div class="card">
374 <h2>Timeline</h2>
375 <table>
376 <thead><tr><th>Time</th><th>Event</th><th>Agent</th><th>Detail</th></tr></thead>
377 <tbody>
378 {timeline_html}
379 </tbody>
380 </table>
381 </div>
382
383 <div class="card">
384 <h2>Side Effects</h2>
385 <div class="grid">
386 <div class="stat"><div class="value">{se_files}</div><div class="label">Files Written</div></div>
387 <div class="stat"><div class="value">{se_tools}</div><div class="label">Tool Calls</div></div>
388 </div>
389 </div>
390
391 <div class="card">
392 <h2>Verification</h2>
393 <p><span class="badge badge-pass">Merkle Root</span> <span class="mono">{merkle_root}</span></p>
394 <p style="margin-top: 0.5rem; font-size: 0.8rem; color: #888;">
395 {leaf_count} leaves · {proof_count} inclusion proofs
396 </p>
397 </div>
398
399 <div class="footer">
400 Generated by Treeship Session Receipt v1 · <a href="https://treeship.dev" style="color:#60a5fa;">treeship.dev</a>
401 </div>
402</body>
403</html>"#,
404 session_id = session.id,
405 name = session.name.as_deref().unwrap_or(&session.id),
406 status = session.status,
407 total_agents = p.total_agents,
408 spawned = p.spawned_subagents,
409 handoffs = p.handoffs,
410 max_depth = p.max_depth,
411 hosts = p.hosts,
412 duration = duration,
413 agents_html = agents_html,
414 timeline_html = timeline_html,
415 se_files = se_files,
416 se_tools = se_tools,
417 merkle_root = merkle_root,
418 leaf_count = receipt.merkle.leaf_count,
419 proof_count = receipt.merkle.inclusion_proofs.len(),
420 )
421}
422
423fn format_duration(ms: u64) -> String {
424 let secs = ms / 1000;
425 if secs < 60 {
426 format!("{secs}s")
427 } else if secs < 3600 {
428 format!("{}m {}s", secs / 60, secs % 60)
429 } else {
430 let h = secs / 3600;
431 let m = (secs % 3600) / 60;
432 format!("{h}h {m}m")
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::session::event::*;
440 use crate::session::manifest::SessionManifest;
441 use crate::session::receipt::{ArtifactEntry, ReceiptComposer};
442
443 fn make_receipt() -> SessionReceipt {
444 let manifest = SessionManifest::new(
445 "ssn_pkg_test".into(),
446 "agent://test".into(),
447 "2026-04-05T08:00:00Z".into(),
448 1743843600000,
449 );
450
451 let mk = |seq: u64, inst: &str, et: EventType| -> SessionEvent {
452 SessionEvent {
453 session_id: "ssn_pkg_test".into(),
454 event_id: format!("evt_{:016x}", seq),
455 timestamp: format!("2026-04-05T08:{:02}:00Z", seq),
456 sequence_no: seq,
457 trace_id: "trace_1".into(),
458 span_id: format!("span_{seq}"),
459 parent_span_id: None,
460 agent_id: format!("agent://{inst}"),
461 agent_instance_id: inst.into(),
462 agent_name: inst.into(),
463 agent_role: None,
464 host_id: "host_1".into(),
465 tool_runtime_id: None,
466 event_type: et,
467 artifact_ref: None,
468 meta: None,
469 }
470 };
471
472 let events = vec![
473 mk(0, "root", EventType::SessionStarted),
474 mk(1, "root", EventType::AgentStarted { parent_agent_instance_id: None }),
475 mk(2, "root", EventType::AgentCalledTool {
476 tool_name: "read_file".into(),
477 tool_input_digest: None,
478 tool_output_digest: None,
479 duration_ms: Some(10),
480 }),
481 mk(3, "root", EventType::AgentCompleted { termination_reason: None }),
482 mk(4, "root", EventType::SessionClosed { summary: Some("Done".into()), duration_ms: Some(60000) }),
483 ];
484
485 let artifacts = vec![
486 ArtifactEntry { artifact_id: "art_001".into(), payload_type: "action".into(), digest: None, signed_at: None },
487 ];
488
489 ReceiptComposer::compose(&manifest, &events, artifacts)
490 }
491
492 #[test]
493 fn build_and_read_package() {
494 let receipt = make_receipt();
495 let tmp = std::env::temp_dir().join(format!("treeship-pkg-test-{}", rand::random::<u32>()));
496
497 let output = build_package(&receipt, &tmp).unwrap();
498 assert!(output.path.exists());
499 assert!(output.path.join("receipt.json").exists());
500 assert!(output.path.join("merkle.json").exists());
501 assert!(output.path.join("render.json").exists());
502 assert!(output.path.join("preview.html").exists());
503 assert!(output.receipt_digest.starts_with("sha256:"));
504 assert!(output.file_count >= 4);
505
506 let read_back = read_package(&output.path).unwrap();
508 assert_eq!(read_back.session.id, "ssn_pkg_test");
509 assert_eq!(read_back.type_, RECEIPT_TYPE);
510
511 let _ = std::fs::remove_dir_all(&tmp);
512 }
513
514 #[test]
515 fn verify_valid_package() {
516 let receipt = make_receipt();
517 let tmp = std::env::temp_dir().join(format!("treeship-pkg-verify-{}", rand::random::<u32>()));
518
519 let output = build_package(&receipt, &tmp).unwrap();
520 let checks = verify_package(&output.path).unwrap();
521
522 let fails: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Fail).collect();
523 assert!(fails.is_empty(), "unexpected failures: {fails:?}");
524
525 let passes: Vec<_> = checks.iter().filter(|c| c.status == VerifyStatus::Pass).collect();
526 assert!(passes.len() >= 5, "expected at least 5 pass checks, got {}", passes.len());
527
528 let _ = std::fs::remove_dir_all(&tmp);
529 }
530
531 #[test]
532 fn verify_detects_missing_receipt() {
533 let tmp = std::env::temp_dir().join(format!("treeship-pkg-empty-{}", rand::random::<u32>()));
534 std::fs::create_dir_all(&tmp).unwrap();
535
536 let err = read_package(&tmp);
537 assert!(err.is_err());
538
539 let _ = std::fs::remove_dir_all(&tmp);
540 }
541
542 #[test]
543 fn preview_html_contains_session_info() {
544 let receipt = make_receipt();
545 let html = generate_preview_html(&receipt);
546 assert!(html.contains("ssn_pkg_test"));
547 assert!(html.contains("treeship.dev"));
548 assert!(html.contains("Timeline"));
549 }
550}