Skip to main content

treeship_core/session/
package.rs

1//! `.treeship` package builder and reader.
2//!
3//! A `.treeship` package is a directory (or tar archive) containing:
4//!
5//! - `receipt.json`   -- the canonical Session Receipt
6//! - `merkle.json`    -- standalone Merkle tree data
7//! - `render.json`    -- Explorer render hints
8//! - `artifacts/`     -- referenced artifact payloads
9//! - `proofs/`        -- inclusion proofs and zk proofs
10//! - `preview.html`   -- static preview (optional)
11
12use std::path::{Path, PathBuf};
13
14use sha2::{Digest, Sha256};
15
16use super::receipt::{SessionReceipt, RECEIPT_TYPE};
17
18/// Errors from package operations.
19#[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
44/// Manifest file inside the package root.
45const 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
52/// Result of building a package.
53pub struct PackageOutput {
54    /// Path to the package directory.
55    pub path: PathBuf,
56    /// SHA-256 digest of the canonical receipt.json.
57    pub receipt_digest: String,
58    /// Merkle root hex (if present).
59    pub merkle_root: Option<String>,
60    /// Number of files in the package.
61    pub file_count: usize,
62}
63
64/// Build a `.treeship` package directory from a composed receipt.
65///
66/// Writes all package files into `output_dir/<session_id>.treeship/`.
67/// Returns metadata about the written package.
68pub 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    // 1. receipt.json -- canonical serialization
82    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    // 2. merkle.json -- standalone copy of the Merkle section
90    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    // 3. render.json
95    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    // 4. Write inclusion proofs as individual files
100    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    // 5. preview.html stub
108    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
122/// Read and parse a `.treeship` package from disk.
123pub 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
142/// Verify a `.treeship` package locally.
143///
144/// Returns a list of check results. All must pass for the package to be valid.
145pub fn verify_package(pkg_dir: &Path) -> Result<Vec<VerifyCheck>, PackageError> {
146    let mut checks = Vec::new();
147
148    // 1. receipt.json exists and parses
149    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    // 2. Type field
161    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    // 3. Determinism: re-serialize and check digest matches
168    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        // Not a hard failure -- pretty-print whitespace may differ
175        checks.push(VerifyCheck::warn("determinism", "receipt.json does not byte-match after re-serialization"));
176    }
177
178    // 4. Merkle root re-computation
179    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)[..16]));
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        // 5. Verify each inclusion proof
204        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    // 6. Leaf count matches artifacts
227    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    // 7. Timeline ordering (determinism rule: timestamp, sequence_no, event_id)
237    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/// A single verification check result.
251#[derive(Debug, Clone)]
252pub struct VerifyCheck {
253    pub name: String,
254    pub status: VerifyStatus,
255    pub detail: String,
256}
257
258/// Status of a verification check.
259#[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
284/// Generate a minimal static preview HTML for the session.
285fn 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 &middot; {proof_count} inclusion proofs
396    </p>
397  </div>
398
399  <div class="footer">
400    Generated by Treeship Session Receipt v1 &middot; <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        // Read back
507        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}