Skip to main content

zeroclaw_infra/
session_backend.rs

1//! Trait abstraction for session persistence backends.
2//!
3//! Backends store per-sender conversation histories. The trait is intentionally
4//! minimal — load, append, remove_last, clear_messages, list — so that JSONL
5//! and SQLite (and future backends) share a common interface.
6
7use chrono::{DateTime, Utc};
8use zeroclaw_api::model_provider::ChatMessage;
9
10/// Metadata about a persisted session.
11#[derive(Debug, Clone)]
12pub struct SessionMetadata {
13    /// Session key (e.g. `telegram_user123`).
14    pub key: String,
15    /// Optional human-readable name (e.g. `eyrie-commander-briefing`).
16    pub name: Option<String>,
17    /// When the session was first created.
18    pub created_at: DateTime<Utc>,
19    /// When the last message was appended.
20    pub last_activity: DateTime<Utc>,
21    /// Total number of messages in the session.
22    pub message_count: usize,
23    /// Alias of the agent that owned this session (HashMap key in
24    /// `config.agents`). `None` for sessions persisted before per-agent
25    /// attribution landed, or for backends that don't track it.
26    pub agent_alias: Option<String>,
27    /// Dotted ChannelRef the session belongs to (`<type>.<alias>`,
28    /// e.g. `discord.clamps`). `None` for non-channel sessions (CLI,
29    /// internal cron runs) or backends without routing columns.
30    pub channel_id: Option<String>,
31    /// Platform-side room / thread identifier (Discord channel id,
32    /// Matrix room id, Slack thread ts, ...). `None` for direct messages
33    /// or backends that don't track it.
34    pub room_id: Option<String>,
35    /// Inbound sender id verbatim (Discord username, phone number, ...).
36    /// Not an FK — sessions can survive deletion of the upstream user.
37    pub sender_id: Option<String>,
38}
39
40/// Structured routing context recorded alongside a session. Mirrors the
41/// `ChannelMessage` fields the orchestrator uses to compose
42/// `conversation_history_key` so the session row can be queried by
43/// channel / room / sender without re-parsing the synthetic key.
44#[derive(Debug, Clone, Default)]
45pub struct SessionContext<'a> {
46    /// `<type>.<alias>` ChannelRef (`discord.clamps`).
47    pub channel_id: Option<&'a str>,
48    /// Platform-side room / thread id.
49    pub room_id: Option<&'a str>,
50    /// Inbound sender id (channel-native username, phone, ...).
51    pub sender_id: Option<&'a str>,
52}
53
54/// Query parameters for listing sessions.
55#[derive(Debug, Clone, Default)]
56pub struct SessionQuery {
57    /// Keyword to search in session messages (FTS5 if available).
58    pub keyword: Option<String>,
59    /// Maximum number of sessions to return.
60    pub limit: Option<usize>,
61}
62
63/// One persisted message with the optional `created_at` the backend
64/// stamped on it. JSONL / in-memory backends return `None`; SQLite
65/// returns the row's `created_at` column.
66#[derive(Debug, Clone)]
67pub struct TimestampedMessage {
68    pub message: ChatMessage,
69    pub created_at: Option<DateTime<Utc>>,
70}
71
72/// Trait for session persistence backends.
73///
74/// Implementations must be `Send + Sync` for sharing across async tasks.
75pub trait SessionBackend: Send + Sync {
76    /// Load all messages for a session. Returns empty vec if session doesn't exist.
77    fn load(&self, session_key: &str) -> Vec<ChatMessage>;
78
79    /// Same as `load`, but each row carries its persisted `created_at`
80    /// when the backend has one. Default impl falls back to `load`
81    /// without timestamps so non-SQLite backends keep working.
82    fn load_with_timestamps(&self, session_key: &str) -> Vec<TimestampedMessage> {
83        self.load(session_key)
84            .into_iter()
85            .map(|message| TimestampedMessage {
86                message,
87                created_at: None,
88            })
89            .collect()
90    }
91
92    /// Append a single message to a session.
93    fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()>;
94
95    /// Remove the last message from a session. Returns `true` if a message was removed.
96    fn remove_last(&self, session_key: &str) -> std::io::Result<bool>;
97
98    /// Update the content of the last message in a session. Used for incremental
99    /// persistence of streaming responses — append a placeholder first, then
100    /// update_last periodically as more content arrives. Returns `false` if
101    /// the session is empty. Default implementation is remove_last + append
102    /// (backends can override for efficiency).
103    fn update_last(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<bool> {
104        if self.remove_last(session_key)? {
105            self.append(session_key, message)?;
106            Ok(true)
107        } else {
108            Ok(false)
109        }
110    }
111
112    /// List all session keys.
113    fn list_sessions(&self) -> Vec<String>;
114
115    /// List sessions with metadata.
116    fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
117        // Default: construct metadata from messages (backends can override for efficiency)
118        self.list_sessions()
119            .into_iter()
120            .map(|key| {
121                let messages = self.load(&key);
122                SessionMetadata {
123                    key,
124                    name: None,
125                    created_at: Utc::now(),
126                    last_activity: Utc::now(),
127                    message_count: messages.len(),
128                    agent_alias: None,
129                    channel_id: None,
130                    room_id: None,
131                    sender_id: None,
132                }
133            })
134            .collect()
135    }
136
137    /// Compact a session file (remove duplicates/corruption). No-op by default.
138    fn compact(&self, _session_key: &str) -> std::io::Result<()> {
139        Ok(())
140    }
141
142    /// Remove sessions that haven't been active within the given TTL hours.
143    fn cleanup_stale(&self, _ttl_hours: u32) -> std::io::Result<usize> {
144        Ok(0)
145    }
146
147    /// Search sessions by keyword. Default returns empty (backends with FTS override).
148    fn search(&self, _query: &SessionQuery) -> Vec<SessionMetadata> {
149        Vec::new()
150    }
151
152    /// Clear all messages from a session, keeping the session itself alive.
153    /// Returns the number of messages removed.
154    ///
155    /// Override for production use. The default is O(n²) via iterative
156    /// `remove_last` — acceptable for tests but may cause latency on
157    /// sessions with >100 messages.
158    fn clear_messages(&self, session_key: &str) -> std::io::Result<usize> {
159        let mut count = 0;
160        while self.remove_last(session_key)? {
161            count += 1;
162        }
163        Ok(count)
164    }
165
166    /// Delete all messages for a session. Returns `true` if the session existed.
167    fn delete_session(&self, _session_key: &str) -> std::io::Result<bool> {
168        Ok(false)
169    }
170
171    /// Quick existence check used by the gateway to avoid resurrecting a
172    /// session that the user just deleted (#7126). The default impl falls
173    /// back to `get_session_metadata`; production backends should override
174    /// with the cheapest query consistent with how `delete_session` decides
175    /// what to wipe (SQLite: a `SELECT 1` against `session_metadata`).
176    fn session_exists(&self, session_key: &str) -> bool {
177        self.get_session_metadata(session_key).is_some()
178    }
179
180    /// Set or update the human-readable name for a session.
181    fn set_session_name(&self, _session_key: &str, _name: &str) -> std::io::Result<()> {
182        Ok(())
183    }
184
185    /// Get the human-readable name for a session (if set).
186    fn get_session_name(&self, _session_key: &str) -> std::io::Result<Option<String>> {
187        Ok(None)
188    }
189
190    /// Record the agent alias that owns a session. Called on WebSocket
191    /// handshake when the alias is known. No-op for backends that don't
192    /// track per-agent attribution.
193    fn set_session_agent_alias(
194        &self,
195        _session_key: &str,
196        _agent_alias: &str,
197    ) -> std::io::Result<()> {
198        Ok(())
199    }
200
201    /// Get the agent alias associated with a session, if recorded.
202    fn get_session_agent_alias(&self, _session_key: &str) -> std::io::Result<Option<String>> {
203        Ok(None)
204    }
205
206    /// Record the channel / room / sender routing context for a session.
207    /// Called by channel orchestrators right before the LLM dispatch so
208    /// the session row can be filtered by platform attribute in the
209    /// dashboard. No-op default; SQLite override fills the columns added
210    /// in the structured-routing migration.
211    fn set_session_context(
212        &self,
213        _session_key: &str,
214        _context: SessionContext<'_>,
215    ) -> std::io::Result<()> {
216        Ok(())
217    }
218
219    /// Look up metadata for a single session by key.
220    ///
221    /// The default impl loads all messages to derive the count and calls
222    /// `get_session_name` for the name. `created_at` and `last_activity` are
223    /// set to `Utc::now()` at call time — backends with stored timestamps
224    /// (e.g. SQLite) should override this method.
225    fn get_session_metadata(&self, session_key: &str) -> Option<SessionMetadata> {
226        let messages = self.load(session_key);
227        if messages.is_empty() {
228            return None;
229        }
230        Some(SessionMetadata {
231            key: session_key.to_string(),
232            name: self.get_session_name(session_key).ok().flatten(),
233            created_at: Utc::now(),
234            last_activity: Utc::now(),
235            message_count: messages.len(),
236            agent_alias: None,
237            channel_id: None,
238            room_id: None,
239            sender_id: None,
240        })
241    }
242
243    /// Set the session state (e.g. "idle", "running", "error").
244    /// `turn_id` identifies the current turn (set when running, cleared on idle).
245    fn set_session_state(
246        &self,
247        _session_key: &str,
248        _state: &str,
249        _turn_id: Option<&str>,
250    ) -> std::io::Result<()> {
251        Ok(())
252    }
253
254    /// Get the current session state. Returns `None` if the backend doesn't track state.
255    fn get_session_state(&self, _session_key: &str) -> std::io::Result<Option<SessionState>> {
256        Ok(None)
257    }
258
259    /// List sessions currently in "running" state.
260    fn list_running_sessions(&self) -> Vec<SessionMetadata> {
261        Vec::new()
262    }
263
264    /// List sessions stuck in "running" state longer than `threshold_secs`.
265    fn list_stuck_sessions(&self, _threshold_secs: u64) -> Vec<SessionMetadata> {
266        Vec::new()
267    }
268}
269
270/// Session state information.
271#[derive(Debug, Clone)]
272pub struct SessionState {
273    /// Current state: "idle", "running", or "error".
274    pub state: String,
275    /// Turn ID of the active or last turn.
276    pub turn_id: Option<String>,
277    /// When the current state was entered.
278    pub turn_started_at: Option<DateTime<Utc>>,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn session_metadata_is_constructible() {
287        let meta = SessionMetadata {
288            key: "test".into(),
289            name: None,
290            created_at: Utc::now(),
291            last_activity: Utc::now(),
292            message_count: 5,
293            agent_alias: None,
294            channel_id: None,
295            room_id: None,
296            sender_id: None,
297        };
298        assert_eq!(meta.key, "test");
299        assert_eq!(meta.message_count, 5);
300    }
301
302    #[test]
303    fn session_query_defaults() {
304        let q = SessionQuery::default();
305        assert!(q.keyword.is_none());
306        assert!(q.limit.is_none());
307    }
308}