Skip to main content

batuta/agent/
session.rs

1//! Session persistence for `apr code`.
2//!
3//! Serializes conversation history to `~/.apr/sessions/{id}/messages.jsonl`
4//! for crash recovery and `/resume`. Each message is one JSON line.
5//!
6//! See: apr-code.md §6
7
8use std::fs;
9use std::io::{BufRead, Write};
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use super::driver::Message;
15
16/// Session manifest stored alongside messages.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SessionManifest {
19    /// Unique session ID.
20    pub id: String,
21    /// Agent name (e.g., "apr-code").
22    pub agent: String,
23    /// Working directory at session start.
24    pub cwd: String,
25    /// Timestamp of session creation (ISO 8601).
26    pub created: String,
27    /// Number of turns completed.
28    pub turns: u32,
29}
30
31/// Persistent session storage backed by JSONL files.
32pub struct SessionStore {
33    /// Root directory for this session.
34    pub dir: PathBuf,
35    /// Session manifest.
36    pub manifest: SessionManifest,
37}
38
39impl SessionStore {
40    /// Create a new session in `~/.apr/sessions/{id}/`.
41    pub fn create(agent_name: &str) -> anyhow::Result<Self> {
42        let id = generate_session_id();
43        let sessions_dir = sessions_root()?;
44        let dir = sessions_dir.join(&id);
45        fs::create_dir_all(&dir)?;
46
47        let cwd =
48            std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| ".".into());
49
50        let manifest = SessionManifest {
51            id,
52            agent: agent_name.to_string(),
53            cwd,
54            created: chrono_now(),
55            turns: 0,
56        };
57
58        // Write manifest
59        let manifest_path = dir.join("manifest.json");
60        let json = serde_json::to_string_pretty(&manifest)?;
61        fs::write(&manifest_path, json)?;
62
63        Ok(Self { dir, manifest })
64    }
65
66    /// Resume an existing session by ID.
67    pub fn resume(session_id: &str) -> anyhow::Result<Self> {
68        let dir = sessions_root()?.join(session_id);
69        if !dir.is_dir() {
70            anyhow::bail!("session not found: {session_id}");
71        }
72
73        let manifest_path = dir.join("manifest.json");
74        let json = fs::read_to_string(&manifest_path)?;
75        let manifest: SessionManifest = serde_json::from_str(&json)?;
76
77        Ok(Self { dir, manifest })
78    }
79
80    /// Find the most recent session for the current working directory.
81    ///
82    /// Only returns sessions modified within `max_age` (default 24h).
83    /// PMAT-165: age filter prevents offering stale sessions.
84    pub fn find_recent_for_cwd() -> Option<SessionManifest> {
85        Self::find_recent_for_cwd_within(std::time::Duration::from_secs(24 * 3600))
86    }
87
88    /// Find the most recent session for cwd within the given age limit.
89    pub fn find_recent_for_cwd_within(max_age: std::time::Duration) -> Option<SessionManifest> {
90        let sessions_dir = sessions_root().ok()?;
91        if !sessions_dir.is_dir() {
92            return None;
93        }
94
95        let cwd = std::env::current_dir().ok()?.display().to_string();
96        let now = std::time::SystemTime::now();
97
98        let mut best: Option<(SessionManifest, std::time::SystemTime)> = None;
99        for entry in fs::read_dir(&sessions_dir).ok()?.flatten() {
100            let manifest_path = entry.path().join("manifest.json");
101            if !manifest_path.is_file() {
102                continue;
103            }
104            if let Ok(json) = fs::read_to_string(&manifest_path) {
105                if let Ok(m) = serde_json::from_str::<SessionManifest>(&json) {
106                    if m.cwd == cwd && m.turns > 0 {
107                        let mtime = entry.metadata().ok()?.modified().ok()?;
108                        // PMAT-165: skip sessions older than max_age
109                        if now.duration_since(mtime).unwrap_or(max_age) >= max_age {
110                            continue;
111                        }
112                        if best.as_ref().is_none_or(|(_, t)| mtime > *t) {
113                            best = Some((m, mtime));
114                        }
115                    }
116                }
117            }
118        }
119
120        best.map(|(m, _)| m)
121    }
122
123    /// Session ID.
124    pub fn id(&self) -> &str {
125        &self.manifest.id
126    }
127
128    /// Append a message to the JSONL log.
129    pub fn append_message(&self, msg: &Message) -> anyhow::Result<()> {
130        let path = self.dir.join("messages.jsonl");
131        let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
132        let json = serde_json::to_string(msg)?;
133        writeln!(file, "{json}")?;
134        Ok(())
135    }
136
137    /// Append multiple messages (e.g., after a turn completes).
138    pub fn append_messages(&self, msgs: &[Message]) -> anyhow::Result<()> {
139        let path = self.dir.join("messages.jsonl");
140        let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
141        for msg in msgs {
142            let json = serde_json::to_string(msg)?;
143            writeln!(file, "{json}")?;
144        }
145        Ok(())
146    }
147
148    /// Load all messages from the JSONL log.
149    pub fn load_messages(&self) -> anyhow::Result<Vec<Message>> {
150        let path = self.dir.join("messages.jsonl");
151        if !path.is_file() {
152            return Ok(Vec::new());
153        }
154
155        let file = fs::File::open(&path)?;
156        let reader = std::io::BufReader::new(file);
157        let mut messages = Vec::new();
158
159        for line in reader.lines() {
160            let line = line?;
161            if line.trim().is_empty() {
162                continue;
163            }
164            let msg: Message = serde_json::from_str(&line)?;
165            messages.push(msg);
166        }
167
168        Ok(messages)
169    }
170
171    /// Update the turn count in the manifest.
172    pub fn record_turn(&mut self) -> anyhow::Result<()> {
173        self.manifest.turns += 1;
174        let manifest_path = self.dir.join("manifest.json");
175        let json = serde_json::to_string_pretty(&self.manifest)?;
176        fs::write(&manifest_path, json)?;
177        Ok(())
178    }
179}
180
181/// Root directory for all sessions.
182fn sessions_root() -> anyhow::Result<PathBuf> {
183    let home =
184        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
185    Ok(home.join(".apr").join("sessions"))
186}
187
188/// Generate a short session ID from timestamp + nanos for uniqueness.
189fn generate_session_id() -> String {
190    use std::time::{SystemTime, UNIX_EPOCH};
191    let dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
192    let ts = dur.as_secs();
193    let nanos = dur.subsec_nanos();
194    format!("{ts:x}-{nanos:08x}")
195}
196
197/// PMAT-165/174: Interactive auto-resume prompt for recent sessions.
198///
199/// Checks if stdin is a TTY (skips when piped). Finds sessions <24h old
200/// for cwd. Shows [Y/n] prompt with age display.
201pub fn offer_auto_resume() -> Option<String> {
202    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
203        return None;
204    }
205    let manifest = SessionStore::find_recent_for_cwd()?;
206    let age = manifest
207        .created
208        .parse::<chrono::DateTime<chrono::Utc>>()
209        .ok()
210        .map(|created| {
211            let elapsed = chrono::Utc::now().signed_duration_since(created);
212            if elapsed.num_hours() > 0 {
213                format!("{}h ago", elapsed.num_hours())
214            } else {
215                format!("{}m ago", elapsed.num_minutes().max(1))
216            }
217        })
218        .unwrap_or_else(|| "recently".to_string());
219    eprintln!("  Found previous session ({age}, {} turns)", manifest.turns);
220    eprint!("  Resume? [Y/n] ");
221    let mut input = String::new();
222    if std::io::stdin().read_line(&mut input).is_err() {
223        return None;
224    }
225    let input = input.trim().to_lowercase();
226    if input.is_empty() || input == "y" || input == "yes" {
227        Some(manifest.id)
228    } else {
229        None
230    }
231}
232
233/// Current UTC time as ISO 8601 string (no chrono dependency).
234fn chrono_now() -> String {
235    // Simple UTC timestamp without external dependency
236    use std::time::{SystemTime, UNIX_EPOCH};
237    let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
238    // Approximate: days since epoch, then h/m/s
239    let days = secs / 86400;
240    let rem = secs % 86400;
241    let h = rem / 3600;
242    let m = (rem % 3600) / 60;
243    let s = rem % 60;
244    // Year calculation (good enough for display)
245    let years = 1970 + days / 365;
246    let day_of_year = days % 365;
247    let month = day_of_year / 30 + 1;
248    let day = day_of_year % 30 + 1;
249    format!("{years:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// Create a session store in a temp dir (isolated from ~/.apr/sessions/).
257    fn create_test_store() -> SessionStore {
258        let tmp = tempfile::tempdir().expect("tmpdir");
259        let id = generate_session_id();
260        let tmp_path = tmp.path().to_path_buf();
261        // Prevent cleanup so test can use the dir
262        std::mem::forget(tmp);
263        let dir = tmp_path.join(&id);
264        fs::create_dir_all(&dir).expect("mkdir");
265
266        let manifest = SessionManifest {
267            id,
268            agent: "test-agent".into(),
269            cwd: ".".into(),
270            created: chrono_now(),
271            turns: 0,
272        };
273        let json = serde_json::to_string_pretty(&manifest).expect("json");
274        fs::write(dir.join("manifest.json"), json).expect("write");
275        SessionStore { dir, manifest }
276    }
277
278    #[test]
279    fn test_session_create_and_persist() {
280        let store = create_test_store();
281        assert!(!store.id().is_empty());
282        assert!(store.dir.is_dir());
283
284        store.append_message(&Message::User("hello".into())).expect("append");
285        store.append_message(&Message::Assistant("hi".into())).expect("append");
286
287        let msgs = store.load_messages().expect("load");
288        assert_eq!(msgs.len(), 2);
289        assert!(matches!(&msgs[0], Message::User(s) if s == "hello"));
290        assert!(matches!(&msgs[1], Message::Assistant(s) if s == "hi"));
291
292        let _ = fs::remove_dir_all(&store.dir);
293    }
294
295    #[test]
296    fn test_session_resume_by_path() {
297        let store = create_test_store();
298        store.append_message(&Message::User("test".into())).expect("append");
299
300        // Resume by reading from same dir
301        let manifest_json = fs::read_to_string(store.dir.join("manifest.json")).expect("read");
302        let manifest: SessionManifest = serde_json::from_str(&manifest_json).expect("parse");
303        let resumed = SessionStore { dir: store.dir.clone(), manifest };
304        let msgs = resumed.load_messages().expect("load");
305        assert_eq!(msgs.len(), 1);
306
307        let _ = fs::remove_dir_all(&store.dir);
308    }
309
310    #[test]
311    fn test_session_resume_nonexistent() {
312        let result = SessionStore::resume("nonexistent-id-12345");
313        assert!(result.is_err());
314    }
315
316    #[test]
317    fn test_generate_session_id_unique() {
318        let id1 = generate_session_id();
319        // Small sleep to ensure different nanos
320        std::thread::sleep(std::time::Duration::from_millis(1));
321        let id2 = generate_session_id();
322        assert_ne!(id1, id2, "IDs should be unique");
323        assert!(id1.contains('-'));
324    }
325
326    #[test]
327    fn test_append_and_load_empty() {
328        let store = create_test_store();
329        let msgs = store.load_messages().expect("load");
330        assert!(msgs.is_empty());
331        let _ = fs::remove_dir_all(&store.dir);
332    }
333
334    #[test]
335    fn test_record_turn() {
336        let mut store = create_test_store();
337        assert_eq!(store.manifest.turns, 0);
338        store.record_turn().expect("record");
339        assert_eq!(store.manifest.turns, 1);
340
341        // Reload manifest from disk
342        let json = fs::read_to_string(store.dir.join("manifest.json")).expect("read");
343        let reloaded: SessionManifest = serde_json::from_str(&json).expect("parse");
344        assert_eq!(reloaded.turns, 1);
345
346        let _ = fs::remove_dir_all(&store.dir);
347    }
348
349    // PMAT-165: age filter tests
350    #[test]
351    fn test_find_recent_for_cwd_within_zero_age() {
352        // With zero max_age, nothing should match
353        let result = SessionStore::find_recent_for_cwd_within(std::time::Duration::ZERO);
354        assert!(result.is_none(), "zero age should return nothing");
355    }
356
357    #[test]
358    fn test_find_recent_for_cwd_delegates_to_within() {
359        // find_recent_for_cwd uses 24h, should not panic
360        let _ = SessionStore::find_recent_for_cwd();
361    }
362
363    // ═══ apr-chat-session-v1 contract (PMAT-189) ═══
364
365    #[test]
366    fn falsify_session_001_jsonl_roundtrip() {
367        let store = create_test_store();
368        let original = vec![
369            Message::User("question".into()),
370            Message::Assistant("answer".into()),
371            Message::User("follow-up".into()),
372            Message::Assistant("response".into()),
373        ];
374        store.append_messages(&original).expect("append");
375        let loaded = store.load_messages().expect("load");
376        assert_eq!(loaded.len(), original.len(), "FALSIFY-SESSION-001: message count preserved");
377        for (i, (orig, load)) in original.iter().zip(loaded.iter()).enumerate() {
378            assert_eq!(
379                format!("{orig:?}"),
380                format!("{load:?}"),
381                "FALSIFY-SESSION-001: message {i} roundtrip mismatch"
382            );
383        }
384        let _ = fs::remove_dir_all(&store.dir);
385    }
386
387    #[test]
388    fn falsify_session_002_resume_preserves_messages() {
389        let store = create_test_store();
390        store.append_message(&Message::User("turn1".into())).expect("append");
391        store.append_message(&Message::Assistant("reply1".into())).expect("append");
392        store.append_message(&Message::User("turn2".into())).expect("append");
393
394        // Simulate resume: re-read manifest + messages
395        let manifest_json = fs::read_to_string(store.dir.join("manifest.json")).expect("read");
396        let manifest: SessionManifest = serde_json::from_str(&manifest_json).expect("parse");
397        let resumed = SessionStore { dir: store.dir.clone(), manifest };
398        let msgs = resumed.load_messages().expect("load");
399        assert_eq!(msgs.len(), 3, "FALSIFY-SESSION-002: all messages survive resume");
400        assert!(matches!(&msgs[2], Message::User(s) if s == "turn2"));
401        let _ = fs::remove_dir_all(&store.dir);
402    }
403
404    #[test]
405    fn falsify_session_003_manifest_serde_roundtrip() {
406        let manifest = SessionManifest {
407            id: "test-123".into(),
408            agent: "apr-code".into(),
409            cwd: "/home/user/project".into(),
410            created: "2026-04-04T12:00:00Z".into(),
411            turns: 5,
412        };
413        let json = serde_json::to_string(&manifest).expect("serialize");
414        let loaded: SessionManifest = serde_json::from_str(&json).expect("deserialize");
415        assert_eq!(loaded.id, manifest.id, "FALSIFY-SESSION-003: id preserved");
416        assert_eq!(loaded.turns, manifest.turns, "FALSIFY-SESSION-003: turns preserved");
417        assert_eq!(loaded.cwd, manifest.cwd, "FALSIFY-SESSION-003: cwd preserved");
418    }
419
420    #[test]
421    fn falsify_session_004_age_filter_24h() {
422        // FALSIFY-SESSION-004: Sessions older than max_age must NOT be returned.
423        // Zero duration = nothing qualifies, so find_recent should return None
424        // regardless of how many sessions exist.
425        let result = SessionStore::find_recent_for_cwd_within(std::time::Duration::ZERO);
426        assert!(
427            result.is_none(),
428            "FALSIFY-SESSION-004: zero max_age must return None (no session is 0s old)"
429        );
430
431        // Very large duration = if any session exists, it qualifies
432        // (can't assert Some because the dir may be empty in CI)
433        let _ = SessionStore::find_recent_for_cwd_within(std::time::Duration::from_secs(
434            365 * 24 * 3600,
435        ));
436    }
437
438    #[test]
439    fn falsify_session_005_unicode_roundtrip() {
440        // FALSIFY-SESSION-005: Messages with unicode, newlines, special chars
441        // must survive JSONL roundtrip without corruption.
442        let store = create_test_store();
443        let special_messages = vec![
444            Message::User("Hello \u{1F600} emoji".into()),
445            Message::Assistant("Line1\nLine2\nLine3".into()),
446            Message::User("Tabs\there\tand\tthere".into()),
447            Message::Assistant("Quotes: \"double\" and 'single'".into()),
448            Message::User("\u{00e9}\u{00e8}\u{00ea} accented".into()),
449        ];
450        store.append_messages(&special_messages).expect("append unicode");
451        let loaded = store.load_messages().expect("load unicode");
452        assert_eq!(
453            loaded.len(),
454            special_messages.len(),
455            "FALSIFY-SESSION-005: all unicode messages preserved"
456        );
457        for (i, (orig, load)) in special_messages.iter().zip(loaded.iter()).enumerate() {
458            assert_eq!(
459                format!("{orig:?}"),
460                format!("{load:?}"),
461                "FALSIFY-SESSION-005: unicode message {i} corrupted"
462            );
463        }
464        let _ = fs::remove_dir_all(&store.dir);
465    }
466
467    #[test]
468    fn falsify_session_006_append_only_monotonic() {
469        // FALSIFY-SESSION-006: Multiple appends grow the log monotonically.
470        let store = create_test_store();
471        store.append_message(&Message::User("first".into())).expect("1");
472        let after_one = store.load_messages().expect("load1");
473        assert_eq!(after_one.len(), 1);
474
475        store.append_message(&Message::Assistant("second".into())).expect("2");
476        let after_two = store.load_messages().expect("load2");
477        assert_eq!(after_two.len(), 2);
478
479        store.append_message(&Message::User("third".into())).expect("3");
480        let after_three = store.load_messages().expect("load3");
481        assert_eq!(after_three.len(), 3);
482
483        // Verify monotonicity: earlier messages unchanged
484        assert_eq!(
485            format!("{:?}", after_two[0]),
486            format!("{:?}", after_three[0]),
487            "FALSIFY-SESSION-006: earlier messages must not change"
488        );
489        assert_eq!(
490            format!("{:?}", after_two[1]),
491            format!("{:?}", after_three[1]),
492            "FALSIFY-SESSION-006: earlier messages must not change"
493        );
494        let _ = fs::remove_dir_all(&store.dir);
495    }
496}