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//! Implement `SessionStore` trait for custom backends (Redis, PostgreSQL, etc.):
16//!
17//! ```ignore
18//! use a3s_code::store::{
19//!     SessionData, SessionSnapshotV1, SessionStore, SessionStoreCapabilities,
20//! };
21//!
22//! struct RedisStore { /* ... */ }
23//!
24//! #[async_trait::async_trait]
25//! impl SessionStore for RedisStore {
26//!     // Required by AgentSession::save: commit the entire value in one
27//!     // backend transaction / atomic replacement.
28//!     async fn save_snapshot(&self, snapshot: &SessionSnapshotV1) -> Result<()> { /* ... */ }
29//!     async fn load_snapshot(&self, id: &str) -> Result<Option<SessionSnapshotV1>> { /* ... */ }
30//!     fn capabilities(&self) -> SessionStoreCapabilities {
31//!         SessionStoreCapabilities { atomic_session_snapshots: true }
32//!     }
33//!
34//!     // Legacy fragment APIs remain available for migration compatibility.
35//!     async fn save(&self, session: &SessionData) -> Result<()> { /* ... */ }
36//!     async fn load(&self, id: &str) -> Result<Option<SessionData>> { /* ... */ }
37//!     async fn delete(&self, id: &str) -> Result<()> { /* ... */ }
38//!     async fn list(&self) -> Result<Vec<String>> { /* ... */ }
39//!     async fn exists(&self, id: &str) -> Result<bool> { /* ... */ }
40//! }
41//! ```
42
43mod file_store;
44mod memory_store;
45mod session_data;
46mod session_snapshot;
47
48#[cfg(test)]
49mod tests;
50
51pub use file_store::FileSessionStore;
52pub use memory_store::MemorySessionStore;
53pub use session_data::{
54    ContextUsage, LlmConfigData, SessionConfig, SessionData, SessionState,
55    DEFAULT_AUTO_COMPACT_THRESHOLD,
56};
57pub use session_snapshot::{SessionSnapshotV1, SESSION_SNAPSHOT_SCHEMA_VERSION};
58
59use crate::loop_checkpoint::LoopCheckpoint;
60use crate::run::RunRecord;
61use crate::subagent_task_tracker::SubagentTaskSnapshot;
62use crate::tools::ArtifactStore;
63use crate::trace::TraceEvent;
64use crate::verification::VerificationReport;
65use anyhow::{bail, Result};
66
67/// Persistence guarantees advertised by a session store implementation.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub struct SessionStoreCapabilities {
70    /// A complete [`SessionSnapshotV1`] is committed as one atomic generation.
71    pub atomic_session_snapshots: bool,
72}
73
74// ============================================================================
75// Session Store Trait
76// ============================================================================
77
78/// Session storage trait
79#[async_trait::async_trait]
80pub trait SessionStore: Send + Sync {
81    /// Save session data
82    async fn save(&self, session: &SessionData) -> Result<()>;
83
84    /// Load session data by ID
85    async fn load(&self, id: &str) -> Result<Option<SessionData>>;
86
87    /// Delete session data
88    async fn delete(&self, id: &str) -> Result<()>;
89
90    /// List all session IDs
91    async fn list(&self) -> Result<Vec<String>>;
92
93    /// Check if session exists
94    async fn exists(&self, id: &str) -> Result<bool>;
95
96    /// Save a complete session generation.
97    ///
98    /// There is deliberately no fragmented default write. Silently mapping an
99    /// aggregate save to several independent writes would acknowledge a
100    /// generation that readers can observe only partially. Backends must make
101    /// their write semantics explicit by overriding this method.
102    async fn save_snapshot(&self, _snapshot: &SessionSnapshotV1) -> Result<()> {
103        bail!(
104            "session store '{}' does not support aggregate session snapshots",
105            self.backend_name()
106        )
107    }
108
109    /// Load one complete session generation.
110    ///
111    /// Legacy backends are assembled through the fragment APIs. This path is
112    /// best-effort and may observe concurrent fragment updates; callers can
113    /// inspect [`Self::capabilities`] before relying on atomicity.
114    async fn load_snapshot(&self, id: &str) -> Result<Option<SessionSnapshotV1>> {
115        let Some(session) = self.load(id).await? else {
116            return Ok(None);
117        };
118        let artifacts = self.load_artifacts(id).await?.unwrap_or_default();
119        Ok(Some(SessionSnapshotV1::new(
120            session,
121            &artifacts,
122            self.load_trace_events(id).await?.unwrap_or_default(),
123            self.load_run_records(id).await?.unwrap_or_default(),
124            self.load_verification_reports(id)
125                .await?
126                .unwrap_or_default(),
127            self.load_subagent_tasks(id).await?.unwrap_or_default(),
128        )))
129    }
130
131    /// Report persistence guarantees without requiring a write probe.
132    fn capabilities(&self) -> SessionStoreCapabilities {
133        SessionStoreCapabilities::default()
134    }
135
136    /// Save artifacts associated with a session.
137    async fn save_artifacts(&self, _id: &str, artifacts: &ArtifactStore) -> Result<()> {
138        if !artifacts.is_empty() {
139            bail!(
140                "session store '{}' does not support artifacts",
141                self.backend_name()
142            );
143        }
144        Ok(())
145    }
146
147    /// Load artifacts associated with a session.
148    async fn load_artifacts(&self, _id: &str) -> Result<Option<ArtifactStore>> {
149        Ok(None)
150    }
151
152    /// Save compact trace events associated with a session.
153    async fn save_trace_events(&self, _id: &str, events: &[TraceEvent]) -> Result<()> {
154        if !events.is_empty() {
155            bail!(
156                "session store '{}' does not support trace events",
157                self.backend_name()
158            );
159        }
160        Ok(())
161    }
162
163    /// Load compact trace events associated with a session.
164    async fn load_trace_events(&self, _id: &str) -> Result<Option<Vec<TraceEvent>>> {
165        Ok(None)
166    }
167
168    /// Save run snapshots and replayable runtime events associated with a session.
169    async fn save_run_records(&self, _id: &str, records: &[RunRecord]) -> Result<()> {
170        if !records.is_empty() {
171            bail!(
172                "session store '{}' does not support run records",
173                self.backend_name()
174            );
175        }
176        Ok(())
177    }
178
179    /// Load run snapshots and replayable runtime events associated with a session.
180    async fn load_run_records(&self, _id: &str) -> Result<Option<Vec<RunRecord>>> {
181        Ok(None)
182    }
183
184    /// Save structured verification reports associated with a session.
185    async fn save_verification_reports(
186        &self,
187        _id: &str,
188        reports: &[VerificationReport],
189    ) -> Result<()> {
190        if !reports.is_empty() {
191            bail!(
192                "session store '{}' does not support verification reports",
193                self.backend_name()
194            );
195        }
196        Ok(())
197    }
198
199    /// Load structured verification reports associated with a session.
200    async fn load_verification_reports(
201        &self,
202        _id: &str,
203    ) -> Result<Option<Vec<VerificationReport>>> {
204        Ok(None)
205    }
206
207    /// Save the session's delegated subagent task tracker snapshots.
208    ///
209    /// Cluster-grade hosts need this so a migrated session keeps a
210    /// queryable history of its delegated child runs. Cancellers are
211    /// **not** persisted — they are runtime-only and re-attaching them
212    /// is the executor's job at task respawn time.
213    async fn save_subagent_tasks(&self, _id: &str, tasks: &[SubagentTaskSnapshot]) -> Result<()> {
214        if !tasks.is_empty() {
215            bail!(
216                "session store '{}' does not support subagent tasks",
217                self.backend_name()
218            );
219        }
220        Ok(())
221    }
222
223    /// Load the session's delegated subagent task tracker snapshots.
224    async fn load_subagent_tasks(&self, _id: &str) -> Result<Option<Vec<SubagentTaskSnapshot>>> {
225        Ok(None)
226    }
227
228    /// Save the latest per-tool-round loop checkpoint for `run_id`.
229    ///
230    /// The agent loop calls this through the
231    /// [`SessionStoreCheckpointSink`](crate::loop_checkpoint::SessionStoreCheckpointSink)
232    /// adapter after each completed tool round. Implementations should
233    /// **overwrite** any earlier checkpoint for the same `run_id` — the
234    /// loop only ever needs the most recent boundary.
235    async fn save_loop_checkpoint(
236        &self,
237        _run_id: &str,
238        _checkpoint: &LoopCheckpoint,
239    ) -> Result<()> {
240        Ok(())
241    }
242
243    /// Load the latest loop checkpoint for `run_id`.
244    async fn load_loop_checkpoint(&self, _run_id: &str) -> Result<Option<LoopCheckpoint>> {
245        Ok(None)
246    }
247
248    /// Delete the loop checkpoint for `run_id`, if present.
249    ///
250    /// Called by the run lifecycle when a run reaches a terminal state
251    /// **in-process** (completed, failed, or cancelled) — at that point
252    /// the checkpoint is dead weight. Only a process crash (the agent
253    /// loop never returns) should leave a checkpoint behind for
254    /// crash-recovery resume. Without this, every tool-using run would
255    /// leak a checkpoint forever — the dominant unbounded-growth source
256    /// for long-running cluster deployments.
257    ///
258    /// Deleting a non-existent checkpoint is a no-op success.
259    async fn delete_loop_checkpoint(&self, _run_id: &str) -> Result<()> {
260        Ok(())
261    }
262
263    /// Persist a workflow checkpoint, overwriting any earlier one for the same
264    /// `workflow_id`. The resumable orchestration combinators call this at each
265    /// step boundary so an interrupted workflow resumes from the last
266    /// completed step (here or, after migration, on another node).
267    async fn save_workflow_checkpoint(
268        &self,
269        _workflow_id: &str,
270        _checkpoint: &crate::orchestration::WorkflowCheckpoint,
271    ) -> Result<()> {
272        Ok(())
273    }
274
275    /// Load the latest workflow checkpoint for `workflow_id`.
276    async fn load_workflow_checkpoint(
277        &self,
278        _workflow_id: &str,
279    ) -> Result<Option<crate::orchestration::WorkflowCheckpoint>> {
280        Ok(None)
281    }
282
283    /// Delete the workflow checkpoint for `workflow_id`, if present. Called
284    /// when a workflow reaches a terminal state in-process; only a crash should
285    /// leave one behind for resume. Deleting a non-existent checkpoint is a
286    /// no-op success.
287    async fn delete_workflow_checkpoint(&self, _workflow_id: &str) -> Result<()> {
288        Ok(())
289    }
290
291    /// Health check — verify the store backend is reachable and operational
292    async fn health_check(&self) -> Result<()> {
293        Ok(())
294    }
295
296    /// Backend name for diagnostics
297    fn backend_name(&self) -> &str {
298        "unknown"
299    }
300}