Skip to main content

mermaid_cli/session/
index.rs

1//! The cross-project session index in `runtime.sqlite3`.
2//!
3//! `.mermaid/conversations/<id>.{jsonl,meta,json}` is the truth about a
4//! session; the SQLite `sessions` table is an index over those files that the
5//! daemon and its dashboard read across projects. Every save upserts the row
6//! ([`session_row`] is the one mapping), and the daemon rebuilds the index on
7//! start for every project it has ever heard of ([`rebuild_session_index`]),
8//! so the table can never disagree with the disk for longer than one daemon
9//! restart. There is no manual reconcile step, because a cache does not need
10//! one.
11
12use std::path::Path;
13
14use mermaid_domain::ConversationHistory;
15use mermaid_runtime::{NewSession, RuntimeStore};
16
17use super::ConversationManager;
18
19/// The index row for a session snapshot. The single mapping from the file's
20/// contents to the table's columns.
21#[must_use]
22pub fn session_row(conversations_dir: &Path, snapshot: &ConversationHistory) -> NewSession {
23    let conversation_path = conversations_dir.join(format!("{}.json", snapshot.id));
24    NewSession {
25        id: Some(snapshot.id.clone()),
26        // The snapshot's own field, not the runner's workdir: they are the
27        // same string by construction (`State::new` derives it from `cwd`),
28        // and one source beats two that must agree.
29        project_path: snapshot.project_path.clone(),
30        model_id: snapshot.model_name.clone(),
31        title: Some(snapshot.title.clone()),
32        conversation_path: Some(conversation_path.display().to_string()),
33        // Saturating rather than `as`: the column is signed, and a count that
34        // somehow exceeded i64 must not land negative.
35        total_tokens: Some(
36            i64::try_from(snapshot.cumulative_token_usage.total_tokens()).unwrap_or(i64::MAX),
37        ),
38    }
39}
40
41/// What a rebuild changed.
42#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
43pub struct SessionIndexReport {
44    /// Project directories that exist and were walked.
45    pub projects: usize,
46    /// Sessions with files on disk that had no row.
47    pub backfilled: usize,
48    /// Rows whose project directory exists but whose files are gone.
49    pub pruned: usize,
50}
51
52/// Rebuild the index from disk for every project the store knows: backfill a
53/// row for each session that has files but no row, and drop rows whose files
54/// are gone. A project directory that does not exist is left alone -- an
55/// unmounted drive is indistinguishable from a deleted checkout, and a stale
56/// row for an unreachable project costs nothing. Per-project I/O errors are
57/// logged and skipped; they never fail the rebuild.
58///
59/// # Errors
60///
61/// Errors only if the store itself cannot be queried.
62pub fn rebuild_session_index(store: &RuntimeStore) -> anyhow::Result<SessionIndexReport> {
63    let mut report = SessionIndexReport::default();
64    for project in store.known_project_paths()? {
65        let project_dir = Path::new(&project);
66        if !project_dir.is_dir() {
67            continue;
68        }
69        report.projects += 1;
70        let manager = match ConversationManager::new(project_dir) {
71            Ok(manager) => manager,
72            Err(error) => {
73                tracing::warn!(project = %project, %error, "session index: cannot open project");
74                continue;
75            },
76        };
77        let on_disk: Vec<String> = match manager.list_conversation_metas() {
78            Ok(metas) => metas.into_iter().map(|meta| meta.id).collect(),
79            Err(error) => {
80                tracing::warn!(project = %project, %error, "session index: cannot list sessions");
81                continue;
82            },
83        };
84        let indexed = store.sessions().ids_for_project(&project)?;
85        for id in &on_disk {
86            if indexed.contains(id) {
87                continue;
88            }
89            let snapshot = match manager.load_conversation(id) {
90                Ok(snapshot) => snapshot,
91                Err(error) => {
92                    tracing::warn!(project = %project, session = %id, %error, "session index: cannot load session");
93                    continue;
94                },
95            };
96            store
97                .sessions()
98                .upsert(session_row(manager.conversations_dir(), &snapshot))?;
99            report.backfilled += 1;
100        }
101        for id in indexed {
102            if on_disk.contains(&id) {
103                continue;
104            }
105            if store.sessions().delete(&id)? {
106                report.pruned += 1;
107            }
108        }
109    }
110    Ok(report)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use chrono::Local;
117    use mermaid_model::models::ChatMessage;
118    use mermaid_runtime::NewTask;
119
120    fn temp_store(name: &str) -> RuntimeStore {
121        let dir = std::env::temp_dir().join(format!(
122            "mermaid_session_index_{name}_{}",
123            std::process::id()
124        ));
125        let _ = std::fs::remove_dir_all(&dir);
126        std::fs::create_dir_all(&dir).unwrap();
127        RuntimeStore::open(dir.join("runtime.sqlite3")).unwrap()
128    }
129
130    fn temp_project(name: &str) -> std::path::PathBuf {
131        let dir = std::env::temp_dir().join(format!(
132            "mermaid_session_index_proj_{name}_{}",
133            std::process::id()
134        ));
135        let _ = std::fs::remove_dir_all(&dir);
136        std::fs::create_dir_all(&dir).unwrap();
137        dir
138    }
139
140    /// A saved session whose files exist on disk, with a task row that names
141    /// the project (which is how the store comes to know the project at all).
142    fn saved_session(project: &std::path::Path) -> (ConversationManager, ConversationHistory) {
143        let manager = ConversationManager::new(project).unwrap();
144        let mut conv = ConversationHistory::new(
145            project.display().to_string(),
146            "test/model".into(),
147            Local::now(),
148        );
149        conv.add_messages(&[ChatMessage::user("hi")], Local::now());
150        // The append path is what a live session writes: the `.jsonl` log
151        // (backfilled from the snapshot here) and the `.meta` sidecar. The
152        // `.json` checkpoint is a throttled cache and is deliberately absent.
153        manager.append_session_events(&conv, &[]).unwrap();
154        (manager, conv)
155    }
156
157    /// The case the old reconciler skipped: a session whose `.jsonl` and
158    /// `.meta` exist but whose `.json` checkpoint does not (a short session, or
159    /// an unclean exit) must still be backfilled.
160    #[test]
161    fn rebuild_backfills_a_session_that_has_files_but_no_row() {
162        let store = temp_store("backfill");
163        let project = temp_project("backfill");
164        let (manager, conv) = saved_session(&project);
165        assert!(
166            !manager
167                .conversations_dir()
168                .join(format!("{}.json", conv.id))
169                .exists()
170        );
171        store
172            .tasks()
173            .create(NewTask::new(
174                "t",
175                project.display().to_string(),
176                "test/model",
177            ))
178            .unwrap();
179        assert!(store.sessions().get(&conv.id).unwrap().is_none());
180
181        let report = rebuild_session_index(&store).unwrap();
182        assert_eq!(report.backfilled, 1, "{report:?}");
183        assert_eq!(report.pruned, 0);
184        let row = store
185            .sessions()
186            .get(&conv.id)
187            .unwrap()
188            .expect("backfilled row");
189        assert_eq!(row.model_id, "test/model");
190        assert_eq!(row.project_path, project.display().to_string());
191        let _ = std::fs::remove_dir_all(&project);
192    }
193
194    #[test]
195    fn rebuild_prunes_a_row_whose_files_are_gone() {
196        let store = temp_store("prune");
197        let project = temp_project("prune");
198        store
199            .sessions()
200            .upsert(NewSession {
201                id: Some("20260101_000000_gone".to_string()),
202                project_path: project.display().to_string(),
203                model_id: "m".to_string(),
204                title: None,
205                conversation_path: None,
206                total_tokens: None,
207            })
208            .unwrap();
209        let report = rebuild_session_index(&store).unwrap();
210        assert_eq!(report.pruned, 1, "{report:?}");
211        assert!(
212            store
213                .sessions()
214                .get("20260101_000000_gone")
215                .unwrap()
216                .is_none()
217        );
218        let _ = std::fs::remove_dir_all(&project);
219    }
220
221    #[test]
222    fn rebuild_leaves_rows_of_unreachable_projects_alone() {
223        let store = temp_store("unreachable");
224        store
225            .sessions()
226            .upsert(NewSession {
227                id: Some("20260101_000000_far".to_string()),
228                project_path: "/definitely/not/mounted/here".to_string(),
229                model_id: "m".to_string(),
230                title: None,
231                conversation_path: None,
232                total_tokens: None,
233            })
234            .unwrap();
235        let report = rebuild_session_index(&store).unwrap();
236        assert_eq!(report, SessionIndexReport::default());
237        assert!(
238            store
239                .sessions()
240                .get("20260101_000000_far")
241                .unwrap()
242                .is_some()
243        );
244    }
245
246    #[test]
247    fn session_row_is_the_single_index_mapping() {
248        let project = temp_project("row");
249        let (manager, conv) = saved_session(&project);
250        let row = session_row(manager.conversations_dir(), &conv);
251        assert_eq!(row.id.as_deref(), Some(conv.id.as_str()));
252        assert_eq!(row.project_path, conv.project_path);
253        assert_eq!(row.model_id, "test/model");
254        assert_eq!(row.title.as_deref(), Some(conv.title.as_str()));
255        assert!(
256            row.conversation_path
257                .unwrap()
258                .ends_with(&format!("{}.json", conv.id))
259        );
260        let _ = std::fs::remove_dir_all(&project);
261    }
262}