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    /// Set or update the human-readable name for a session.
172    fn set_session_name(&self, _session_key: &str, _name: &str) -> std::io::Result<()> {
173        Ok(())
174    }
175
176    /// Get the human-readable name for a session (if set).
177    fn get_session_name(&self, _session_key: &str) -> std::io::Result<Option<String>> {
178        Ok(None)
179    }
180
181    /// Record the agent alias that owns a session. Called on WebSocket
182    /// handshake when the alias is known. No-op for backends that don't
183    /// track per-agent attribution.
184    fn set_session_agent_alias(
185        &self,
186        _session_key: &str,
187        _agent_alias: &str,
188    ) -> std::io::Result<()> {
189        Ok(())
190    }
191
192    /// Get the agent alias associated with a session, if recorded.
193    fn get_session_agent_alias(&self, _session_key: &str) -> std::io::Result<Option<String>> {
194        Ok(None)
195    }
196
197    /// Record the channel / room / sender routing context for a session.
198    /// Called by channel orchestrators right before the LLM dispatch so
199    /// the session row can be filtered by platform attribute in the
200    /// dashboard. No-op default; SQLite override fills the columns added
201    /// in the structured-routing migration.
202    fn set_session_context(
203        &self,
204        _session_key: &str,
205        _context: SessionContext<'_>,
206    ) -> std::io::Result<()> {
207        Ok(())
208    }
209
210    /// Look up metadata for a single session by key.
211    ///
212    /// The default impl loads all messages to derive the count and calls
213    /// `get_session_name` for the name. `created_at` and `last_activity` are
214    /// set to `Utc::now()` at call time — backends with stored timestamps
215    /// (e.g. SQLite) should override this method.
216    fn get_session_metadata(&self, session_key: &str) -> Option<SessionMetadata> {
217        let messages = self.load(session_key);
218        if messages.is_empty() {
219            return None;
220        }
221        Some(SessionMetadata {
222            key: session_key.to_string(),
223            name: self.get_session_name(session_key).ok().flatten(),
224            created_at: Utc::now(),
225            last_activity: Utc::now(),
226            message_count: messages.len(),
227            agent_alias: None,
228            channel_id: None,
229            room_id: None,
230            sender_id: None,
231        })
232    }
233
234    /// Set the session state (e.g. "idle", "running", "error").
235    /// `turn_id` identifies the current turn (set when running, cleared on idle).
236    fn set_session_state(
237        &self,
238        _session_key: &str,
239        _state: &str,
240        _turn_id: Option<&str>,
241    ) -> std::io::Result<()> {
242        Ok(())
243    }
244
245    /// Get the current session state. Returns `None` if the backend doesn't track state.
246    fn get_session_state(&self, _session_key: &str) -> std::io::Result<Option<SessionState>> {
247        Ok(None)
248    }
249
250    /// List sessions currently in "running" state.
251    fn list_running_sessions(&self) -> Vec<SessionMetadata> {
252        Vec::new()
253    }
254
255    /// List sessions stuck in "running" state longer than `threshold_secs`.
256    fn list_stuck_sessions(&self, _threshold_secs: u64) -> Vec<SessionMetadata> {
257        Vec::new()
258    }
259}
260
261/// Session state information.
262#[derive(Debug, Clone)]
263pub struct SessionState {
264    /// Current state: "idle", "running", or "error".
265    pub state: String,
266    /// Turn ID of the active or last turn.
267    pub turn_id: Option<String>,
268    /// When the current state was entered.
269    pub turn_started_at: Option<DateTime<Utc>>,
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn session_metadata_is_constructible() {
278        let meta = SessionMetadata {
279            key: "test".into(),
280            name: None,
281            created_at: Utc::now(),
282            last_activity: Utc::now(),
283            message_count: 5,
284            agent_alias: None,
285            channel_id: None,
286            room_id: None,
287            sender_id: None,
288        };
289        assert_eq!(meta.key, "test");
290        assert_eq!(meta.message_count, 5);
291    }
292
293    #[test]
294    fn session_query_defaults() {
295        let q = SessionQuery::default();
296        assert!(q.keyword.is_none());
297        assert!(q.limit.is_none());
298    }
299}