Skip to main content

magi_code/sessions/
manager.rs

1use super::metadata::SessionMetadataSummary;
2use super::read::validate_session_id;
3use super::store::{prepare_session_root, primary_path};
4use std::path::{Path, PathBuf};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub(crate) struct SessionInternalDiagnostic {
9    pub(crate) session_id: Option<String>,
10    pub(crate) message: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub(crate) struct SessionListReport {
15    pub(crate) summaries: Vec<SessionMetadataSummary>,
16    pub(crate) diagnostics: Vec<SessionInternalDiagnostic>,
17}
18
19#[derive(Debug, Clone)]
20pub struct SessionManager {
21    pub(in crate::sessions) root: PathBuf,
22}
23
24impl SessionManager {
25    #[must_use]
26    pub fn new(root: PathBuf) -> Self {
27        Self { root }
28    }
29
30    pub fn create(&self) -> anyhow::Result<Session> {
31        prepare_session_root(&self.root)?;
32        let id = Uuid::new_v4().to_string();
33        let path = self.path_for_valid_id(&id)?;
34        // Do not create JSONL until first event append; avoids orphan session files.
35        Ok(Session { id, path })
36    }
37
38    pub fn open(&self, id: impl Into<String>) -> anyhow::Result<Session> {
39        let id = validate_session_id(id.into())?;
40        Ok(Session {
41            path: self.path_for_valid_id(&id)?,
42            id,
43        })
44    }
45
46    pub(crate) fn open_existing(&self, id: impl Into<String>) -> anyhow::Result<Session> {
47        let session = self.open(id)?;
48        super::store::open_existing_primary(&self.root, &session.id)?
49            .ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))?;
50        Ok(session)
51    }
52
53    pub fn list(&self) -> anyhow::Result<Vec<Session>> {
54        Ok(self
55            .list_metadata_summaries()?
56            .into_iter()
57            .map(|summary| summary.session)
58            .collect())
59    }
60
61    pub fn most_recent(&self) -> anyhow::Result<Option<Session>> {
62        let report = self.list_metadata_report()?;
63        if !report.diagnostics.is_empty() {
64            anyhow::bail!("session discovery found unreadable or unsafe history");
65        }
66        Ok(report
67            .summaries
68            .into_iter()
69            .last()
70            .map(|summary| summary.session))
71    }
72
73    pub(crate) fn path_for_valid_id(&self, id: &str) -> anyhow::Result<PathBuf> {
74        primary_path(&self.root, id)
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Session {
80    pub(in crate::sessions) id: String,
81    pub(in crate::sessions) path: PathBuf,
82}
83
84impl Session {
85    pub fn id(&self) -> &str {
86        &self.id
87    }
88
89    pub fn path(&self) -> &Path {
90        &self.path
91    }
92
93    #[cfg(test)]
94    pub(crate) fn unchecked_for_test(id: String, path: PathBuf) -> Self {
95        Self { id, path }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use proptest::prelude::*;
103    use std::path::Component;
104    use tempfile::TempDir;
105
106    fn valid_session_id_strategy() -> impl Strategy<Value = String> {
107        proptest::string::string_regex("[A-Za-z0-9_-]{1,64}").unwrap()
108    }
109
110    fn invalid_session_id_strategy() -> impl Strategy<Value = String> {
111        prop_oneof![
112            Just(String::new()),
113            Just(".".to_string()),
114            any::<String>().prop_map(|value| format!("{value}..")),
115            any::<String>().prop_map(|value| format!("{value}/{value}")),
116            any::<String>().prop_map(|value| format!("{value}\\{value}")),
117            any::<String>().prop_map(|value| format!("{value}.jsonl")),
118            any::<String>().prop_map(|value| format!("{value}é")),
119        ]
120    }
121
122    fn lexical_normalize(path: &Path) -> PathBuf {
123        let mut normalized = PathBuf::new();
124        for component in path.components() {
125            match component {
126                Component::CurDir => {}
127                Component::ParentDir => {
128                    normalized.pop();
129                }
130                Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
131                    normalized.push(component.as_os_str());
132                }
133            }
134        }
135        normalized
136    }
137
138    proptest! {
139        #[test]
140        fn path_for_valid_id_keeps_valid_ids_under_session_root(id in valid_session_id_strategy()) {
141            let temp = TempDir::new().unwrap();
142            let root = temp.path().join("sessions");
143            let manager = SessionManager::new(root.clone());
144            let path = manager.path_for_valid_id(&id).unwrap();
145            let normalized_root = lexical_normalize(&root);
146            let normalized_path = lexical_normalize(&path);
147            let expected_file_name = format!("{id}.jsonl");
148
149            prop_assert!(normalized_path.starts_with(&normalized_root));
150            prop_assert_eq!(normalized_path.parent(), Some(normalized_root.as_path()));
151            prop_assert_eq!(
152                normalized_path.file_name().and_then(|file_name| file_name.to_str()),
153                Some(expected_file_name.as_str())
154            );
155
156            let session = manager.open(id.clone()).unwrap();
157            prop_assert_eq!(session.id(), id);
158            prop_assert_eq!(session.path(), path.as_path());
159        }
160
161        #[test]
162        fn open_and_path_for_valid_id_reject_generated_unsafe_ids(id in invalid_session_id_strategy()) {
163            let temp = TempDir::new().unwrap();
164            let manager = SessionManager::new(temp.path().join("sessions"));
165
166            prop_assert!(manager.open(id.clone()).is_err());
167            prop_assert!(manager.path_for_valid_id(&id).is_err());
168        }
169    }
170
171    #[test]
172    fn session_open_rejects_unsafe_ids_before_joining_paths() {
173        let temp = TempDir::new().unwrap();
174        let manager = SessionManager::new(temp.path().join("sessions"));
175        for id in [
176            "",
177            "..",
178            "../escape",
179            "nested/id",
180            "nested\\id",
181            "/absolute",
182            "bad.jsonl",
183        ] {
184            assert!(manager.open(id).is_err(), "accepted unsafe id {id:?}");
185        }
186        assert!(manager.open("safe_ID-123").is_ok());
187    }
188}