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_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
18/// Construct the configured session-persistence backend.
19///
20/// `backend` is the value of `[channels].session_backend` from config:
21/// `"sqlite"` (default) opens `{workspace}/sessions/sessions.db`, `"jsonl"`
22/// opens `{workspace}/sessions/*.jsonl`. Unknown values fall back to
23/// SQLite with a warning so a typo in config never silently disables
24/// persistence. The `Arc<dyn SessionBackend>` return type keeps every
25/// call site (channel orchestrator, runtime tools) reading from the
26/// same store.
27///
28/// Errors propagate from the underlying backend constructor (typically
29/// filesystem permissions on the sessions directory).
30pub 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
54/// Open the SQLite backend and, on first open, import any pre-existing
55/// `sessions/*.jsonl` files left over from the legacy JSONL store. Renames
56/// the imported files to `*.jsonl.migrated` so re-runs are no-ops; preserves
57/// them on disk so an operator can roll back without data loss. Errors from
58/// the import path are logged and skipped — the SQLite backend itself still
59/// opens, since blocking startup on a best-effort migration would be worse
60/// than a partial migration.
61fn 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        // The JSONL backend writes one file per session key.
108        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        // The JSONL companion file must NOT have been created.
122        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        // Seed JSONL session files, then open SQLite — the .jsonl files must
140        // be migrated and the imported sessions must be visible via the new
141        // backend. The .jsonl files get renamed to .jsonl.migrated so the
142        // operator can roll back.
143        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        // .jsonl renamed to .jsonl.migrated; original gone.
156        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}