1pub mod acp_session_store;
6pub mod debounce;
7pub mod session_backend;
8pub mod session_queue;
9pub mod session_sqlite;
10pub mod session_store;
11pub mod stall_watchdog;
12
13use std::path::Path;
14use std::sync::Arc;
15
16use crate::session_backend::SessionBackend;
17
18pub fn make_session_backend(
31 workspace_dir: &Path,
32 backend: &str,
33) -> std::io::Result<Arc<dyn SessionBackend>> {
34 match backend {
35 "jsonl" => {
36 let store = session_store::SessionStore::new(workspace_dir)?;
37 Ok(Arc::new(store))
38 }
39 "sqlite" => Ok(Arc::new(open_sqlite_with_jsonl_import(workspace_dir)?)),
40 other => {
41 ::zeroclaw_log::record!(
42 WARN,
43 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
44 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
45 .with_attrs(::serde_json::json!({"other": other})),
46 "Unknown session_backend ''; falling back to sqlite. \
47 Valid values: 'sqlite' (default), 'jsonl'."
48 );
49 Ok(Arc::new(open_sqlite_with_jsonl_import(workspace_dir)?))
50 }
51 }
52}
53
54fn open_sqlite_with_jsonl_import(
62 workspace_dir: &Path,
63) -> std::io::Result<session_sqlite::SqliteSessionBackend> {
64 let backend = session_sqlite::SqliteSessionBackend::new(workspace_dir)
65 .map_err(|e| std::io::Error::other(e.to_string()))?;
66 match backend.migrate_from_jsonl(workspace_dir) {
67 Ok(0) => {}
68 Ok(n) => ::zeroclaw_log::record!(
69 INFO,
70 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
71 &format!(
72 "session_backend=sqlite: imported {n} legacy JSONL session(s) from \
73 {}/sessions; renamed to *.jsonl.migrated.",
74 workspace_dir.display()
75 )
76 ),
77 Err(e) => ::zeroclaw_log::record!(
78 WARN,
79 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
80 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
81 .with_attrs(::serde_json::json!({"e": e.to_string()})),
82 "session_backend=sqlite: JSONL import skipped: . Existing JSONL \
83 sessions remain on disk; switch to session_backend = \"jsonl\" if \
84 you need them visible immediately."
85 ),
86 }
87 Ok(backend)
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use tempfile::TempDir;
94 use zeroclaw_api::model_provider::ChatMessage;
95
96 fn user_msg(content: &str) -> ChatMessage {
97 ChatMessage::user(content)
98 }
99
100 #[test]
101 fn make_session_backend_jsonl_round_trips_through_session_store() {
102 let tmp = TempDir::new().unwrap();
103 let backend = make_session_backend(tmp.path(), "jsonl").unwrap();
104 backend.append("k1", &user_msg("hello-jsonl")).unwrap();
105 let loaded = backend.load("k1");
106 assert_eq!(loaded.len(), 1);
107 let jsonl = tmp.path().join("sessions").join("k1.jsonl");
109 assert!(jsonl.exists(), "jsonl file must be written under sessions/");
110 }
111
112 #[test]
113 fn make_session_backend_sqlite_round_trips_through_sqlite_db() {
114 let tmp = TempDir::new().unwrap();
115 let backend = make_session_backend(tmp.path(), "sqlite").unwrap();
116 backend.append("k1", &user_msg("hello-sqlite")).unwrap();
117 let loaded = backend.load("k1");
118 assert_eq!(loaded.len(), 1);
119 let db = tmp.path().join("sessions").join("sessions.db");
120 assert!(db.exists(), "sqlite db must be written under sessions/");
121 assert!(!tmp.path().join("sessions").join("k1.jsonl").exists());
123 }
124
125 #[test]
126 fn make_session_backend_unknown_value_falls_back_to_sqlite() {
127 let tmp = TempDir::new().unwrap();
128 let backend = make_session_backend(tmp.path(), "totally-not-a-backend").unwrap();
129 backend.append("k1", &user_msg("hello-fallback")).unwrap();
130 let db = tmp.path().join("sessions").join("sessions.db");
131 assert!(
132 db.exists(),
133 "unknown value must fall back to sqlite, not error"
134 );
135 }
136
137 #[test]
138 fn make_session_backend_sqlite_imports_legacy_jsonl_on_first_open() {
139 let tmp = TempDir::new().unwrap();
144 {
145 let jsonl = make_session_backend(tmp.path(), "jsonl").unwrap();
146 jsonl.append("legacy", &user_msg("from-jsonl")).unwrap();
147 }
148 let sqlite = make_session_backend(tmp.path(), "sqlite").unwrap();
149 let loaded = sqlite.load("legacy");
150 assert_eq!(
151 loaded.len(),
152 1,
153 "legacy JSONL session must hydrate via SQLite"
154 );
155 let jsonl_orig = tmp.path().join("sessions").join("legacy.jsonl");
157 let jsonl_migrated = tmp.path().join("sessions").join("legacy.jsonl.migrated");
158 assert!(!jsonl_orig.exists(), "original .jsonl should be renamed");
159 assert!(
160 jsonl_migrated.exists(),
161 ".jsonl.migrated rollback file should remain"
162 );
163 }
164}