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}