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;