Skip to main content

zeroclaw_infra/
session_backend.rs

1//! Trait abstraction for session persistence backends.
2
3use chrono::{DateTime, Utc};
4use zeroclaw_api::model_provider::ChatMessage;
5
6/// Metadata about a persisted session.
7#[derive(Debug, Clone)]
8pub struct SessionMetadata {
9    /// Session key (e.g. `telegram_user123`).
10    pub key: String,
11    /// Optional human-readable name (e.g. `eyrie-commander-briefing`).
12    pub name: Option<String>,
13    /// When the session was first created.
14    pub created_at: DateTime<Utc>,
15    /// When the last message was appended.
16    pub last_activity: DateTime<Utc>,
17    /// Total number of messages in the session.
18    pub message_count: usize,
19    /// Alias of the agent that owned this session (HashMap key in
20    /// `config.agents`). `None` for sessions persisted before per-agent
21    /// attribution landed, or for backends that don't track it.
22    pub agent_alias: Option<String>,
23    /// Dotted ChannelRef the session belongs to (`<type>.<alias>`,
24    /// e.g. `discord.clamps`). `None` for non-channel sessions (CLI,
25    /// internal cron runs) or backends without routing columns.
26    pub channel_id: Option<String>,
27    /// Platform-side room / thread identifier (Discord channel id,
28    /// Matrix room id, Slack thread ts, ...). `None` for direct messages
29    /// or backends that don't track it.
30    pub room_id: Option<String>,
31    /// Inbound sender id verbatim (Discord username, phone number, ...).
32    /// Not an FK — sessions can survive deletion of the upstream user.
33    pub sender_id: Option<String>,
34    /// Stable id of the authenticated principal that created the session
35    /// (RFC 7141). `None` for sessions persisted before principal-keying
36    /// landed, channel-originated sessions, or backends without the column.
37    pub principal_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/// A caller-owned, synchronous authority hold. Storage knows no runtime policy.
73pub trait SessionEffectGuard {}
74impl<T> SessionEffectGuard for T {}
75
76pub type SessionEffectAuthorization<'a> =
77    dyn Fn(Option<&str>) -> std::io::Result<Box<dyn SessionEffectGuard + 'a>> + 'a;
78
79/// Trait for session persistence backends shared across async tasks.
80pub trait SessionBackend: Send + Sync {
81    /// Load all messages for a session. Returns empty vec if session doesn't exist.
82    fn load(&self, session_key: &str) -> Vec<ChatMessage>;
83
84    /// Like `load`, but distinguishes an unreadable transcript from a genuinely
85    /// missing/empty session. `Ok(vec)` is the verified durable transcript
86    /// (empty means no session), `Err` means the transcript could not be read
87    /// or parsed and must not be treated as empty. The default impl
88    /// delegates to `load` and never errors; backends that can detect read
89    /// failures (JSONL) override this so callers can fail closed instead of
90    /// seeding a new-message-only cache over an existing transcript.
91    fn try_load(&self, session_key: &str) -> std::io::Result<Vec<ChatMessage>> {
92        Ok(self.load(session_key))
93    }
94
95    /// Same as `load`, but each row carries its persisted `created_at`
96    /// when the backend has one. Default impl falls back to `load`
97    /// without timestamps so non-SQLite backends keep working.
98    fn load_with_timestamps(&self, session_key: &str) -> Vec<TimestampedMessage> {
99        self.load(session_key)
100            .into_iter()
101            .map(|message| TimestampedMessage {
102                message,
103                created_at: None,
104            })
105            .collect()
106    }
107
108    /// Append a single message to a session.
109    fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()>;
110
111    /// Append under the storage mutation boundary, resolving its current owner
112    /// there. Retain authorization through commit and the synchronous live-cache
113    /// effect. Unsupported stores fail closed; `committed` must not reenter storage.
114    fn append_authorized(
115        &self,
116        _session_key: &str,
117        _message: &ChatMessage,
118        _authorize: &SessionEffectAuthorization<'_>,
119        _committed: &mut dyn FnMut(),
120    ) -> std::io::Result<usize> {
121        Err(std::io::Error::new(
122            std::io::ErrorKind::Unsupported,
123            "guarded session append unsupported",
124        ))
125    }
126
127    /// Rename an existing row under its mutation lock and current owner lease.
128    fn set_session_name_authorized(
129        &self,
130        _session_key: &str,
131        _name: &str,
132        _authorize: &SessionEffectAuthorization<'_>,
133    ) -> std::io::Result<()> {
134        Err(std::io::Error::new(
135            std::io::ErrorKind::Unsupported,
136            "guarded session rename unsupported",
137        ))
138    }
139
140    /// Remove the last message from a session. Returns `true` if a message was removed.
141    fn remove_last(&self, session_key: &str) -> std::io::Result<bool>;
142
143    /// Replace a session's entire durable transcript with `messages`. Used to
144    /// keep the canonical stored history in sync with a caller-owned buffer
145    /// that trimmed or rewrote turns in place (e.g. history-budget
146    /// enforcement), rather than appending on top of a now-stale transcript.
147    /// The default clears the session (`clear_messages`) and re-appends
148    /// every message through the required `append` primitive, so every
149    /// backend gets real replacement semantics — a backend that never
150    /// overrides this does not silently drop the caller's authoritative
151    /// trimmed history. It is O(n) calls and not atomic; backends with a
152    /// native batch/transactional rewrite (JSONL, SQLite) override this.
153    fn rewrite_messages(&self, session_key: &str, messages: &[ChatMessage]) -> std::io::Result<()> {
154        self.clear_messages(session_key)?;
155        for message in messages {
156            self.append(session_key, message)?;
157        }
158        Ok(())
159    }
160
161    fn update_last(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<bool> {
162        if self.remove_last(session_key)? {
163            self.append(session_key, message)?;
164            Ok(true)
165        } else {
166            Ok(false)
167        }
168    }
169
170    /// List all session keys.
171    fn list_sessions(&self) -> Vec<String>;
172
173    /// List sessions with metadata.
174    fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
175        // Default: construct metadata from messages (backends can override for efficiency)
176        self.list_sessions()
177            .into_iter()
178            .map(|key| {
179                let messages = self.load(&key);
180                SessionMetadata {
181                    key,
182                    name: None,
183                    created_at: Utc::now(),
184                    last_activity: Utc::now(),
185                    message_count: messages.len(),
186                    agent_alias: None,
187                    channel_id: None,
188                    room_id: None,
189                    sender_id: None,
190                    principal_id: None,
191                }
192            })
193            .collect()
194    }
195
196    /// Compact a session file (remove duplicates/corruption). No-op by default.
197    fn compact(&self, _session_key: &str) -> std::io::Result<()> {
198        Ok(())
199    }
200
201    /// Remove sessions that haven't been active within the given TTL hours.
202    fn cleanup_stale(&self, _ttl_hours: u32) -> std::io::Result<usize> {
203        Ok(0)
204    }
205
206    /// Search sessions by keyword. Default returns empty (backends with FTS override).
207    fn search(&self, _query: &SessionQuery) -> Vec<SessionMetadata> {
208        Vec::new()
209    }
210
211    fn clear_messages(&self, session_key: &str) -> std::io::Result<usize> {
212        let mut count = 0;
213        while self.remove_last(session_key)? {
214            count += 1;
215        }
216        Ok(count)
217    }
218
219    /// Delete all messages for a session. Returns `true` if the session existed.
220    fn delete_session(&self, _session_key: &str) -> std::io::Result<bool> {
221        Ok(false)
222    }
223
224    fn clear_agent_attribution(&self, _agent_alias: &str) -> std::io::Result<usize> {
225        Ok(0)
226    }
227
228    fn rename_agent_attribution(&self, _from: &str, _to: &str) -> std::io::Result<usize> {
229        Ok(0)
230    }
231
232    fn count_agent_attribution(&self, _agent_alias: &str) -> std::io::Result<usize> {
233        Ok(0)
234    }
235
236    fn session_exists(&self, session_key: &str) -> bool {
237        self.get_session_metadata(session_key).is_some()
238    }
239
240    /// Set or update the human-readable name for a session.
241    fn set_session_name(&self, _session_key: &str, _name: &str) -> std::io::Result<()> {
242        Ok(())
243    }
244
245    /// Get the human-readable name for a session (if set).
246    fn get_session_name(&self, _session_key: &str) -> std::io::Result<Option<String>> {
247        Ok(None)
248    }
249
250    /// Record the agent alias that owns a session. Called on WebSocket
251    /// handshake when the alias is known. No-op for backends that don't
252    /// track per-agent attribution.
253    fn set_session_agent_alias(
254        &self,
255        _session_key: &str,
256        _agent_alias: &str,
257    ) -> std::io::Result<()> {
258        Ok(())
259    }
260
261    /// Get the agent alias associated with a session, if recorded.
262    fn get_session_agent_alias(&self, _session_key: &str) -> std::io::Result<Option<String>> {
263        Ok(None)
264    }
265
266    /// Record whether this session's persisted transcript currently starts
267    /// with the synthetic trim breadcrumb, as one canonical fact alongside
268    /// the transcript itself. Callers must not infer this from message text
269    /// on restore: a genuine first user turn that happens to equal the
270    /// localized breadcrumb string must keep its turn-boundary role, and a
271    /// crumb written under another locale must stay classified as synthetic.
272    /// No-op for backends that don't track it (the caller falls back to
273    /// treating the session as having no breadcrumb).
274    fn set_session_trim_breadcrumb(
275        &self,
276        _session_key: &str,
277        _present: bool,
278    ) -> std::io::Result<()> {
279        Ok(())
280    }
281
282    /// Get the recorded breadcrumb provenance for a session. `None` means
283    /// the backend doesn't track it or no session-level state exists yet
284    /// (callers should assume `false`, not attempt text inference).
285    fn get_session_trim_breadcrumb(&self, _session_key: &str) -> std::io::Result<Option<bool>> {
286        Ok(None)
287    }
288
289    /// Replace a session's durable transcript and its breadcrumb provenance
290    /// with the agent's authoritative post-turn state. Callers that own a
291    /// trimmed in-memory history must use this instead of appending only the
292    /// turn's delta. The default implementation performs two separate writes
293    /// (`rewrite_messages` then `set_session_trim_breadcrumb`); they are
294    /// serialized under the caller's lock (channel per-sender lock or the
295    /// store's mutation guard) so concurrent turns cannot interleave, but the
296    /// pair is not crash-atomic. A crash between the two writes can leave
297    /// transcript and flag temporarily out of sync, recoverable on the next
298    /// trim. Both built-in backends override this with a stronger guarantee:
299    /// `SqliteSessionBackend` makes the pair atomic inside one transaction,
300    /// and the JSONL `SessionStore` rolls the transcript back to its
301    /// pre-replace content when the breadcrumb write fails, so an in-process
302    /// failure converges back to the last known-good pair instead of leaving
303    /// a new transcript paired with a stale flag (a process crash between the
304    /// two file writes can still split them, since JSONL uses two files).
305    /// Backends that can provide a transaction should follow SQLite's
306    /// example; ones limited to independent writes should follow JSONL's.
307    fn replace_conversation_state(
308        &self,
309        session_key: &str,
310        messages: &[ChatMessage],
311        breadcrumb_present: bool,
312    ) -> std::io::Result<()> {
313        self.rewrite_messages(session_key, messages)?;
314        self.set_session_trim_breadcrumb(session_key, breadcrumb_present)
315    }
316
317    /// Replace the durable transcript and breadcrumb flag only if the session
318    /// still exists. Returns `Ok(true)` when the replacement was written and
319    /// `Ok(false)` when the session was already deleted, in which case
320    /// nothing is written and, in particular, no fresh session row or file is
321    /// recreated for the deleted key.
322    ///
323    /// Post-turn persistence must use this instead of
324    /// [`replace_conversation_state`](Self::replace_conversation_state):
325    /// a bare existence check followed by a separate replace is a
326    /// check-then-act race — a `DELETE /api/sessions/{id}` (or any other
327    /// deleter) committing between the two recreates durable state the user
328    /// just wiped, since both the SQLite breadcrumb upsert and the JSONL
329    /// rewrite recreate their rows/files unconditionally.
330    ///
331    /// The default implementation is check-then-act and therefore only
332    /// best-effort. Backends with a serializing primitive close the race by
333    /// overriding it: `SqliteSessionBackend` performs the existence check and
334    /// both writes inside one transaction under the connection mutex (which
335    /// the deleter also holds), and the JSONL `SessionStore` holds its
336    /// mutation guard across the existence probe and both file writes (which
337    /// its deleter also holds).
338    fn replace_conversation_state_if_exists(
339        &self,
340        session_key: &str,
341        messages: &[ChatMessage],
342        breadcrumb_present: bool,
343    ) -> std::io::Result<bool> {
344        if !self.session_exists(session_key) {
345            return Ok(false);
346        }
347        self.replace_conversation_state(session_key, messages, breadcrumb_present)?;
348        Ok(true)
349    }
350
351    fn set_session_context(
352        &self,
353        _session_key: &str,
354        _context: SessionContext<'_>,
355    ) -> std::io::Result<()> {
356        Ok(())
357    }
358
359    fn get_session_metadata(&self, session_key: &str) -> Option<SessionMetadata> {
360        let messages = self.load(session_key);
361        if messages.is_empty() {
362            return None;
363        }
364        Some(SessionMetadata {
365            key: session_key.to_string(),
366            name: self.get_session_name(session_key).ok().flatten(),
367            created_at: Utc::now(),
368            last_activity: Utc::now(),
369            message_count: messages.len(),
370            agent_alias: None,
371            channel_id: None,
372            room_id: None,
373            sender_id: None,
374            principal_id: None,
375        })
376    }
377
378    /// Record the authenticated principal that owns a session (RFC 7141).
379    /// Called at `session/new` when the dispatcher has a real (non-sentinel)
380    /// principal bound. No-op for backends without the column.
381    fn set_session_principal(
382        &self,
383        _session_key: &str,
384        _principal_id: &str,
385    ) -> std::io::Result<()> {
386        // Fail closed: a backend that cannot record the owner cannot isolate
387        // the session after a restart. Callers that need isolation treat this
388        // as a failed creation rather than a silently ownerless row.
389        Err(std::io::Error::new(
390            std::io::ErrorKind::Unsupported,
391            "this session backend does not record an owning principal",
392        ))
393    }
394
395    /// Invoke a disclosure effect under the backend's owner-read boundary.
396    fn with_session_owner(
397        &self,
398        _session_key: &str,
399        _effect: &mut dyn FnMut(Option<&str>) -> std::io::Result<()>,
400    ) -> std::io::Result<()> {
401        Err(std::io::Error::new(
402            std::io::ErrorKind::Unsupported,
403            "guarded session disclosure unsupported",
404        ))
405    }
406
407    /// Authorize after the backend serialization boundary, retaining the guard
408    /// through the exact-owner mutation. Unsupported stores fail closed.
409    fn delete_session_authorized(
410        &self,
411        _session_key: &str,
412        _expected_owner: Option<&str>,
413        _authorize: &SessionEffectAuthorization<'_>,
414    ) -> std::io::Result<bool> {
415        Err(std::io::Error::new(
416            std::io::ErrorKind::Unsupported,
417            "guarded session deletion unsupported",
418        ))
419    }
420
421    /// Backends with no state effect still admit against the caller's locked
422    /// live incarnation. Persisting implementations must resolve the stored owner
423    /// under their mutation lock instead of trusting this fallback identity.
424    fn set_session_state_authorized(
425        &self,
426        _session_key: &str,
427        _state: &str,
428        _turn_id: Option<&str>,
429        live_owner: Option<&str>,
430        authorize: &SessionEffectAuthorization<'_>,
431    ) -> std::io::Result<()> {
432        let _authority = authorize(live_owner)?;
433        Ok(())
434    }
435
436    /// Delete only a matching stored owner. Backends without ownership support
437    /// fail closed without deleting anything.
438    fn delete_session_owned(
439        &self,
440        _session_key: &str,
441        _owner_principal_id: &str,
442    ) -> std::io::Result<bool> {
443        Ok(false)
444    }
445
446    /// Set the session state (e.g. "idle", "running", "error").
447    /// `turn_id` identifies the current turn (set when running, cleared on idle).
448    fn set_session_state(
449        &self,
450        _session_key: &str,
451        _state: &str,
452        _turn_id: Option<&str>,
453    ) -> std::io::Result<()> {
454        Ok(())
455    }
456
457    /// Get the current session state. Returns `None` if the backend doesn't track state.
458    fn get_session_state(&self, _session_key: &str) -> std::io::Result<Option<SessionState>> {
459        Ok(None)
460    }
461
462    /// List sessions currently in "running" state.
463    fn list_running_sessions(&self) -> Vec<SessionMetadata> {
464        Vec::new()
465    }
466
467    /// List sessions stuck in "running" state longer than `threshold_secs`.
468    fn list_stuck_sessions(&self, _threshold_secs: u64) -> Vec<SessionMetadata> {
469        Vec::new()
470    }
471}
472
473/// Session state information.
474#[derive(Debug, Clone)]
475pub struct SessionState {
476    /// Current state: "idle", "running", or "error".
477    pub state: String,
478    /// Turn ID of the active or last turn.
479    pub turn_id: Option<String>,
480    /// When the current state was entered.
481    pub turn_started_at: Option<DateTime<Utc>>,
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn session_metadata_is_constructible() {
490        let meta = SessionMetadata {
491            key: "test".into(),
492            name: None,
493            created_at: Utc::now(),
494            last_activity: Utc::now(),
495            message_count: 5,
496            agent_alias: None,
497            channel_id: None,
498            room_id: None,
499            sender_id: None,
500            principal_id: None,
501        };
502        assert_eq!(meta.key, "test");
503        assert_eq!(meta.message_count, 5);
504    }
505
506    #[test]
507    fn session_query_defaults() {
508        let q = SessionQuery::default();
509        assert!(q.keyword.is_none());
510        assert!(q.limit.is_none());
511    }
512}