Skip to main content

ai_agent/utils/
session_storage.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/utils/sessionStorage.ts
2//! Session storage utilities - file-based session persistence
3
4use crate::constants::env::system;
5use crate::session::SessionData;
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// Get the session storage directory
10pub fn get_session_storage_dir() -> PathBuf {
11    let home = std::env::var(system::HOME)
12        .or_else(|_| std::env::var(system::USERPROFILE))
13        .unwrap_or_else(|_| "/tmp".to_string());
14    PathBuf::from(home)
15        .join(".open-agent-sdk")
16        .join("session_storage")
17}
18
19/// Get the transcript file path for a session
20pub fn get_transcript_path(session_id: &str) -> PathBuf {
21    get_session_storage_dir()
22        .join(session_id)
23        .join("transcript.json")
24}
25
26/// Get the session state file path for a session
27pub fn get_session_state_path(session_id: &str) -> PathBuf {
28    get_session_storage_dir()
29        .join(session_id)
30        .join("state.json")
31}
32
33/// Ensure the session storage directory exists
34fn ensure_storage_dir() -> std::io::Result<()> {
35    std::fs::create_dir_all(get_session_storage_dir())
36}
37
38/// Ensure a session-specific directory exists
39fn ensure_session_dir(session_id: &str) -> std::io::Result<()> {
40    std::fs::create_dir_all(get_session_storage_dir().join(session_id))
41}
42
43/// Check if a session's transcript file exists
44pub fn session_exists(session_id: &str) -> bool {
45    get_transcript_path(session_id).exists()
46}
47
48/// Internal transcript entry stored on disk
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TranscriptEntry {
51    pub role: String,
52    pub content: String,
53    #[serde(default)]
54    pub timestamp: Option<String>,
55}
56
57/// Load transcript for a session from disk
58pub fn load_transcript(session_id: &str) -> Vec<String> {
59    let path = get_transcript_path(session_id);
60    if !path.exists() {
61        return vec![];
62    }
63
64    match std::fs::read_to_string(&path) {
65        Ok(content) => {
66            match serde_json::from_str::<Vec<TranscriptEntry>>(&content) {
67                Ok(entries) => entries.into_iter().map(|e| e.content).collect(),
68                Err(_) => {
69                    // Try parsing as Vec<String> for backward compatibility
70                    serde_json::from_str::<Vec<String>>(&content).unwrap_or_default()
71                }
72            }
73        }
74        Err(_) => vec![],
75    }
76}
77
78/// Load full session data including transcript entries with metadata
79pub fn load_transcript_with_metadata(session_id: &str) -> Result<Vec<TranscriptEntry>, String> {
80    let path = get_transcript_path(session_id);
81    if !path.exists() {
82        return Err(format!("Transcript not found for session: {}", session_id));
83    }
84
85    let content =
86        std::fs::read_to_string(&path).map_err(|e| format!("Failed to read transcript: {}", e))?;
87
88    serde_json::from_str::<Vec<TranscriptEntry>>(&content)
89        .map_err(|e| format!("Failed to parse transcript: {}", e))
90}
91
92/// Save transcript for a session to disk
93pub fn save_transcript(session_id: &str, transcript: &[String]) -> Result<(), String> {
94    ensure_session_dir(session_id).map_err(|e| format!("Failed to create session dir: {}", e))?;
95
96    let entries: Vec<TranscriptEntry> = transcript
97        .iter()
98        .map(|content| TranscriptEntry {
99            role: "assistant".to_string(),
100            content: content.clone(),
101            timestamp: Some(chrono::Utc::now().to_rfc3339()),
102        })
103        .collect();
104
105    let json = serde_json::to_string_pretty(&entries)
106        .map_err(|e| format!("Failed to serialize transcript: {}", e))?;
107
108    let path = get_transcript_path(session_id);
109    std::fs::write(&path, json).map_err(|e| format!("Failed to write transcript: {}", e))?;
110
111    Ok(())
112}
113
114/// Append a message to an existing transcript
115pub fn append_to_transcript(session_id: &str, role: &str, content: &str) -> Result<(), String> {
116    let path = get_transcript_path(session_id);
117
118    let mut entries = if path.exists() {
119        let existing = std::fs::read_to_string(&path)
120            .map_err(|e| format!("Failed to read existing transcript: {}", e))?;
121        serde_json::from_str::<Vec<TranscriptEntry>>(&existing)
122            .map_err(|e| format!("Failed to parse existing transcript: {}", e))?
123    } else {
124        ensure_session_dir(session_id)
125            .map_err(|e| format!("Failed to create session dir: {}", e))?;
126        vec![]
127    };
128
129    entries.push(TranscriptEntry {
130        role: role.to_string(),
131        content: content.to_string(),
132        timestamp: Some(chrono::Utc::now().to_rfc3339()),
133    });
134
135    let json = serde_json::to_string_pretty(&entries)
136        .map_err(|e| format!("Failed to serialize transcript: {}", e))?;
137
138    std::fs::write(&path, json).map_err(|e| format!("Failed to write transcript: {}", e))?;
139
140    Ok(())
141}
142
143/// Delete a session's stored data
144pub fn delete_session_storage(session_id: &str) -> Result<(), String> {
145    let session_dir = get_session_storage_dir().join(session_id);
146    if session_dir.exists() {
147        std::fs::remove_dir_all(&session_dir)
148            .map_err(|e| format!("Failed to delete session storage: {}", e))?;
149    }
150    Ok(())
151}
152
153/// Flush all pending session storage writes to disk.
154/// Forces fsync on all session files to prevent data loss before error/success results.
155/// Matches TypeScript's flushSessionStorage() which calls getProject().flush().
156///
157/// In Rust, std::fs::write is synchronous but data may still be in OS page cache.
158/// This function ensures durability by calling fsync via File::sync_all().
159pub fn flush_session_storage() -> Result<(), String> {
160    let session_dir = get_session_storage_dir();
161    if !session_dir.exists() {
162        return Ok(()); // Nothing to flush
163    }
164
165    for entry in std::fs::read_dir(&session_dir)
166        .map_err(|e| format!("Failed to read session storage dir: {}", e))?
167    {
168        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
169        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
170            // Flush transcript file
171            let transcript = entry.path().join("transcript.json");
172            if transcript.exists() {
173                let mut f = std::fs::OpenOptions::new()
174                    .write(true)
175                    .open(&transcript)
176                    .map_err(|e| format!("Failed to open {}: {}", transcript.display(), e))?;
177                let _ = f.sync_all(); // Best effort - don't fail on sync error
178            }
179            // Flush state file
180            let state = entry.path().join("state.json");
181            if state.exists() {
182                let mut f = std::fs::OpenOptions::new()
183                    .write(true)
184                    .open(&state)
185                    .map_err(|e| format!("Failed to open {}: {}", state.display(), e))?;
186                let _ = f.sync_all();
187            }
188        }
189    }
190    Ok(())
191}
192
193/// List all stored session IDs
194pub fn list_stored_sessions() -> Vec<String> {
195    let dir = get_session_storage_dir();
196    if !dir.exists() {
197        return vec![];
198    }
199
200    let mut sessions = vec![];
201    if let Ok(entries) = std::fs::read_dir(&dir) {
202        for entry in entries.flatten() {
203            if entry.path().is_dir() {
204                if let Some(name) = entry.file_name().to_str() {
205                    let transcript_path = entry.path().join("transcript.json");
206                    if transcript_path.exists() {
207                        sessions.push(name.to_string());
208                    }
209                }
210            }
211        }
212    }
213    sessions
214}
215
216/// Get the size of stored transcript in bytes
217pub fn get_transcript_size(session_id: &str) -> u64 {
218    let path = get_transcript_path(session_id);
219    if !path.exists() {
220        return 0;
221    }
222    std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
223}
224
225/// Check if session data is corrupted by attempting to parse it
226pub fn is_session_data_valid(session_id: &str) -> bool {
227    let path = get_transcript_path(session_id);
228    if !path.exists() {
229        return false;
230    }
231
232    match std::fs::read_to_string(&path) {
233        Ok(content) => {
234            serde_json::from_str::<Vec<TranscriptEntry>>(&content).is_ok()
235                || serde_json::from_str::<Vec<String>>(&content).is_ok()
236        }
237        Err(_) => false,
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_get_transcript_path() {
247        let path = get_transcript_path("test-session-123");
248        assert!(path.to_string_lossy().contains("test-session-123"));
249        assert!(path.to_string_lossy().contains("transcript.json"));
250    }
251
252    #[test]
253    fn test_get_session_state_path() {
254        let path = get_session_state_path("test-session-456");
255        assert!(path.to_string_lossy().contains("test-session-456"));
256        assert!(path.to_string_lossy().contains("state.json"));
257    }
258
259    #[test]
260    fn test_session_not_exists() {
261        assert!(!session_exists("nonexistent-session-xyz"));
262    }
263
264    #[test]
265    fn test_list_stored_sessions_empty() {
266        let sessions = list_stored_sessions();
267        assert!(sessions.is_empty());
268    }
269
270    #[test]
271    fn test_load_transcript_nonexistent() {
272        let result = load_transcript("nonexistent");
273        assert!(result.is_empty());
274    }
275
276    #[test]
277    fn test_get_transcript_size_nonexistent() {
278        let size = get_transcript_size("nonexistent");
279        assert_eq!(size, 0);
280    }
281
282    #[test]
283    fn test_is_session_data_valid_nonexistent() {
284        assert!(!is_session_data_valid("nonexistent"));
285    }
286}