agent_core/core/
session_index.rs1use 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 #[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}