Skip to main content

swink_agent_memory/
store.rs

1//! Session storage trait for pluggable persistence backends.
2
3use std::io;
4use std::sync::Once;
5
6use swink_agent::{AgentMessage, CustomMessageRegistry};
7
8use crate::entry::SessionEntry;
9use crate::interrupt::InterruptState;
10use crate::load_options::LoadOptions;
11use crate::meta::SessionMeta;
12use crate::search::{SessionHit, SessionSearchOptions};
13
14/// Pluggable session persistence.
15///
16/// All save/load methods use [`AgentMessage`] as the canonical message type,
17/// preserving both LLM and custom messages without silent data loss.
18///
19/// The default implementation ([`JsonlSessionStore`](crate::JsonlSessionStore))
20/// uses JSONL files, but alternative backends (`SQLite`, S3, etc.) can
21/// implement this trait directly.
22pub trait SessionStore: Send + Sync {
23    /// Persist a session, including both LLM and custom messages.
24    ///
25    /// Overwrites any existing session with the same ID. Custom messages that
26    /// cannot be serialized are logged and skipped — callers should ensure
27    /// custom types implement `CustomMessage::to_json` and
28    /// `CustomMessage::type_name`.
29    fn save(&self, id: &str, meta: &SessionMeta, messages: &[AgentMessage]) -> io::Result<()>;
30
31    /// Persist a session transcript plus its state snapshot.
32    ///
33    /// Stores with optimistic-concurrency metadata should return the metadata
34    /// as persisted on disk so callers can keep their local sequence in sync.
35    /// Backends must implement this explicitly if they support atomic
36    /// transcript+state persistence; the default returns
37    /// [`io::ErrorKind::Unsupported`] so callers do not accidentally rely on a
38    /// non-atomic fallback.
39    fn save_full(
40        &self,
41        id: &str,
42        meta: &SessionMeta,
43        messages: &[AgentMessage],
44        state: &serde_json::Value,
45    ) -> io::Result<SessionMeta> {
46        let _ = (id, meta, messages, state);
47        Err(io::Error::new(
48            io::ErrorKind::Unsupported,
49            "SessionStore::save_full requires an explicit atomic backend implementation",
50        ))
51    }
52
53    /// Append messages to an existing session without rewriting the entire file.
54    fn append(&self, id: &str, messages: &[AgentMessage]) -> io::Result<()>;
55
56    /// Load a session by ID.
57    ///
58    /// If `registry` is `Some`, custom messages are deserialized using the
59    /// provided registry. If `None`, custom messages are skipped.
60    fn load(
61        &self,
62        id: &str,
63        registry: Option<&CustomMessageRegistry>,
64    ) -> io::Result<(SessionMeta, Vec<AgentMessage>)>;
65
66    /// Load a session transcript plus its state snapshot from one consistent
67    /// read boundary.
68    ///
69    /// Backends must implement this explicitly if they support atomic
70    /// transcript+state restore; the default returns
71    /// [`io::ErrorKind::Unsupported`] so callers do not silently mix
72    /// transcript and state revisions via separate reads.
73    fn load_full(
74        &self,
75        id: &str,
76        registry: Option<&CustomMessageRegistry>,
77    ) -> io::Result<(SessionMeta, Vec<AgentMessage>, Option<serde_json::Value>)> {
78        let _ = (id, registry);
79        Err(io::Error::new(
80            io::ErrorKind::Unsupported,
81            "SessionStore::load_full requires an explicit atomic backend implementation",
82        ))
83    }
84
85    /// List all saved sessions, sorted by last updated (newest first).
86    fn list(&self) -> io::Result<Vec<SessionMeta>>;
87
88    /// Delete a session by ID.
89    fn delete(&self, id: &str) -> io::Result<()>;
90
91    /// Save session state snapshot.
92    ///
93    /// The default implementation is a no-op that **discards the state**:
94    /// the snapshot is dropped and a later [`load_state`](Self::load_state)
95    /// returns `None`. It exists only for backward compatibility with
96    /// pre-034 store implementations (spec 034 FR-018). The first time the
97    /// default runs in a process it emits a `tracing::warn!` so the data
98    /// loss is visible; subsequent calls are silent.
99    ///
100    /// **Planned breaking change:** `save_state` will become a required
101    /// (non-defaulted) trait method in the next major version bump. Custom
102    /// `SessionStore` implementations should override it (together with
103    /// [`load_state`](Self::load_state)) now.
104    fn save_state(&self, id: &str, state: &serde_json::Value) -> io::Result<()> {
105        static WARN_ONCE: Once = Once::new();
106        WARN_ONCE.call_once(|| {
107            tracing::warn!(
108                session_id = %id,
109                "SessionStore::save_state default no-op invoked: this store does not \
110                 implement state persistence, so session state will be lost. Override \
111                 save_state and load_state; they become required methods in the next \
112                 major version. This warning is emitted once per process."
113            );
114        });
115        let _ = state;
116        Ok(())
117    }
118
119    /// Load session state snapshot.
120    ///
121    /// The default implementation always returns `Ok(None)` (empty state),
122    /// even if a matching [`save_state`](Self::save_state) call appeared to
123    /// succeed. It exists only for backward compatibility with pre-034 store
124    /// implementations (spec 034 FR-018). The first time the default runs in
125    /// a process it emits a `tracing::warn!` so the silent fallback is
126    /// visible; subsequent calls are silent.
127    ///
128    /// **Planned breaking change:** `load_state` will become a required
129    /// (non-defaulted) trait method in the next major version bump. Custom
130    /// `SessionStore` implementations should override it (together with
131    /// [`save_state`](Self::save_state)) now.
132    fn load_state(&self, id: &str) -> io::Result<Option<serde_json::Value>> {
133        static WARN_ONCE: Once = Once::new();
134        WARN_ONCE.call_once(|| {
135            tracing::warn!(
136                session_id = %id,
137                "SessionStore::load_state default no-op invoked: this store does not \
138                 implement state persistence, so loads always return empty state. \
139                 Override save_state and load_state; they become required methods in \
140                 the next major version. This warning is emitted once per process."
141            );
142        });
143        Ok(None)
144    }
145
146    /// Persist interrupt state for a session.
147    ///
148    /// Stores the interrupt as `{session_id}.interrupt.json`. Overwrites any
149    /// existing interrupt for the same session. Default: no-op.
150    fn save_interrupt(&self, id: &str, state: &InterruptState) -> io::Result<()> {
151        let _ = (id, state);
152        Ok(())
153    }
154
155    /// Load interrupt state for a session.
156    ///
157    /// Returns `Some` if an interrupt file exists, `None` otherwise.
158    /// Returns an error if the file exists but is corrupted. Default: `None`.
159    fn load_interrupt(&self, id: &str) -> io::Result<Option<InterruptState>> {
160        let _ = id;
161        Ok(None)
162    }
163
164    /// Clear interrupt state for a session.
165    ///
166    /// Deletes the `{session_id}.interrupt.json` file. Idempotent — safe to
167    /// call if no interrupt exists. Default: no-op.
168    fn clear_interrupt(&self, id: &str) -> io::Result<()> {
169        let _ = id;
170        Ok(())
171    }
172
173    /// Load a session with filtering options.
174    ///
175    /// Returns metadata and a filtered subset of session entries based on the
176    /// provided [`LoadOptions`]. Default options return the full session.
177    fn load_with_options(
178        &self,
179        id: &str,
180        options: &LoadOptions,
181    ) -> io::Result<(SessionMeta, Vec<SessionEntry>)>;
182
183    /// Search across persisted sessions.
184    ///
185    /// Backends that do not support search can rely on the default empty
186    /// result for backward compatibility.
187    fn search(&self, query: &str, options: &SessionSearchOptions) -> io::Result<Vec<SessionHit>> {
188        let _ = (query, options);
189        Ok(Vec::new())
190    }
191}
192
193#[cfg(test)]
194#[path = "store_tests.rs"]
195mod tests;