Skip to main content

a3s_code_core/store/
mod.rs

1//! Session persistence layer
2//!
3//! Provides pluggable session storage via the `SessionStore` trait.
4//!
5//! ## Default Implementation
6//!
7//! `FileSessionStore` stores each session as a JSON file:
8//! - Session metadata (id, name, timestamps)
9//! - Configuration (system prompt, policies)
10//! - Conversation history (messages)
11//! - Context usage statistics
12//!
13//! ## Custom Backends
14//!
15//! Prefer snapshot APIs (`save_snapshot` / `load_snapshot`). Legacy fragment
16//! `save` / `load` of [`SessionData`] remain for migration compatibility only
17//! — new backends must not treat fragments as the primary authority.
18//!
19//! ```ignore
20//! use a3s_code::store::{
21//!     SessionData, SessionSnapshotV1, SessionStore, SessionStoreCapabilities,
22//! };
23//!
24//! struct RedisStore { /* ... */ }
25//!
26//! #[async_trait::async_trait]
27//! impl SessionStore for RedisStore {
28//!     // Required by AgentSession::save: commit the entire value in one
29//!     // backend transaction / atomic replacement.
30//!     async fn save_snapshot(&self, snapshot: &SessionSnapshotV1) -> Result<()> { /* ... */ }
31//!     async fn load_snapshot(&self, id: &str) -> Result<Option<SessionSnapshotV1>> { /* ... */ }
32//!     fn capabilities(&self) -> SessionStoreCapabilities {
33//!         SessionStoreCapabilities { atomic_session_snapshots: true }
34//!     }
35//!
36//!     // Legacy fragment APIs: migration compatibility only.
37//!     async fn save(&self, session: &SessionData) -> Result<()> { /* ... */ }
38//!     async fn load(&self, id: &str) -> Result<Option<SessionData>> { /* ... */ }
39//!     async fn delete(&self, id: &str) -> Result<()> { /* ... */ }
40//!     async fn list(&self) -> Result<Vec<String>> { /* ... */ }
41//!     async fn exists(&self, id: &str) -> Result<bool> { /* ... */ }
42//! }
43//! ```
44
45mod encryption;
46mod file_store;
47mod lease;
48mod memory_store;
49mod session_data;
50mod session_snapshot;
51mod wal;
52mod watch;
53
54#[cfg(test)]
55mod tests;
56
57#[cfg(test)]
58mod persistence_soak;
59
60pub use encryption::SessionStoreAtRestCipher;
61pub use file_store::FileSessionStore;
62pub use lease::{SessionStoreWriterLeaseV1, SESSION_STORE_WRITER_LEASE_SCHEMA_V1};
63pub use memory_store::MemorySessionStore;
64pub use session_data::{
65    ContextUsage, LlmConfigData, SessionConfig, SessionData, SessionState,
66    DEFAULT_AUTO_COMPACT_THRESHOLD,
67};
68pub use session_snapshot::{SessionSnapshotV1, SESSION_SNAPSHOT_SCHEMA_VERSION};
69pub use wal::{
70    snapshot_content_digest, FileSessionStoreWal, SessionStoreWalEntryV1, SessionStoreWalPhaseV1,
71    SESSION_STORE_WAL_ENTRY_SCHEMA_V1,
72};
73pub use watch::{
74    SessionStoreCommitEventV1, SessionStoreCommitWatch, SESSION_STORE_COMMIT_EVENT_SCHEMA_V1,
75};
76
77use crate::loop_checkpoint::LoopCheckpoint;
78use crate::run::RunRecord;
79use crate::subagent_task_tracker::SubagentTaskSnapshot;
80use crate::tools::ArtifactStore;
81use crate::trace::TraceEvent;
82use crate::verification::VerificationReport;
83use anyhow::{bail, Result};
84
85/// Persistence guarantees advertised by a session store implementation.
86///
87/// Hosts inspect these flags before relying on a durability semantics; a
88/// missing guarantee means the caller must arrange it above the store. The
89/// flags describe what an implementation already proves with its own tests,
90/// not aspirations (KRN-6).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub struct SessionStoreCapabilities {
93    /// A complete [`SessionSnapshotV1`] is committed as one atomic generation.
94    pub atomic_session_snapshots: bool,
95    /// Saving a generation whose identity already exists either replaces it
96    /// under one compare-and-swap decision or fails without a partial write;
97    /// concurrent writers never interleave generations.
98    pub aggregate_cas: bool,
99    /// Events are appended once with monotonically increasing sequence and
100    /// can be replayed after reopen; duplicates fail closed.
101    pub append_only_event_log: bool,
102    /// Cross-process writers are fenced by a lease so a stale process cannot
103    /// overwrite a newer generation after a takeover.
104    pub lease_fencing: bool,
105    /// Persisted bytes are encrypted at rest by the backend itself.
106    pub encrypted_at_rest: bool,
107    /// The store can notify watchers of committed generations.
108    pub watch: bool,
109    /// Artifact garbage collection is reference-aware: content reachable
110    /// from a retained provenance receipt, review finding, checkpoint, or
111    /// publication is never removed.
112    pub reference_aware_artifact_gc: bool,
113}
114
115// ============================================================================
116// Session Store Trait
117// ============================================================================
118
119/// Session storage trait
120#[async_trait::async_trait]
121pub trait SessionStore: Send + Sync {
122    /// Save session data
123    async fn save(&self, session: &SessionData) -> Result<()>;
124
125    /// Load session data by ID
126    async fn load(&self, id: &str) -> Result<Option<SessionData>>;
127
128    /// Delete session data
129    async fn delete(&self, id: &str) -> Result<()>;
130
131    /// List all session IDs
132    async fn list(&self) -> Result<Vec<String>>;
133
134    /// Check if session exists
135    async fn exists(&self, id: &str) -> Result<bool>;
136
137    /// Save a complete session generation.
138    ///
139    /// There is deliberately no fragmented default write. Silently mapping an
140    /// aggregate save to several independent writes would acknowledge a
141    /// generation that readers can observe only partially. Backends must make
142    /// their write semantics explicit by overriding this method.
143    async fn save_snapshot(&self, _snapshot: &SessionSnapshotV1) -> Result<()> {
144        bail!(
145            "session store '{}' does not support aggregate session snapshots",
146            self.backend_name()
147        )
148    }
149
150    /// Compare-and-swap save of one complete generation (STORE-CAS1).
151    ///
152    /// When `expected_current_digest` is `None`, the write is unconditional.
153    /// When `Some(digest)`, the currently durable snapshot for the same
154    /// session id must exist and match that digest; otherwise the method
155    /// returns `Ok(false)` without writing. Successful commits return
156    /// `Ok(true)`.
157    async fn save_snapshot_cas(
158        &self,
159        snapshot: &SessionSnapshotV1,
160        expected_current_digest: Option<&str>,
161    ) -> Result<bool> {
162        if expected_current_digest.is_some() {
163            bail!(
164                "session store '{}' does not support aggregate snapshot CAS",
165                self.backend_name()
166            );
167        }
168        self.save_snapshot(snapshot).await?;
169        Ok(true)
170    }
171
172    /// Acquire (or take over) the store-wide writer lease (STORE-LEASE1).
173    ///
174    /// Successful callers receive the new durable epoch. After a takeover,
175    /// any previously held epoch is stale and must fail closed on the next
176    /// fenced snapshot commit.
177    async fn acquire_writer_lease(&self, _holder_id: &str) -> Result<SessionStoreWriterLeaseV1> {
178        bail!(
179            "session store '{}' does not support writer lease fencing",
180            self.backend_name()
181        )
182    }
183
184    /// Read the currently durable writer lease, if any.
185    async fn writer_lease(&self) -> Result<Option<SessionStoreWriterLeaseV1>> {
186        Ok(None)
187    }
188
189    /// Subscribe to durable snapshot commit notifications (STORE-WATCH1).
190    ///
191    /// Events are published only after a complete generation is durable.
192    /// Lagged subscribers may skip intermediate commits.
193    async fn watch_commits(&self) -> Result<SessionStoreCommitWatch> {
194        bail!(
195            "session store '{}' does not support commit watch notifications",
196            self.backend_name()
197        )
198    }
199
200    /// Load one complete session generation.
201    ///
202    /// Legacy backends are assembled through the fragment APIs. This path is
203    /// best-effort and may observe concurrent fragment updates; callers can
204    /// inspect [`Self::capabilities`] before relying on atomicity.
205    async fn load_snapshot(&self, id: &str) -> Result<Option<SessionSnapshotV1>> {
206        let Some(session) = self.load(id).await? else {
207            return Ok(None);
208        };
209        let artifacts = self.load_artifacts(id).await?.unwrap_or_default();
210        Ok(Some(SessionSnapshotV1::new(
211            session,
212            &artifacts,
213            self.load_trace_events(id).await?.unwrap_or_default(),
214            self.load_run_records(id).await?.unwrap_or_default(),
215            self.load_verification_reports(id)
216                .await?
217                .unwrap_or_default(),
218            self.load_subagent_tasks(id).await?.unwrap_or_default(),
219        )))
220    }
221
222    /// Report persistence guarantees without requiring a write probe.
223    fn capabilities(&self) -> SessionStoreCapabilities {
224        SessionStoreCapabilities::default()
225    }
226
227    /// Save artifacts associated with a session.
228    async fn save_artifacts(&self, _id: &str, artifacts: &ArtifactStore) -> Result<()> {
229        if !artifacts.is_empty() {
230            bail!(
231                "session store '{}' does not support artifacts",
232                self.backend_name()
233            );
234        }
235        Ok(())
236    }
237
238    /// Load artifacts associated with a session.
239    async fn load_artifacts(&self, _id: &str) -> Result<Option<ArtifactStore>> {
240        Ok(None)
241    }
242
243    /// Save compact trace events associated with a session.
244    async fn save_trace_events(&self, _id: &str, events: &[TraceEvent]) -> Result<()> {
245        if !events.is_empty() {
246            bail!(
247                "session store '{}' does not support trace events",
248                self.backend_name()
249            );
250        }
251        Ok(())
252    }
253
254    /// Load compact trace events associated with a session.
255    async fn load_trace_events(&self, _id: &str) -> Result<Option<Vec<TraceEvent>>> {
256        Ok(None)
257    }
258
259    /// Save run snapshots and replayable runtime events associated with a session.
260    async fn save_run_records(&self, _id: &str, records: &[RunRecord]) -> Result<()> {
261        if !records.is_empty() {
262            bail!(
263                "session store '{}' does not support run records",
264                self.backend_name()
265            );
266        }
267        Ok(())
268    }
269
270    /// Load run snapshots and replayable runtime events associated with a session.
271    async fn load_run_records(&self, _id: &str) -> Result<Option<Vec<RunRecord>>> {
272        Ok(None)
273    }
274
275    /// Save structured verification reports associated with a session.
276    async fn save_verification_reports(
277        &self,
278        _id: &str,
279        reports: &[VerificationReport],
280    ) -> Result<()> {
281        if !reports.is_empty() {
282            bail!(
283                "session store '{}' does not support verification reports",
284                self.backend_name()
285            );
286        }
287        Ok(())
288    }
289
290    /// Load structured verification reports associated with a session.
291    async fn load_verification_reports(
292        &self,
293        _id: &str,
294    ) -> Result<Option<Vec<VerificationReport>>> {
295        Ok(None)
296    }
297
298    /// Save the session's delegated subagent task tracker snapshots.
299    ///
300    /// Cluster-grade hosts need this so a migrated session keeps a
301    /// queryable history of its delegated child runs. Cancellers are
302    /// **not** persisted — they are runtime-only and re-attaching them
303    /// is the executor's job at task respawn time.
304    async fn save_subagent_tasks(&self, _id: &str, tasks: &[SubagentTaskSnapshot]) -> Result<()> {
305        if !tasks.is_empty() {
306            bail!(
307                "session store '{}' does not support subagent tasks",
308                self.backend_name()
309            );
310        }
311        Ok(())
312    }
313
314    /// Load the session's delegated subagent task tracker snapshots.
315    async fn load_subagent_tasks(&self, _id: &str) -> Result<Option<Vec<SubagentTaskSnapshot>>> {
316        Ok(None)
317    }
318
319    /// Save the latest per-tool-round loop checkpoint for `run_id`.
320    ///
321    /// The agent loop calls this through the
322    /// [`SessionStoreCheckpointSink`](crate::loop_checkpoint::SessionStoreCheckpointSink)
323    /// adapter after each completed tool round. Implementations should
324    /// **overwrite** any earlier checkpoint for the same `run_id` — the
325    /// loop only ever needs the most recent boundary.
326    async fn save_loop_checkpoint(
327        &self,
328        _run_id: &str,
329        _checkpoint: &LoopCheckpoint,
330    ) -> Result<()> {
331        Ok(())
332    }
333
334    /// Load the latest loop checkpoint for `run_id`.
335    async fn load_loop_checkpoint(&self, _run_id: &str) -> Result<Option<LoopCheckpoint>> {
336        Ok(None)
337    }
338
339    /// Delete the loop checkpoint for `run_id`, if present.
340    ///
341    /// Called by the run lifecycle when a run reaches a terminal state
342    /// **in-process** (completed, failed, or cancelled) — at that point
343    /// the checkpoint is dead weight. Only a process crash (the agent
344    /// loop never returns) should leave a checkpoint behind for
345    /// crash-recovery resume. Without this, every tool-using run would
346    /// leak a checkpoint forever — the dominant unbounded-growth source
347    /// for long-running cluster deployments.
348    ///
349    /// Deleting a non-existent checkpoint is a no-op success.
350    async fn delete_loop_checkpoint(&self, _run_id: &str) -> Result<()> {
351        Ok(())
352    }
353
354    /// Persist a workflow checkpoint, overwriting any earlier one for the same
355    /// `workflow_id`. The resumable orchestration combinators call this at each
356    /// step boundary so an interrupted workflow resumes from the last
357    /// completed step (here or, after migration, on another node).
358    async fn save_workflow_checkpoint(
359        &self,
360        _workflow_id: &str,
361        _checkpoint: &crate::orchestration::WorkflowCheckpoint,
362    ) -> Result<()> {
363        Ok(())
364    }
365
366    /// Load the latest workflow checkpoint for `workflow_id`.
367    async fn load_workflow_checkpoint(
368        &self,
369        _workflow_id: &str,
370    ) -> Result<Option<crate::orchestration::WorkflowCheckpoint>> {
371        Ok(None)
372    }
373
374    /// Delete the workflow checkpoint for `workflow_id`, if present. Called
375    /// when a workflow reaches a terminal state in-process; only a crash should
376    /// leave one behind for resume. Deleting a non-existent checkpoint is a
377    /// no-op success.
378    async fn delete_workflow_checkpoint(&self, _workflow_id: &str) -> Result<()> {
379        Ok(())
380    }
381
382    /// Health check — verify the store backend is reachable and operational
383    async fn health_check(&self) -> Result<()> {
384        Ok(())
385    }
386
387    /// Backend name for diagnostics
388    fn backend_name(&self) -> &str {
389        "unknown"
390    }
391}