Skip to main content

behest_runtime/
snapshot.rs

1//! Agent execution snapshotting and recovery.
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7use tokio::fs;
8use uuid::Uuid;
9
10use super::error::{RuntimeError, RuntimeResult};
11use super::run::{RunId, RunRequest, RunStatus};
12use super::turn::TurnState;
13use behest_provider::{FinishReason, Message, TokenUsage};
14
15/// A serialized execution snapshot of an active agent run.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Snapshot {
18    /// Unique identifier for the run.
19    pub run_id: RunId,
20    /// Session ID associated with the run.
21    pub session_id: Uuid,
22    /// Current run status.
23    pub status: RunStatus,
24    /// Current iteration count.
25    pub iteration: usize,
26    /// Current turn state within the loop.
27    pub current_state: TurnState,
28    /// Accumulated token usage so far.
29    pub total_usage: TokenUsage,
30    /// Last finish reason returned by the model, if any.
31    pub last_finish: Option<FinishReason>,
32    /// Optional last committed assistant message.
33    pub assistant_message: Option<Message>,
34    /// Optional ID of the last committed assistant message in the store.
35    pub assistant_msg_id: Option<Uuid>,
36    /// The original run request.
37    pub request: RunRequest,
38    /// Number of output recovery attempts made so far for truncated responses.
39    #[serde(default)]
40    pub output_recovery_count: u32,
41    /// Timestamp when this snapshot was captured.
42    pub timestamp: DateTime<Utc>,
43}
44
45/// Trait for snapshot persistence backends.
46#[async_trait]
47pub trait SnapshotStore: Send + Sync + 'static {
48    /// Saves a snapshot.
49    ///
50    /// # Errors
51    ///
52    /// Returns `RuntimeError` if serialization or disk I/O fails.
53    async fn save(&self, snapshot: &Snapshot) -> RuntimeResult<()>;
54
55    /// Loads a snapshot by run ID.
56    ///
57    /// # Errors
58    ///
59    /// Returns `RuntimeError` if disk I/O or deserialization fails.
60    async fn load(&self, run_id: RunId) -> RuntimeResult<Option<Snapshot>>;
61
62    /// Deletes a snapshot by run ID.
63    ///
64    /// # Errors
65    ///
66    /// Returns `RuntimeError` if I/O fails.
67    async fn delete(&self, run_id: RunId) -> RuntimeResult<()>;
68
69    /// Lists all active snapshots.
70    ///
71    /// # Errors
72    ///
73    /// Returns `RuntimeError` if directory reading fails.
74    async fn list(&self) -> RuntimeResult<Vec<Snapshot>>;
75}
76
77/// Filesystem-based [`SnapshotStore`] with atomic write semantics.
78///
79/// Snapshots are serialized as JSON files. Each write first goes to a `.tmp`
80/// file and is then atomically renamed to the final path, preventing partial
81/// snapshots from being visible to concurrent readers.
82pub struct FileSnapshotStore {
83    base_dir: PathBuf,
84}
85
86impl FileSnapshotStore {
87    /// Creates a new filesystem-backed snapshot store in the given directory.
88    #[must_use]
89    pub fn new(base_dir: PathBuf) -> Self {
90        Self { base_dir }
91    }
92
93    fn path_for(&self, run_id: RunId) -> PathBuf {
94        self.base_dir.join(format!("snapshot_{run_id}.json"))
95    }
96}
97
98#[async_trait]
99impl SnapshotStore for FileSnapshotStore {
100    async fn save(&self, snapshot: &Snapshot) -> RuntimeResult<()> {
101        if let Err(e) = fs::create_dir_all(&self.base_dir).await {
102            return Err(RuntimeError::RecoveryFailed(format!(
103                "failed to create snapshot dir: {e}"
104            )));
105        }
106
107        let serialized = serde_json::to_string_pretty(snapshot).map_err(|e| {
108            RuntimeError::RecoveryFailed(format!("failed to serialize snapshot: {e}"))
109        })?;
110
111        let temp_path = self
112            .base_dir
113            .join(format!("snapshot_{}.json.tmp", snapshot.run_id));
114        if let Err(e) = fs::write(&temp_path, serialized).await {
115            return Err(RuntimeError::RecoveryFailed(format!(
116                "failed to write snapshot: {e}"
117            )));
118        }
119
120        let final_path = self.path_for(snapshot.run_id);
121        fs::rename(temp_path, final_path).await.map_err(|e| {
122            RuntimeError::RecoveryFailed(format!("failed to finalize snapshot: {e}"))
123        })?;
124
125        Ok(())
126    }
127
128    async fn load(&self, run_id: RunId) -> RuntimeResult<Option<Snapshot>> {
129        let path = self.path_for(run_id);
130        if !path.exists() {
131            return Ok(None);
132        }
133
134        let content = fs::read_to_string(&path).await.map_err(|e| {
135            RuntimeError::RecoveryFailed(format!("failed to read snapshot file: {e}"))
136        })?;
137
138        let snapshot: Snapshot = serde_json::from_str(&content).map_err(|e| {
139            RuntimeError::RecoveryFailed(format!("failed to deserialize snapshot file: {e}"))
140        })?;
141
142        Ok(Some(snapshot))
143    }
144
145    async fn delete(&self, run_id: RunId) -> RuntimeResult<()> {
146        let path = self.path_for(run_id);
147        if path.exists() {
148            fs::remove_file(path).await.map_err(|e| {
149                RuntimeError::RecoveryFailed(format!("failed to delete snapshot file: {e}"))
150            })?;
151        }
152        Ok(())
153    }
154
155    async fn list(&self) -> RuntimeResult<Vec<Snapshot>> {
156        if !self.base_dir.exists() {
157            return Ok(Vec::new());
158        }
159
160        let mut snapshots = Vec::new();
161        let mut entries = fs::read_dir(&self.base_dir).await.map_err(|e| {
162            RuntimeError::RecoveryFailed(format!("failed to read snapshot dir: {e}"))
163        })?;
164
165        while let Some(entry) = entries.next_entry().await.map_err(|e| {
166            RuntimeError::RecoveryFailed(format!("failed to read snapshot directory entry: {e}"))
167        })? {
168            let path = entry.path();
169            if path.is_file()
170                && let Some(ext) = path.extension()
171                && ext == "json"
172                && let Ok(content) = fs::read_to_string(&path).await
173                && let Ok(snapshot) = serde_json::from_str::<Snapshot>(&content)
174            {
175                snapshots.push(snapshot);
176            }
177        }
178
179        Ok(snapshots)
180    }
181}