Skip to main content

agent_core/core/
session_index.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6#[serde(rename_all = "snake_case")]
7pub enum SessionIndexEventKind {
8    Start,
9    End,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct SessionIndexRecord {
14    pub schema_version: u8,
15    pub session_id: String,
16    pub event: SessionIndexEventKind,
17    pub timestamp: DateTime<Utc>,
18
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub cwd: Option<PathBuf>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub profile: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub model: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub duration_ms: Option<u64>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub turns: Option<usize>,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub tags: Vec<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub note: Option<String>,
33}
34
35impl SessionIndexRecord {
36    pub fn start(session_id: impl Into<String>) -> Self {
37        Self::new(session_id, SessionIndexEventKind::Start)
38    }
39
40    pub fn end(session_id: impl Into<String>) -> Self {
41        Self::new(session_id, SessionIndexEventKind::End)
42    }
43
44    fn new(session_id: impl Into<String>, event: SessionIndexEventKind) -> Self {
45        Self {
46            schema_version: 1,
47            session_id: session_id.into(),
48            event,
49            timestamp: Utc::now(),
50            cwd: None,
51            profile: None,
52            model: None,
53            duration_ms: None,
54            turns: None,
55            tags: Vec::new(),
56            note: None,
57        }
58    }
59}
60
61pub fn index_path() -> PathBuf {
62    crate::core::config::base_dir()
63        .join("sessions")
64        .join("index.jsonl")
65}
66
67pub fn append_record(record: &SessionIndexRecord) -> crate::Result<()> {
68    append_record_to_path(&index_path(), record)
69}
70
71pub fn read_recent(limit: usize) -> crate::Result<Vec<SessionIndexRecord>> {
72    read_recent_from_path(&index_path(), limit)
73}
74
75fn append_record_to_path(path: &std::path::Path, record: &SessionIndexRecord) -> crate::Result<()> {
76    if let Some(parent) = path.parent() {
77        crate::core::private_fs::ensure_private_dir(parent).map_err(|err| {
78            crate::core::error::RuntimeError::Session(format!(
79                "create session index directory: {err}"
80            ))
81        })?;
82    }
83
84    let mut file = crate::core::private_fs::open_private_append(path).map_err(|err| {
85        crate::core::error::RuntimeError::Session(format!("open session index: {err}"))
86    })?;
87    let mut line = serde_json::to_string(record).map_err(|err| {
88        crate::core::error::RuntimeError::Session(format!("serialize session index record: {err}"))
89    })?;
90    line.push('\n');
91    use std::io::Write;
92    file.write_all(line.as_bytes()).map_err(|err| {
93        crate::core::error::RuntimeError::Session(format!("write session index record: {err}"))
94    })?;
95    Ok(())
96}
97
98fn read_recent_from_path(
99    path: &std::path::Path,
100    limit: usize,
101) -> crate::Result<Vec<SessionIndexRecord>> {
102    if limit == 0 || !path.exists() {
103        return Ok(Vec::new());
104    }
105
106    let contents = std::fs::read_to_string(path).map_err(|err| {
107        crate::core::error::RuntimeError::Session(format!("read session index: {err}"))
108    })?;
109    let mut records = Vec::new();
110    for line in contents.lines().rev().take(limit) {
111        if line.trim().is_empty() {
112            continue;
113        }
114        match serde_json::from_str::<SessionIndexRecord>(line) {
115            Ok(record) => records.push(record),
116            Err(err) => {
117                tracing::warn!("skipping malformed session index line: {err}");
118                continue;
119            }
120        }
121    }
122    records.reverse();
123    Ok(records)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use serde_json::Value;
130    use serial_test::serial;
131    use std::sync::Mutex;
132
133    static ENV_LOCK: Mutex<()> = Mutex::new(());
134
135    struct EnvGuard {
136        old_base_dir: Option<String>,
137    }
138
139    impl EnvGuard {
140        fn set_base_dir(path: &std::path::Path) -> Self {
141            let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
142            std::env::set_var("SYNAPS_BASE_DIR", path);
143            Self { old_base_dir }
144        }
145    }
146
147    impl Drop for EnvGuard {
148        fn drop(&mut self) {
149            if let Some(value) = self.old_base_dir.take() {
150                std::env::set_var("SYNAPS_BASE_DIR", value);
151            } else {
152                std::env::remove_var("SYNAPS_BASE_DIR");
153            }
154        }
155    }
156
157    fn temp_base_dir(test_name: &str) -> PathBuf {
158        std::env::temp_dir().join(format!(
159            "synaps-session-index-{test_name}-{}",
160            uuid::Uuid::new_v4()
161        ))
162    }
163
164    #[test]
165    #[serial]
166    fn append_record_creates_jsonl_under_base_dir() {
167        let _lock = ENV_LOCK.lock().unwrap();
168        let base = temp_base_dir("creates-jsonl");
169        let _guard = EnvGuard::set_base_dir(&base);
170
171        let record = SessionIndexRecord::start("sess-1");
172        append_record(&record).unwrap();
173
174        let path = base.join("sessions").join("index.jsonl");
175        assert!(path.exists());
176        let contents = std::fs::read_to_string(path).unwrap();
177        let line: Value = serde_json::from_str(contents.trim()).unwrap();
178        assert_eq!(line["schema_version"], 1);
179        assert_eq!(line["session_id"], "sess-1");
180        assert_eq!(line["event"], "start");
181        assert!(line.get("timestamp").is_some());
182        assert!(line.get("cwd").is_none());
183    }
184
185    #[test]
186    #[serial]
187    fn append_start_and_end_are_valid_json_lines() {
188        let _lock = ENV_LOCK.lock().unwrap();
189        let base = temp_base_dir("start-end-lines");
190        let _guard = EnvGuard::set_base_dir(&base);
191
192        append_record(&SessionIndexRecord::start("sess-1")).unwrap();
193        append_record(&SessionIndexRecord::end("sess-1")).unwrap();
194
195        let contents = std::fs::read_to_string(index_path()).unwrap();
196        let lines: Vec<&str> = contents.lines().collect();
197        assert_eq!(lines.len(), 2);
198        assert_eq!(
199            serde_json::from_str::<Value>(lines[0]).unwrap()["event"],
200            "start"
201        );
202        assert_eq!(
203            serde_json::from_str::<Value>(lines[1]).unwrap()["event"],
204            "end"
205        );
206    }
207
208    #[test]
209    #[serial]
210    fn read_recent_returns_newest_records_in_chronological_order() {
211        let _lock = ENV_LOCK.lock().unwrap();
212        let base = temp_base_dir("read-recent");
213        let _guard = EnvGuard::set_base_dir(&base);
214
215        append_record(&SessionIndexRecord::start("sess-1")).unwrap();
216        append_record(&SessionIndexRecord::start("sess-2")).unwrap();
217        append_record(&SessionIndexRecord::end("sess-2")).unwrap();
218
219        let records = read_recent(2).unwrap();
220        assert_eq!(records.len(), 2);
221        assert_eq!(records[0].session_id, "sess-2");
222        assert_eq!(records[0].event, SessionIndexEventKind::Start);
223        assert_eq!(records[1].session_id, "sess-2");
224        assert_eq!(records[1].event, SessionIndexEventKind::End);
225    }
226
227    #[test]
228    #[serial]
229    fn read_recent_missing_index_returns_empty() {
230        let _lock = ENV_LOCK.lock().unwrap();
231        let base = temp_base_dir("missing-index");
232        let _guard = EnvGuard::set_base_dir(&base);
233
234        assert!(read_recent(10).unwrap().is_empty());
235    }
236
237    #[test]
238    #[serial]
239    fn read_recent_limit_zero_returns_empty() {
240        let _lock = ENV_LOCK.lock().unwrap();
241        let base = temp_base_dir("limit-zero");
242        let _guard = EnvGuard::set_base_dir(&base);
243
244        append_record(&SessionIndexRecord::start("sess-1")).unwrap();
245
246        assert!(read_recent(0).unwrap().is_empty());
247    }
248
249    /// Private-mode tests (spec §5.4). Umask isolation: `#[serial(umask)]`
250    /// serializes umask-mutating tests crate-wide; `UmaskGuard` restores the
251    /// previous mask on drop (panic-safe). These use the path-parametrized
252    /// writer directly, so no env mutation is needed.
253    #[cfg(unix)]
254    mod private_modes {
255        use super::*;
256        use crate::core::private_fs::test_support::UmaskGuard;
257        use std::os::unix::fs::PermissionsExt;
258
259        fn mode_of(path: &std::path::Path) -> u32 {
260            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
261        }
262
263        #[test]
264        #[serial(umask)]
265        fn append_creates_0600_index_and_0700_dir_under_permissive_umask() {
266            let _umask = UmaskGuard::set(0);
267            let tmp = tempfile::TempDir::new().unwrap();
268            let path = tmp.path().join("sessions").join("index.jsonl");
269            append_record_to_path(&path, &SessionIndexRecord::start("s")).unwrap();
270            assert_eq!(
271                mode_of(path.parent().unwrap()),
272                0o700,
273                "index dir must be 0700"
274            );
275            assert_eq!(mode_of(&path), 0o600, "index file must be 0600");
276        }
277
278        #[test]
279        fn append_refuses_symlink_index() {
280            let tmp = tempfile::TempDir::new().unwrap();
281            let dir = tmp.path().join("sessions");
282            std::fs::create_dir_all(&dir).unwrap();
283            let victim = tmp.path().join("victim.jsonl");
284            std::fs::write(&victim, "").unwrap();
285            std::os::unix::fs::symlink(&victim, dir.join("index.jsonl")).unwrap();
286            let res =
287                append_record_to_path(&dir.join("index.jsonl"), &SessionIndexRecord::start("s"));
288            assert!(res.is_err(), "append through a symlink must fail");
289            assert_eq!(
290                std::fs::read_to_string(&victim).unwrap(),
291                "",
292                "no bytes may be written through the planted symlink"
293            );
294        }
295    }
296}