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}