Skip to main content

claude_code_sdk_rust/sessions/
store.rs

1use crate::error::{ClaudeSDKError, Result};
2use crate::session_store::{
3    project_key_for_directory, SessionKey, SessionStoreEntry, SessionStoreHandle,
4};
5use crate::session_summary::{fold_session_summary, summary_entry_to_sdk_info};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct SDKSessionInfo {
11    pub session_id: String,
12    pub summary: String,
13    pub last_modified: i64,
14    pub file_size: Option<i64>,
15    pub custom_title: Option<String>,
16    pub first_prompt: Option<String>,
17    pub git_branch: Option<String>,
18    pub cwd: Option<String>,
19    pub tag: Option<String>,
20    pub created_at: Option<i64>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct SDKSessionMessage {
25    pub r#type: String,
26    pub uuid: String,
27    pub session_id: String,
28    pub message: serde_json::Value,
29    pub parent_tool_use_id: Option<String>,
30}
31
32pub async fn rename_session_via_store(
33    session_store: &SessionStoreHandle,
34    session_id: &str,
35    title: &str,
36    directory: Option<&str>,
37) -> Result<()> {
38    validate_session_id(session_id)?;
39    let title = title.trim();
40    if title.is_empty() {
41        return Err(ClaudeSDKError::Session(
42            "title must be non-empty".to_string(),
43        ));
44    }
45    let project_key = project_key_for_directory(directory.map(Path::new));
46    session_store
47        .append(
48            SessionKey {
49                project_key,
50                session_id: session_id.to_string(),
51                subpath: None,
52            },
53            vec![metadata_entry(
54                "custom-title",
55                session_id,
56                [("customTitle", serde_json::Value::String(title.to_string()))],
57            )],
58        )
59        .await
60}
61
62pub async fn tag_session_via_store(
63    session_store: &SessionStoreHandle,
64    session_id: &str,
65    tag: Option<&str>,
66    directory: Option<&str>,
67) -> Result<()> {
68    validate_session_id(session_id)?;
69    let tag = tag.map(sanitize_tag).transpose()?;
70    let project_key = project_key_for_directory(directory.map(Path::new));
71    session_store
72        .append(
73            SessionKey {
74                project_key,
75                session_id: session_id.to_string(),
76                subpath: None,
77            },
78            vec![metadata_entry(
79                "tag",
80                session_id,
81                [("tag", serde_json::Value::String(tag.unwrap_or_default()))],
82            )],
83        )
84        .await
85}
86
87pub async fn delete_session_via_store(
88    session_store: &SessionStoreHandle,
89    session_id: &str,
90    directory: Option<&str>,
91) -> Result<()> {
92    validate_session_id(session_id)?;
93    let project_key = project_key_for_directory(directory.map(Path::new));
94    session_store
95        .delete(SessionKey {
96            project_key,
97            session_id: session_id.to_string(),
98            subpath: None,
99        })
100        .await
101}
102
103pub async fn list_sessions_from_store(
104    session_store: &SessionStoreHandle,
105    directory: Option<&str>,
106    limit: Option<usize>,
107    offset: usize,
108) -> Result<Vec<SDKSessionInfo>> {
109    let project_path = canonical_project_path(directory);
110    let project_key = project_key_for_directory(Some(Path::new(&project_path)));
111    let listing = session_store.list_sessions(&project_key).await?;
112    let summaries = session_store
113        .list_session_summaries(&project_key)
114        .await
115        .unwrap_or_default();
116
117    let mut infos = Vec::new();
118    let mut covered = std::collections::HashSet::new();
119    let known_mtimes = listing
120        .iter()
121        .map(|entry| (entry.session_id.clone(), entry.mtime))
122        .collect::<std::collections::HashMap<_, _>>();
123
124    for summary in summaries {
125        if let Some(known_mtime) = known_mtimes.get(&summary.session_id) {
126            if summary.mtime < *known_mtime {
127                continue;
128            }
129        }
130        covered.insert(summary.session_id.clone());
131        if let Some(info) = summary_entry_to_sdk_info(&summary, Some(&project_path)) {
132            infos.push(info);
133        }
134    }
135
136    for entry in listing {
137        if covered.contains(&entry.session_id) {
138            continue;
139        }
140        match derive_session_info_from_store(
141            session_store,
142            &project_key,
143            &project_path,
144            &entry.session_id,
145        )
146        .await
147        {
148            Ok(Some(mut info)) => {
149                info.last_modified = entry.mtime;
150                infos.push(info);
151            }
152            Ok(None) => {}
153            Err(_) => infos.push(degraded_session_info(&entry.session_id, entry.mtime)),
154        }
155    }
156
157    Ok(sort_limit_offset(infos, limit, offset))
158}
159
160fn degraded_session_info(session_id: &str, last_modified: i64) -> SDKSessionInfo {
161    SDKSessionInfo {
162        session_id: session_id.to_string(),
163        summary: String::new(),
164        last_modified,
165        file_size: None,
166        custom_title: None,
167        first_prompt: None,
168        git_branch: None,
169        cwd: None,
170        tag: None,
171        created_at: None,
172    }
173}
174
175pub async fn get_session_info_from_store(
176    session_store: &SessionStoreHandle,
177    session_id: &str,
178    directory: Option<&str>,
179) -> Result<Option<SDKSessionInfo>> {
180    if !is_uuid(session_id) {
181        return Ok(None);
182    }
183    let project_path = canonical_project_path(directory);
184    let project_key = project_key_for_directory(Some(Path::new(&project_path)));
185    derive_session_info_from_store(session_store, &project_key, &project_path, session_id).await
186}
187
188pub async fn get_session_messages_from_store(
189    session_store: &SessionStoreHandle,
190    session_id: &str,
191    directory: Option<&str>,
192    limit: Option<usize>,
193    offset: usize,
194) -> Result<Vec<SDKSessionMessage>> {
195    if !is_uuid(session_id) {
196        return Ok(Vec::new());
197    }
198    let project_key = project_key_for_directory(directory.map(Path::new));
199    let key = SessionKey {
200        project_key,
201        session_id: session_id.to_string(),
202        subpath: None,
203    };
204    let Some(entries) = session_store.load(key).await? else {
205        return Ok(Vec::new());
206    };
207    Ok(entries_to_session_messages(
208        session_id, entries, limit, offset,
209    ))
210}
211
212pub async fn list_subagents_from_store(
213    session_store: &SessionStoreHandle,
214    session_id: &str,
215    directory: Option<&str>,
216) -> Result<Vec<String>> {
217    if !is_uuid(session_id) {
218        return Ok(Vec::new());
219    }
220    let project_key = project_key_for_directory(directory.map(Path::new));
221    let subkeys = session_store
222        .list_subkeys(crate::session_store::SessionListSubkeysKey {
223            project_key,
224            session_id: session_id.to_string(),
225        })
226        .await?;
227    let mut seen = std::collections::HashSet::new();
228    let mut ids = Vec::new();
229    for subkey in subkeys {
230        if let Some(agent_id) = subagent_id_from_subkey(&subkey) {
231            if seen.insert(agent_id.to_string()) {
232                ids.push(agent_id.to_string());
233            }
234        }
235    }
236    Ok(ids)
237}
238
239pub async fn get_subagent_messages_from_store(
240    session_store: &SessionStoreHandle,
241    session_id: &str,
242    agent_id: &str,
243    directory: Option<&str>,
244    limit: Option<usize>,
245    offset: usize,
246) -> Result<Vec<SDKSessionMessage>> {
247    if !is_uuid(session_id) || agent_id.is_empty() {
248        return Ok(Vec::new());
249    }
250    let project_key = project_key_for_directory(directory.map(Path::new));
251    let subpath = find_subagent_subpath(session_store, &project_key, session_id, agent_id).await?;
252    let Some(entries) = session_store
253        .load(SessionKey {
254            project_key,
255            session_id: session_id.to_string(),
256            subpath: Some(subpath),
257        })
258        .await?
259    else {
260        return Ok(Vec::new());
261    };
262    let entries = entries
263        .into_iter()
264        .filter(|entry| {
265            entry.get("type").and_then(|value| value.as_str()) != Some("agent_metadata")
266        })
267        .collect();
268    Ok(entries_to_session_messages(
269        session_id, entries, limit, offset,
270    ))
271}
272
273async fn find_subagent_subpath(
274    session_store: &SessionStoreHandle,
275    project_key: &str,
276    session_id: &str,
277    agent_id: &str,
278) -> Result<String> {
279    let target = format!("agent-{agent_id}");
280    let subkeys = session_store
281        .list_subkeys(crate::session_store::SessionListSubkeysKey {
282            project_key: project_key.to_string(),
283            session_id: session_id.to_string(),
284        })
285        .await
286        .unwrap_or_default();
287    Ok(subkeys
288        .into_iter()
289        .find(|subkey| {
290            subkey.starts_with("subagents/") && subkey.rsplit('/').next() == Some(target.as_str())
291        })
292        .unwrap_or_else(|| format!("subagents/{target}")))
293}
294
295async fn derive_session_info_from_store(
296    session_store: &SessionStoreHandle,
297    project_key: &str,
298    project_path: &str,
299    session_id: &str,
300) -> Result<Option<SDKSessionInfo>> {
301    let key = SessionKey {
302        project_key: project_key.to_string(),
303        session_id: session_id.to_string(),
304        subpath: None,
305    };
306    let Some(entries) = session_store.load(key.clone()).await? else {
307        return Ok(None);
308    };
309    if entries.is_empty() {
310        return Ok(None);
311    }
312    let summary = fold_session_summary(None, &key, &entries);
313    Ok(summary_entry_to_sdk_info(&summary, Some(project_path)))
314}
315
316fn entries_to_session_messages(
317    session_id: &str,
318    entries: Vec<SessionStoreEntry>,
319    limit: Option<usize>,
320    offset: usize,
321) -> Vec<SDKSessionMessage> {
322    entries
323        .into_iter()
324        .filter_map(|entry| session_message_from_entry(session_id, entry))
325        .skip(offset)
326        .take(limit.filter(|limit| *limit > 0).unwrap_or(usize::MAX))
327        .collect()
328}
329
330fn session_message_from_entry(
331    fallback_session_id: &str,
332    entry: SessionStoreEntry,
333) -> Option<SDKSessionMessage> {
334    let message_type = entry.get("type")?.as_str()?;
335    if message_type != "user" && message_type != "assistant" {
336        return None;
337    }
338    let uuid = entry.get("uuid")?.as_str()?.to_string();
339    let message = entry.get("message")?.clone();
340    Some(SDKSessionMessage {
341        r#type: message_type.to_string(),
342        uuid,
343        session_id: entry
344            .get("session_id")
345            .and_then(|value| value.as_str())
346            .unwrap_or(fallback_session_id)
347            .to_string(),
348        message,
349        parent_tool_use_id: entry
350            .get("parent_tool_use_id")
351            .and_then(|value| value.as_str())
352            .map(String::from),
353    })
354}
355
356fn subagent_id_from_subkey(subkey: &str) -> Option<&str> {
357    if !subkey.starts_with("subagents/") {
358        return None;
359    }
360    subkey.rsplit('/').next()?.strip_prefix("agent-")
361}
362
363fn sort_limit_offset(
364    mut infos: Vec<SDKSessionInfo>,
365    limit: Option<usize>,
366    offset: usize,
367) -> Vec<SDKSessionInfo> {
368    infos.sort_by_key(|info| std::cmp::Reverse(info.last_modified));
369    let infos = if offset > 0 {
370        infos.into_iter().skip(offset).collect()
371    } else {
372        infos
373    };
374    if let Some(limit) = limit.filter(|limit| *limit > 0) {
375        infos.into_iter().take(limit).collect()
376    } else {
377        infos
378    }
379}
380
381fn canonical_project_path(directory: Option<&str>) -> String {
382    let path = directory.map(Path::new).unwrap_or_else(|| Path::new("."));
383    let absolute = if path.is_absolute() {
384        path.to_path_buf()
385    } else {
386        std::env::current_dir()
387            .unwrap_or_else(|_| ".".into())
388            .join(path)
389    };
390    std::fs::canonicalize(&absolute)
391        .unwrap_or(absolute)
392        .to_string_lossy()
393        .to_string()
394}
395
396fn is_uuid(value: &str) -> bool {
397    uuid::Uuid::parse_str(value).is_ok()
398}
399
400fn validate_session_id(session_id: &str) -> Result<()> {
401    if is_uuid(session_id) {
402        Ok(())
403    } else {
404        Err(ClaudeSDKError::Session(format!(
405            "Invalid session_id: {session_id}"
406        )))
407    }
408}
409
410fn sanitize_tag(tag: &str) -> Result<String> {
411    let sanitized = tag
412        .chars()
413        .filter(|ch| !ch.is_control())
414        .collect::<String>()
415        .trim()
416        .to_string();
417    if sanitized.is_empty() {
418        Err(ClaudeSDKError::Session(
419            "tag must be non-empty (use None to clear)".to_string(),
420        ))
421    } else {
422        Ok(sanitized)
423    }
424}
425
426fn metadata_entry<const N: usize>(
427    entry_type: &str,
428    session_id: &str,
429    fields: [(&str, serde_json::Value); N],
430) -> SessionStoreEntry {
431    let mut entry = serde_json::Map::new();
432    entry.insert("type".to_string(), serde_json::json!(entry_type));
433    entry.insert("sessionId".to_string(), serde_json::json!(session_id));
434    entry.insert(
435        "uuid".to_string(),
436        serde_json::json!(uuid::Uuid::new_v4().to_string()),
437    );
438    entry.insert(
439        "timestamp".to_string(),
440        serde_json::json!(chrono::Utc::now().to_rfc3339()),
441    );
442    for (key, value) in fields {
443        entry.insert(key.to_string(), value);
444    }
445    entry
446}