Skip to main content

systemprompt_cloud/cli_session/
store.rs

1//! On-disk index of CLI sessions keyed by tenant or `local`.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::TenantId;
13
14use super::{CliSession, LOCAL_SESSION_KEY, SessionKey};
15use crate::error::CloudResult;
16
17const STORE_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SessionStore {
21    pub version: u32,
22    pub sessions: HashMap<String, CliSession>,
23    pub active_key: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub active_profile_name: Option<String>,
26    pub updated_at: DateTime<Utc>,
27}
28
29impl Default for SessionStore {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl SessionStore {
36    #[must_use]
37    pub fn new() -> Self {
38        Self {
39            version: STORE_VERSION,
40            sessions: HashMap::new(),
41            active_key: None,
42            active_profile_name: None,
43            updated_at: Utc::now(),
44        }
45    }
46
47    #[must_use]
48    pub fn get_valid_session(&self, key: &SessionKey) -> Option<&CliSession> {
49        self.sessions
50            .get(&key.as_storage_key())
51            .filter(|s| !s.is_expired() && s.has_valid_credentials())
52    }
53
54    pub fn get_valid_session_mut(&mut self, key: &SessionKey) -> Option<&mut CliSession> {
55        self.sessions
56            .get_mut(&key.as_storage_key())
57            .filter(|s| !s.is_expired() && s.has_valid_credentials())
58    }
59
60    #[must_use]
61    pub fn get_session(&self, key: &SessionKey) -> Option<&CliSession> {
62        self.sessions.get(&key.as_storage_key())
63    }
64
65    pub fn upsert_session(&mut self, key: &SessionKey, session: CliSession) {
66        self.sessions.insert(key.as_storage_key(), session);
67        self.updated_at = Utc::now();
68    }
69
70    pub fn remove_session(&mut self, key: &SessionKey) -> Option<CliSession> {
71        let storage_key = key.as_storage_key();
72        let removed = self.sessions.remove(&storage_key);
73        if removed.is_some() {
74            self.updated_at = Utc::now();
75        }
76        removed
77    }
78
79    pub fn set_active(&mut self, key: &SessionKey) {
80        self.active_key = Some(key.as_storage_key());
81        self.updated_at = Utc::now();
82    }
83
84    pub fn set_active_with_profile(&mut self, key: &SessionKey, profile_name: &str) {
85        self.active_key = Some(key.as_storage_key());
86        self.active_profile_name = Some(profile_name.to_owned());
87        self.updated_at = Utc::now();
88    }
89
90    pub fn set_active_with_profile_path(
91        &mut self,
92        key: &SessionKey,
93        profile_name: &str,
94        profile_path: PathBuf,
95    ) {
96        self.active_key = Some(key.as_storage_key());
97        self.active_profile_name = Some(profile_name.to_owned());
98
99        if let Some(session) = self.sessions.get_mut(&key.as_storage_key()) {
100            session.update_profile_path(profile_path);
101        }
102
103        self.updated_at = Utc::now();
104    }
105
106    #[must_use]
107    pub fn active_session_key(&self) -> Option<SessionKey> {
108        self.active_key.as_ref().map(|k| {
109            if k == LOCAL_SESSION_KEY {
110                SessionKey::Local
111            } else {
112                k.strip_prefix("tenant_").map_or(SessionKey::Local, |id| {
113                    SessionKey::Tenant(TenantId::new(id))
114                })
115            }
116        })
117    }
118
119    #[must_use]
120    pub fn active_session(&self) -> Option<&CliSession> {
121        self.active_session_key()
122            .and_then(|key| self.get_valid_session(&key))
123    }
124
125    pub fn prune_expired(&mut self) -> usize {
126        let expired_keys: Vec<String> = self
127            .sessions
128            .iter()
129            .filter(|(_, s)| s.is_expired())
130            .map(|(k, _)| k.clone())
131            .collect();
132
133        let count = expired_keys.len();
134        for key in &expired_keys {
135            self.sessions.remove(key);
136        }
137
138        if count > 0 {
139            self.updated_at = Utc::now();
140        }
141        count
142    }
143
144    #[must_use]
145    pub fn find_by_profile_name(&self, name: &str) -> Option<&CliSession> {
146        self.sessions
147            .values()
148            .find(|s| s.profile_name.as_str() == name && !s.is_expired())
149    }
150
151    #[must_use]
152    pub fn all_sessions(&self) -> Vec<(&String, &CliSession)> {
153        self.sessions.iter().collect()
154    }
155
156    #[must_use]
157    pub fn len(&self) -> usize {
158        self.sessions.len()
159    }
160
161    #[must_use]
162    pub fn is_empty(&self) -> bool {
163        self.sessions.is_empty()
164    }
165
166    #[must_use]
167    pub fn load(sessions_dir: &Path) -> Option<Self> {
168        let index_path = sessions_dir.join("index.json");
169        let content = match fs::read_to_string(&index_path) {
170            Ok(c) => c,
171            Err(e) => {
172                tracing::debug!(error = %e, "No session store found");
173                return None;
174            },
175        };
176        match serde_json::from_str(&content) {
177            Ok(store) => Some(store),
178            Err(e) => {
179                tracing::warn!(error = %e, "Failed to parse session store");
180                None
181            },
182        }
183    }
184
185    #[expect(
186        clippy::unnecessary_wraps,
187        reason = "Preserves the existing public signature for callers using `?`"
188    )]
189    pub fn load_or_create(sessions_dir: &Path) -> CloudResult<Self> {
190        Ok(Self::load(sessions_dir).unwrap_or_else(Self::new))
191    }
192
193    pub fn save(&self, sessions_dir: &Path) -> CloudResult<()> {
194        fs::create_dir_all(sessions_dir)?;
195
196        let gitignore_path = sessions_dir.join(".gitignore");
197        if !gitignore_path.exists() {
198            fs::write(&gitignore_path, "*\n")?;
199        }
200
201        let index_path = sessions_dir.join("index.json");
202        let content = serde_json::to_string_pretty(self)?;
203        let temp_path = index_path.with_extension("tmp");
204        fs::write(&temp_path, &content)?;
205
206        #[cfg(unix)]
207        {
208            use std::os::unix::fs::PermissionsExt;
209            let mut perms = fs::metadata(&temp_path)?.permissions();
210            perms.set_mode(0o600);
211            fs::set_permissions(&temp_path, perms)?;
212        }
213
214        fs::rename(&temp_path, &index_path)?;
215        Ok(())
216    }
217}