agent_trace/trace/
session_checkpoint.rs1use 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}