Skip to main content

dot_agent_core/history/
checkpoint.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use super::delta::{compute_delta, load_delta, save_delta_files, Delta};
8use crate::error::Result;
9
10/// Checkpoint metadata
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Checkpoint {
13    /// Unique checkpoint ID
14    pub id: String,
15    /// Associated operation ID
16    pub operation_id: String,
17    /// When the checkpoint was created
18    pub created_at: DateTime<Utc>,
19    /// Whether this is a full snapshot
20    pub is_full: bool,
21    /// Base checkpoint ID for incremental (None for full)
22    pub base_checkpoint: Option<String>,
23    /// File hashes at this checkpoint (path -> sha256)
24    pub file_hashes: HashMap<String, String>,
25    /// Total files count
26    pub file_count: usize,
27    /// Total size in bytes
28    pub total_size: u64,
29}
30
31impl Checkpoint {
32    pub fn new(id: String, operation_id: String, is_full: bool) -> Self {
33        Self {
34            id,
35            operation_id,
36            created_at: Utc::now(),
37            is_full,
38            base_checkpoint: None,
39            file_hashes: HashMap::new(),
40            file_count: 0,
41            total_size: 0,
42        }
43    }
44
45    pub fn with_base(mut self, base_id: String) -> Self {
46        self.base_checkpoint = Some(base_id);
47        self
48    }
49
50    pub fn with_hashes(mut self, hashes: HashMap<String, String>) -> Self {
51        self.file_count = hashes.len();
52        self.file_hashes = hashes;
53        self
54    }
55
56    pub fn with_size(mut self, size: u64) -> Self {
57        self.total_size = size;
58        self
59    }
60}
61
62/// Manages checkpoints storage
63pub struct CheckpointManager {
64    /// Base directory for checkpoints
65    base_dir: PathBuf,
66    /// Manifest of all checkpoints
67    manifest: CheckpointManifest,
68    /// Number of operations between full snapshots
69    full_snapshot_interval: usize,
70}
71
72/// Manifest tracking all checkpoints
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct CheckpointManifest {
75    pub version: u32,
76    pub checkpoints: Vec<CheckpointEntry>,
77    pub latest_full: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CheckpointEntry {
82    pub id: String,
83    pub operation_id: String,
84    pub created_at: DateTime<Utc>,
85    pub is_full: bool,
86}
87
88impl CheckpointManager {
89    pub fn new(base_dir: PathBuf) -> Result<Self> {
90        fs::create_dir_all(&base_dir)?;
91
92        let manifest_path = base_dir.join("manifest.toml");
93        let manifest = if manifest_path.exists() {
94            let content = fs::read_to_string(&manifest_path)?;
95            toml::from_str(&content)
96                .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?
97        } else {
98            CheckpointManifest {
99                version: 1,
100                checkpoints: Vec::new(),
101                latest_full: None,
102            }
103        };
104
105        Ok(Self {
106            base_dir,
107            manifest,
108            full_snapshot_interval: 10,
109        })
110    }
111
112    /// Set the interval for full snapshots
113    pub fn with_full_snapshot_interval(mut self, interval: usize) -> Self {
114        self.full_snapshot_interval = interval;
115        self
116    }
117
118    /// Generate a new checkpoint ID
119    fn generate_checkpoint_id() -> String {
120        let now = Utc::now();
121        format!("cp-{}", now.format("%Y%m%d_%H%M%S_%3f"))
122    }
123
124    /// Determine if we should create a full snapshot
125    fn should_create_full(&self) -> bool {
126        if self.manifest.latest_full.is_none() {
127            return true;
128        }
129
130        let since_last_full = self
131            .manifest
132            .checkpoints
133            .iter()
134            .rev()
135            .take_while(|c| !c.is_full)
136            .count();
137
138        since_last_full >= self.full_snapshot_interval
139    }
140
141    /// Create a checkpoint for the given source directory
142    pub fn create_checkpoint(
143        &mut self,
144        operation_id: &str,
145        source_dir: &Path,
146    ) -> Result<Checkpoint> {
147        let checkpoint_id = Self::generate_checkpoint_id();
148        let is_full = self.should_create_full();
149
150        let checkpoint_dir = self.base_dir.join(&checkpoint_id);
151        fs::create_dir_all(&checkpoint_dir)?;
152
153        // Get base file hashes for incremental
154        let base_hashes = if is_full {
155            HashMap::new()
156        } else if let Some(ref latest_full_id) = self.manifest.latest_full {
157            self.load_checkpoint(latest_full_id)?
158                .map(|c| c.file_hashes)
159                .unwrap_or_default()
160        } else {
161            HashMap::new()
162        };
163
164        // Compute delta
165        let delta = compute_delta(&base_hashes, source_dir)?;
166
167        // Save delta files
168        save_delta_files(&delta, source_dir, &checkpoint_dir)?;
169
170        // Collect current file hashes
171        let mut current_hashes = base_hashes.clone();
172        let mut total_size = 0u64;
173
174        for entry in &delta.entries {
175            match entry.delta_type {
176                super::delta::DeltaType::Added | super::delta::DeltaType::Modified => {
177                    if let Some(ref hash) = entry.hash {
178                        current_hashes.insert(entry.path.clone(), hash.clone());
179                    }
180                    if let Some(size) = entry.size {
181                        total_size = total_size.saturating_add(size);
182                    }
183                }
184                super::delta::DeltaType::Deleted => {
185                    current_hashes.remove(&entry.path);
186                }
187            }
188        }
189
190        // Create checkpoint
191        let checkpoint = Checkpoint::new(checkpoint_id.clone(), operation_id.to_string(), is_full)
192            .with_hashes(current_hashes)
193            .with_size(total_size);
194
195        if !is_full {
196            let checkpoint = checkpoint.with_base(
197                self.manifest
198                    .latest_full
199                    .clone()
200                    .unwrap_or_else(|| checkpoint_id.clone()),
201            );
202            self.save_checkpoint_meta(&checkpoint, &checkpoint_dir)?;
203
204            // Update manifest
205            self.manifest.checkpoints.push(CheckpointEntry {
206                id: checkpoint_id,
207                operation_id: operation_id.to_string(),
208                created_at: checkpoint.created_at,
209                is_full,
210            });
211            self.save_manifest()?;
212
213            return Ok(checkpoint);
214        }
215
216        // Save checkpoint metadata
217        self.save_checkpoint_meta(&checkpoint, &checkpoint_dir)?;
218
219        // Update manifest
220        self.manifest.checkpoints.push(CheckpointEntry {
221            id: checkpoint_id.clone(),
222            operation_id: operation_id.to_string(),
223            created_at: checkpoint.created_at,
224            is_full,
225        });
226
227        if is_full {
228            self.manifest.latest_full = Some(checkpoint_id);
229        }
230
231        self.save_manifest()?;
232
233        Ok(checkpoint)
234    }
235
236    /// Save checkpoint metadata to disk
237    fn save_checkpoint_meta(&self, checkpoint: &Checkpoint, checkpoint_dir: &Path) -> Result<()> {
238        let meta_path = checkpoint_dir.join("meta.toml");
239        let content = toml::to_string_pretty(checkpoint)
240            .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
241        fs::write(meta_path, content)?;
242        Ok(())
243    }
244
245    /// Save manifest to disk
246    fn save_manifest(&self) -> Result<()> {
247        let manifest_path = self.base_dir.join("manifest.toml");
248        let content = toml::to_string_pretty(&self.manifest)
249            .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
250        fs::write(manifest_path, content)?;
251        Ok(())
252    }
253
254    /// Load a checkpoint by ID
255    pub fn load_checkpoint(&self, checkpoint_id: &str) -> Result<Option<Checkpoint>> {
256        let checkpoint_dir = self.base_dir.join(checkpoint_id);
257        let meta_path = checkpoint_dir.join("meta.toml");
258
259        if !meta_path.exists() {
260            return Ok(None);
261        }
262
263        let content = fs::read_to_string(meta_path)?;
264        let checkpoint: Checkpoint = toml::from_str(&content)
265            .map_err(|e| crate::error::DotAgentError::Internal(e.to_string()))?;
266
267        Ok(Some(checkpoint))
268    }
269
270    /// Restore a checkpoint to the target directory
271    pub fn restore_checkpoint(&self, checkpoint_id: &str, target_dir: &Path) -> Result<()> {
272        // Find the chain of checkpoints to apply
273        let chain = self.build_checkpoint_chain(checkpoint_id)?;
274
275        // Clear target directory (but keep .dot-agent-meta.toml)
276        self.clear_target_preserving_meta(target_dir)?;
277
278        // Apply checkpoints in order (oldest first)
279        for cp_id in chain {
280            let checkpoint_dir = self.base_dir.join(&cp_id);
281            let delta = load_delta(&checkpoint_dir)?;
282            self.apply_delta(&delta, &checkpoint_dir, target_dir)?;
283        }
284
285        Ok(())
286    }
287
288    /// Build the chain of checkpoints from base to target
289    fn build_checkpoint_chain(&self, checkpoint_id: &str) -> Result<Vec<String>> {
290        let mut chain = Vec::new();
291        let mut current_id = checkpoint_id.to_string();
292
293        loop {
294            chain.push(current_id.clone());
295
296            let checkpoint = self
297                .load_checkpoint(&current_id)?
298                .ok_or_else(|| crate::error::DotAgentError::NotFound(current_id.clone()))?;
299
300            if checkpoint.is_full {
301                break;
302            }
303
304            match checkpoint.base_checkpoint {
305                Some(base_id) => current_id = base_id,
306                None => break,
307            }
308        }
309
310        chain.reverse();
311        Ok(chain)
312    }
313
314    /// Clear target directory while preserving metadata files
315    fn clear_target_preserving_meta(&self, target_dir: &Path) -> Result<()> {
316        if !target_dir.exists() {
317            return Ok(());
318        }
319
320        for entry in fs::read_dir(target_dir)? {
321            let entry = entry?;
322            let path = entry.path();
323            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
324
325            // Preserve dot-agent metadata
326            if name == ".dot-agent-meta.toml" || name == ".dot-agent-history" {
327                continue;
328            }
329
330            if path.is_dir() {
331                fs::remove_dir_all(&path)?;
332            } else {
333                fs::remove_file(&path)?;
334            }
335        }
336
337        Ok(())
338    }
339
340    /// Apply a delta to the target directory
341    fn apply_delta(&self, delta: &Delta, checkpoint_dir: &Path, target_dir: &Path) -> Result<()> {
342        let delta_dir = checkpoint_dir.join("delta");
343
344        for entry in &delta.entries {
345            let target_path = target_dir.join(&entry.path);
346
347            match entry.delta_type {
348                super::delta::DeltaType::Added | super::delta::DeltaType::Modified => {
349                    let source_path = delta_dir.join(&entry.path);
350                    if source_path.exists() {
351                        if let Some(parent) = target_path.parent() {
352                            fs::create_dir_all(parent)?;
353                        }
354                        fs::copy(&source_path, &target_path)?;
355                    }
356                }
357                super::delta::DeltaType::Deleted => {
358                    if target_path.exists() {
359                        fs::remove_file(&target_path)?;
360                    }
361                }
362            }
363        }
364
365        Ok(())
366    }
367
368    /// List all checkpoints
369    pub fn list_checkpoints(&self) -> &[CheckpointEntry] {
370        &self.manifest.checkpoints
371    }
372
373    /// Prune old checkpoints, keeping at least `keep_count` recent ones
374    pub fn prune(&mut self, keep_count: usize) -> Result<usize> {
375        if self.manifest.checkpoints.len() <= keep_count {
376            return Ok(0);
377        }
378
379        let to_remove = self.manifest.checkpoints.len() - keep_count;
380        let mut removed = 0;
381
382        // Remove oldest checkpoints (but never remove latest_full)
383        let mut new_checkpoints = Vec::new();
384        for (i, entry) in self.manifest.checkpoints.iter().enumerate() {
385            if i < to_remove && Some(&entry.id) != self.manifest.latest_full.as_ref() {
386                let checkpoint_dir = self.base_dir.join(&entry.id);
387                if checkpoint_dir.exists() {
388                    fs::remove_dir_all(&checkpoint_dir)?;
389                    removed += 1;
390                }
391            } else {
392                new_checkpoints.push(entry.clone());
393            }
394        }
395
396        self.manifest.checkpoints = new_checkpoints;
397        self.save_manifest()?;
398
399        Ok(removed)
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use tempfile::TempDir;
407
408    #[test]
409    fn checkpoint_creation() {
410        let cp = Checkpoint::new("cp-001".into(), "op-001".into(), true);
411        assert!(cp.is_full);
412        assert!(cp.base_checkpoint.is_none());
413    }
414
415    #[test]
416    fn checkpoint_manager_init() -> Result<()> {
417        let temp = TempDir::new()?;
418        let manager = CheckpointManager::new(temp.path().to_path_buf())?;
419
420        assert!(manager.manifest.checkpoints.is_empty());
421        assert!(manager.manifest.latest_full.is_none());
422
423        Ok(())
424    }
425
426    #[test]
427    fn create_full_checkpoint() -> Result<()> {
428        let temp_base = TempDir::new()?;
429        let temp_source = TempDir::new()?;
430
431        // Create source files
432        fs::write(temp_source.path().join("file1.txt"), "content1")?;
433        fs::write(temp_source.path().join("file2.txt"), "content2")?;
434
435        let mut manager = CheckpointManager::new(temp_base.path().to_path_buf())?;
436        let checkpoint = manager.create_checkpoint("op-001", temp_source.path())?;
437
438        assert!(checkpoint.is_full);
439        assert_eq!(checkpoint.file_count, 2);
440
441        Ok(())
442    }
443
444    #[test]
445    fn restore_checkpoint() -> Result<()> {
446        let temp_base = TempDir::new()?;
447        let temp_source = TempDir::new()?;
448        let temp_target = TempDir::new()?;
449
450        // Create source files
451        fs::write(temp_source.path().join("file1.txt"), "content1")?;
452        fs::write(temp_source.path().join("file2.txt"), "content2")?;
453
454        let mut manager = CheckpointManager::new(temp_base.path().to_path_buf())?;
455        let checkpoint = manager.create_checkpoint("op-001", temp_source.path())?;
456
457        // Restore to target
458        manager.restore_checkpoint(&checkpoint.id, temp_target.path())?;
459
460        assert!(temp_target.path().join("file1.txt").exists());
461        assert!(temp_target.path().join("file2.txt").exists());
462
463        Ok(())
464    }
465}