Skip to main content

zeroclaw_infra/
lib.rs

1//! Channel infrastructure: session backends, debouncing, and stall watchdog.
2//!
3//! These are cross-cutting utilities used by multiple channel implementations.
4
5pub mod acp_session_store;
6pub mod debounce;
7pub mod session_backend;
8pub mod session_sqlite;
9pub mod session_store;
10pub mod stall_watchdog;
11
12use std::path::Path;
13use std::sync::Arc;
14
15use crate::session_backend::SessionBackend;
16
17/// Construct the configured session-persistence backend.
18///
19/// `backend` is the value of `[channels].session_backend` from config:
20/// `"sqlite"` (default) opens `{workspace}/sessions/sessions.db`, `"jsonl"`
21/// opens `{workspace}/sessions/*.jsonl`. Unknown values fall back to
22/// SQLite with a warning so a typo in config never silently disables
23/// persistence. The `Arc<dyn SessionBackend>` return type keeps every
24/// call site (channel orchestrator, runtime tools) reading from the
25/// same store.
26///
27/// Errors propagate from the underlying backend constructor (typically
28/// filesystem permissions on the sessions directory).
29pub fn make_session_backend(
30    workspace_dir: &Path,
31    backend: &str,
32) -> std::io::Result<Arc<dyn SessionBackend>> {
33    match backend {
34        "jsonl" => {
35            let store = session_store::SessionStore::new(workspace_dir)?;
36            Ok(Arc::new(store))
37        }
38        "sqlite" => Ok(Arc::new(open_sqlite_with_jsonl_import(workspace_dir)?)),
39        other => {
40            ::zeroclaw_log::record!(
41                WARN,
42                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
43                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
44                    .with_attrs(::serde_json::json!({"other": other})),
45                "Unknown session_backend ''; falling back to sqlite. \
46                 Valid values: 'sqlite' (default), 'jsonl'."
47            );
48            Ok(Arc::new(open_sqlite_with_jsonl_import(workspace_dir)?))
49        }
50    }
51}
52
53/// Open the SQLite backend and, on first open, import any pre-existing
54/// `sessions/*.jsonl` files left over from the legacy JSONL store. Renames
55/// the imported files to `*.jsonl.migrated` so re-runs are no-ops; preserves
56/// them on disk so an operator can roll back without data loss. Errors from
57/// the import path are logged and skipped — the SQLite backend itself still
58/// opens, since blocking startup on a best-effort migration would be worse
59/// than a partial migration.
60fn open_sqlite_with_jsonl_import(
61    workspace_dir: &Path,
62) -> std::io::Result<session_sqlite::SqliteSessionBackend> {
63    let backend = session_sqlite::SqliteSessionBackend::new(workspace_dir)
64        .map_err(|e| std::io::Error::other(e.to_string()))?;
65    match backend.migrate_from_jsonl(workspace_dir) {
66        Ok(0) => {}
67        Ok(n) => ::zeroclaw_log::record!(
68            INFO,
69            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
70            &format!(
71                "session_backend=sqlite: imported {n} legacy JSONL session(s) from \
72             {}/sessions; renamed to *.jsonl.migrated.",
73                workspace_dir.display()
74            )
75        ),
76        Err(e) => ::zeroclaw_log::record!(
77            WARN,
78            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
79                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
80                .with_attrs(::serde_json::json!({"e": e.to_string()})),
81            "session_backend=sqlite: JSONL import skipped: . Existing JSONL \
82             sessions remain on disk; switch to session_backend = \"jsonl\" if \
83             you need them visible immediately."
84        ),
85    }
86    Ok(backend)
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use tempfile::TempDir;
93    use zeroclaw_api::model_provider::ChatMessage;
94
95    fn user_msg(content: &str) -> ChatMessage {
96        ChatMessage::user(content)
97    }
98
99    #[test]
100    fn make_session_backend_jsonl_round_trips_through_session_store() {
101        let tmp = TempDir::new().unwrap();
102        let backend = make_session_backend(tmp.path(), "jsonl").unwrap();
103        backend.append("k1", &user_msg("hello-jsonl")).unwrap();
104        let loaded = backend.load("k1");
105        assert_eq!(loaded.len(), 1);
106        // The JSONL backend writes one file per session key.
107        let jsonl = tmp.path().join("sessions").join("k1.jsonl");
108        assert!(jsonl.exists(), "jsonl file must be written under sessions/");
109    }
110
111    #[test]
112    fn make_session_backend_sqlite_round_trips_through_sqlite_db() {
113        let tmp = TempDir::new().unwrap();
114        let backend = make_session_backend(tmp.path(), "sqlite").unwrap();
115        backend.append("k1", &user_msg("hello-sqlite")).unwrap();
116        let loaded = backend.load("k1");
117        assert_eq!(loaded.len(), 1);
118        let db = tmp.path().join("sessions").join("sessions.db");
119        assert!(db.exists(), "sqlite db must be written under sessions/");
120        // The JSONL companion file must NOT have been created.
121        assert!(!tmp.path().join("sessions").join("k1.jsonl").exists());
122    }
123
124    #[test]
125    fn make_session_backend_unknown_value_falls_back_to_sqlite() {
126        let tmp = TempDir::new().unwrap();
127        let backend = make_session_backend(tmp.path(), "totally-not-a-backend").unwrap();
128        backend.append("k1", &user_msg("hello-fallback")).unwrap();
129        let db = tmp.path().join("sessions").join("sessions.db");
130        assert!(
131            db.exists(),
132            "unknown value must fall back to sqlite, not error"
133        );
134    }
135
136    #[test]
137    fn make_session_backend_sqlite_imports_legacy_jsonl_on_first_open() {
138        // Seed JSONL session files, then open SQLite — the .jsonl files must
139        // be migrated and the imported sessions must be visible via the new
140        // backend. The .jsonl files get renamed to .jsonl.migrated so the
141        // operator can roll back.
142        let tmp = TempDir::new().unwrap();
143        {
144            let jsonl = make_session_backend(tmp.path(), "jsonl").unwrap();
145            jsonl.append("legacy", &user_msg("from-jsonl")).unwrap();
146        }
147        let sqlite = make_session_backend(tmp.path(), "sqlite").unwrap();
148        let loaded = sqlite.load("legacy");
149        assert_eq!(
150            loaded.len(),
151            1,
152            "legacy JSONL session must hydrate via SQLite"
153        );
154        // .jsonl renamed to .jsonl.migrated; original gone.
155        let jsonl_orig = tmp.path().join("sessions").join("legacy.jsonl");
156        let jsonl_migrated = tmp.path().join("sessions").join("legacy.jsonl.migrated");
157        assert!(!jsonl_orig.exists(), "original .jsonl should be renamed");
158        assert!(
159            jsonl_migrated.exists(),
160            ".jsonl.migrated rollback file should remain"
161        );
162    }
163}