Skip to main content

agent_trace/trace/
session_checkpoint.rs

1use crate::llm::Llm;
2use crate::running_summary::{load_all_events, SummaryEvent};
3use crate::session::AgentSession;
4use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7const CHECKPOINTS_DIR: &str = ".agent-trace/session_checkpoints";
8
9pub fn checkpoint_dir(store_root: &Path) -> PathBuf {
10    store_root.join(CHECKPOINTS_DIR)
11}
12
13pub fn checkpoint_path(store_root: &Path, session_id: &str) -> PathBuf {
14    checkpoint_dir(store_root).join(format!("{session_id}.md"))
15}
16
17pub fn load_events_for_session(store_root: &Path, session_id: &str) -> Result<Vec<SummaryEvent>> {
18    Ok(load_all_events(store_root)?
19        .into_iter()
20        .filter(|e| e.session_id.as_deref() == Some(session_id))
21        .collect())
22}
23
24pub fn synthesize_template_session_checkpoint(
25    session: &AgentSession,
26    events: &[SummaryEvent],
27) -> String {
28    let mut out = String::from("# Current Session Checkpoint\n\n");
29    out.push_str(&format!(
30        "*Agent: {} / {} ({})*\n",
31        session.name, session.session_id, session.transport
32    ));
33    out.push_str(&format!("*Started: {}*\n\n", session.started_at));
34
35    out.push_str("## Activity Summary\n\n");
36    if events.is_empty() {
37        out.push_str("*(no recorded events for this session)*\n");
38    } else {
39        let mut paths = std::collections::HashSet::new();
40        for e in events {
41            paths.insert(e.path.as_str());
42        }
43        out.push_str(&format!(
44            "{} event(s) across {} file(s).\n\n",
45            events.len(),
46            paths.len()
47        ));
48        for e in events {
49            let time = e
50                .timestamp
51                .split('T')
52                .nth(1)
53                .and_then(|t| t.split('Z').next())
54                .unwrap_or(&e.timestamp);
55            out.push_str(&format!(
56                "- [{time}] {} {} — {}\n",
57                e.action, e.path, e.summary
58            ));
59        }
60    }
61
62    out
63}
64
65pub fn generate_session_checkpoint(store_root: &Path, session: &AgentSession) -> Result<String> {
66    let events = load_events_for_session(store_root, &session.session_id)?;
67    let event_strings: Vec<String> = events
68        .iter()
69        .map(|e| format!("[{}] {} {} — {}", e.timestamp, e.action, e.path, e.summary))
70        .collect();
71
72    if let Ok(api) = Llm::from_store_root(store_root) {
73        if !api.is_degraded() {
74            match api.summarize_session(&session.session_id, &event_strings) {
75                Ok(summary) => {
76                    let mut out = String::from("# Current Session Checkpoint\n\n");
77                    out.push_str(&format!(
78                        "*Agent: {} / {} ({})*\n\n",
79                        session.name, session.session_id, session.transport
80                    ));
81                    out.push_str(&summary);
82                    out.push_str("\n\n## Events\n\n");
83                    if events.is_empty() {
84                        out.push_str("*(no recorded events)*\n");
85                    } else {
86                        for e in &events {
87                            out.push_str(&format!("- {} {} — {}\n", e.action, e.path, e.summary));
88                        }
89                    }
90                    return Ok(out);
91                }
92                Err(e) => {
93                    tracing::warn!("session checkpoint synthesis failed: {e}");
94                }
95            }
96        }
97    }
98
99    Ok(synthesize_template_session_checkpoint(session, &events))
100}
101
102pub fn persist_checkpoint(store_root: &Path, session_id: &str, content: &str) -> Result<()> {
103    let path = checkpoint_path(store_root, session_id);
104    if let Some(parent) = path.parent() {
105        std::fs::create_dir_all(parent)?;
106    }
107    crate::util::atomic_write(&path, content)?;
108    Ok(())
109}
110
111pub fn load_checkpoint(store_root: &Path, session_id: &str) -> Option<String> {
112    let path = checkpoint_path(store_root, session_id);
113    std::fs::read_to_string(path).ok()
114}
115
116pub fn maybe_write_session_checkpoint(store_root: &Path, session_id: &str) -> Result<()> {
117    let session = crate::session::load_session(store_root)
118        .filter(|s| !s.is_stale() && s.session_id == session_id)
119        .ok_or_else(|| anyhow::anyhow!("no active session matching {session_id}"))?;
120    let content = generate_session_checkpoint(store_root, &session)?;
121    persist_checkpoint(store_root, session_id, &content)?;
122    tracing::info!(
123        session_id = %session_id,
124        agent = %session.name,
125        "persisted session checkpoint"
126    );
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::running_summary::append_event;
134    use crate::session;
135    use tempfile::TempDir;
136
137    fn sample_event(session_id: &str, path: &str, summary: &str) -> SummaryEvent {
138        SummaryEvent {
139            timestamp: "2026-06-05T20:58:09Z".into(),
140            session_id: Some(session_id.into()),
141            agent_name: Some("bot".into()),
142            actor: "agent:bot".into(),
143            action: "modify".into(),
144            path: path.into(),
145            doc_type: "plan".into(),
146            summary: summary.into(),
147            source: "mcp_write".into(),
148            detected_by: "mcp".into(),
149            lines_added: 1,
150            lines_removed: 0,
151            change_kind: "modify".into(),
152        }
153    }
154
155    #[test]
156    fn template_checkpoint_lists_session_events() {
157        let tmp = TempDir::new().unwrap();
158        let root = tmp.path();
159        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
160        let session = AgentSession {
161            name: "bot".into(),
162            session_id: "20260605-120000".into(),
163            transport: "mcp".into(),
164            started_at: "2026-06-05T12:00:00Z".into(),
165            last_heartbeat: "2026-06-05T12:00:00Z".into(),
166        };
167        append_event(
168            root,
169            sample_event("20260605-120000", "plan.md", "phase 1 done"),
170        )
171        .unwrap();
172        append_event(
173            root,
174            sample_event("20260605-120000", "notes.md", "follow-up"),
175        )
176        .unwrap();
177        append_event(root, sample_event("other-session", "plan.md", "ignored")).unwrap();
178
179        let events = load_events_for_session(root, "20260605-120000").unwrap();
180        let checkpoint = synthesize_template_session_checkpoint(&session, &events);
181        assert!(checkpoint.contains("# Current Session Checkpoint"));
182        assert!(checkpoint.contains("phase 1 done"));
183        assert!(checkpoint.contains("follow-up"));
184        assert!(!checkpoint.contains("ignored"));
185    }
186
187    #[test]
188    fn checkpoint_persist_and_load_roundtrip() {
189        let tmp = TempDir::new().unwrap();
190        let root = tmp.path();
191        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
192        session::start_session(root, "bot", "cli").unwrap();
193        let sess = session::load_session(root).unwrap();
194        append_event(root, sample_event(&sess.session_id, "plan.md", "work")).unwrap();
195
196        let content = generate_session_checkpoint(root, &sess).unwrap();
197        persist_checkpoint(root, &sess.session_id, &content).unwrap();
198        assert!(checkpoint_path(root, &sess.session_id).exists());
199
200        let loaded = load_checkpoint(root, &sess.session_id).unwrap();
201        assert_eq!(loaded, content);
202    }
203}