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}