Skip to content

Commit 6a77c34

Browse files
committed
feat: Add snapshot functionality (closes #57, #232)
- Add snapshot() method to AgentFS SDK using checkpoint + file copy - Add 'agentfs snapshot' CLI command for manual snapshots - Add automatic snapshot on nested 'agentfs run' (detected via AGENTFS_SESSION env var) - Add Snapshot error variant - Add tests for snapshot functionality
1 parent f50bf2f commit 6a77c34

7 files changed

Lines changed: 324 additions & 0 deletions

File tree

cli/src/cmd/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod fs;
33
pub mod init;
44
pub mod mcp_server;
55
pub mod ps;
6+
pub mod snapshot;
67
pub mod sync;
78
pub mod timeline;
89

cli/src/cmd/snapshot.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//! Snapshot command - create point-in-time copies of agent filesystems.
2+
3+
use agentfs_sdk::{AgentFS, AgentFSOptions, EncryptionConfig};
4+
use anyhow::{Context, Result};
5+
use std::path::Path;
6+
7+
/// Create a snapshot of an agent filesystem.
8+
///
9+
/// This creates a consistent point-in-time copy of the database using
10+
/// SQLite's VACUUM INTO command, which ensures atomicity and consistency.
11+
pub async fn handle_snapshot_command(
12+
id_or_path: String,
13+
dest_path: &Path,
14+
encryption: Option<(&str, &str)>,
15+
) -> Result<()> {
16+
// Resolve the source database
17+
let mut options =
18+
AgentFSOptions::resolve(&id_or_path).context("Failed to resolve agent ID or path")?;
19+
20+
// Apply encryption if provided
21+
if let Some((key, cipher)) = encryption {
22+
options = options.with_encryption(EncryptionConfig {
23+
hex_key: key.to_string(),
24+
cipher: cipher.to_string(),
25+
});
26+
}
27+
28+
let agentfs = AgentFS::open(options)
29+
.await
30+
.context("Failed to open agent filesystem")?;
31+
32+
// Convert destination path to string
33+
let dest_path_str = dest_path
34+
.to_str()
35+
.context("Destination path contains non-UTF8 characters")?;
36+
37+
// Create the snapshot
38+
agentfs
39+
.snapshot(dest_path_str)
40+
.await
41+
.context("Failed to create snapshot")?;
42+
43+
eprintln!("Snapshot created: {}", dest_path.display());
44+
45+
Ok(())
46+
}

cli/src/main.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,23 @@ fn main() {
291291
std::process::exit(1);
292292
}
293293
}
294+
Command::Snapshot {
295+
id_or_path,
296+
dest_path,
297+
key,
298+
cipher,
299+
} => {
300+
let encryption = parse_encryption(key, cipher);
301+
let rt = get_runtime();
302+
if let Err(e) = rt.block_on(cmd::snapshot::handle_snapshot_command(
303+
id_or_path,
304+
&dest_path,
305+
encryption.as_ref().map(|(k, c)| (k.as_str(), c.as_str())),
306+
)) {
307+
eprintln!("Error: {}", e);
308+
std::process::exit(1);
309+
}
310+
}
294311
Command::Prune { command } => match command {
295312
PruneCommand::Mounts { force } => {
296313
if let Err(e) = cmd::mount::prune_mounts(force) {

cli/src/parser.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,25 @@ pub enum Command {
278278
},
279279
/// List active agentfs run sessions
280280
Ps,
281+
/// Create a snapshot of an agent filesystem
282+
Snapshot {
283+
/// Agent ID or database path to snapshot
284+
#[arg(value_name = "ID_OR_PATH", add = ArgValueCompleter::new(id_or_path_completer))]
285+
id_or_path: String,
286+
287+
/// Destination path for the snapshot
288+
#[arg(value_name = "DEST_PATH", add = ArgValueCompleter::new(PathCompleter::any()))]
289+
dest_path: PathBuf,
290+
291+
/// Hex-encoded encryption key for encrypted databases.
292+
#[arg(long, env = "AGENTFS_KEY")]
293+
key: Option<String>,
294+
295+
/// Cipher algorithm for encryption (required with --key).
296+
/// Options: aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4, aes128gcm, aes256gcm
297+
#[arg(long, env = "AGENTFS_CIPHER")]
298+
cipher: Option<String>,
299+
},
281300
/// Prune unused resources
282301
Prune {
283302
#[command(subcommand)]

cli/src/sandbox/linux.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ pub async fn run_cmd(
165165
.context("Failed to read session base path")?;
166166
let overlay_base = PathBuf::from(overlay_base.trim());
167167

168+
// Check if we're running inside an existing agentfs session (nested run)
169+
// If so, create an automatic snapshot before joining
170+
if std::env::var("AGENTFS_SESSION").is_ok() {
171+
if let Err(e) = create_auto_snapshot(&session.db_path).await {
172+
eprintln!("Warning: Failed to create automatic snapshot: {}", e);
173+
}
174+
}
175+
168176
eprintln!("Joining existing session: {}", session.run_id);
169177
eprintln!();
170178
return run_in_existing_session(
@@ -475,6 +483,47 @@ fn setup_run_directory(session_id: Option<String>) -> Result<RunSession> {
475483
})
476484
}
477485

486+
/// Create an automatic snapshot before a nested run joins an existing session.
487+
///
488+
/// This creates a point-in-time copy of the delta database, allowing agents
489+
/// to branch off from a known state. The snapshot is saved in the same run
490+
/// directory with a timestamp suffix.
491+
async fn create_auto_snapshot(db_path: &Path) -> Result<()> {
492+
use chrono::Utc;
493+
494+
// Generate snapshot filename with timestamp
495+
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
496+
let snapshot_name = format!("snapshot_{}.db", timestamp);
497+
498+
let snapshot_path = db_path
499+
.parent()
500+
.context("Failed to get parent directory of database")?
501+
.join(&snapshot_name);
502+
503+
let db_path_str = db_path
504+
.to_str()
505+
.context("Database path contains non-UTF8 characters")?;
506+
507+
let snapshot_path_str = snapshot_path
508+
.to_str()
509+
.context("Snapshot path contains non-UTF8 characters")?;
510+
511+
// Open the existing database and create a snapshot
512+
let options = AgentFSOptions::with_path(db_path_str);
513+
let agentfs = AgentFS::open(options)
514+
.await
515+
.context("Failed to open database for snapshot")?;
516+
517+
agentfs
518+
.snapshot(snapshot_path_str)
519+
.await
520+
.context("Failed to create snapshot")?;
521+
522+
eprintln!("📸 Auto-snapshot created: {}", snapshot_path.display());
523+
524+
Ok(())
525+
}
526+
478527
/// Create a pair of pipes for parent-child synchronization.
479528
///
480529
/// Returns (child_pipe, parent_pipe) where each is [read_fd, write_fd].

sdk/rust/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ pub enum Error {
6868
/// Internal error (for unexpected conditions)
6969
#[error("{0}")]
7070
Internal(String),
71+
72+
/// Snapshot error
73+
#[error("snapshot error: {0}")]
74+
Snapshot(String),
7175
}
7276

7377
/// Result type alias using the SDK Error type.

sdk/rust/src/lib.rs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,63 @@ impl AgentFS {
600600
Ok(whiteouts)
601601
}
602602

603+
/// Create a snapshot of the current database state.
604+
///
605+
/// This creates a point-in-time copy of the database by checkpointing the WAL
606+
/// and copying the database file. The snapshot is consistent because we
607+
/// checkpoint first to ensure all data is written to the main database file.
608+
///
609+
/// # Arguments
610+
/// * `dest_path` - Path where the snapshot will be saved (e.g., "/path/to/snapshot.db")
611+
///
612+
/// # Example
613+
/// ```no_run
614+
/// use agentfs_sdk::{AgentFS, AgentFSOptions};
615+
///
616+
/// # async fn example() -> agentfs_sdk::error::Result<()> {
617+
/// let agent = AgentFS::open(AgentFSOptions::with_id("my-agent")).await?;
618+
/// agent.snapshot("/tmp/my-agent-snapshot.db").await?;
619+
/// # Ok(())
620+
/// # }
621+
/// ```
622+
pub async fn snapshot(&self, dest_path: &str) -> Result<()> {
623+
let conn = self.pool.get_connection().await?;
624+
625+
// Checkpoint the WAL to ensure all data is in the main database file
626+
// PRAGMA wal_checkpoint(TRUNCATE) writes all WAL content to the database
627+
// and truncates the WAL file
628+
let mut checkpoint_rows = conn.query("PRAGMA wal_checkpoint(TRUNCATE)", ()).await?;
629+
// Consume the result rows
630+
while let Some(_) = checkpoint_rows.next().await? {}
631+
632+
// Get the source database path by querying the database filename
633+
let mut rows = conn.query("PRAGMA database_list", ()).await?;
634+
let mut source_path: Option<String> = None;
635+
636+
while let Some(row) = rows.next().await? {
637+
// database_list returns: seq, name, file
638+
// We want the 'file' column (index 2) for the 'main' database
639+
if let Ok(Value::Text(name)) = row.get_value(1) {
640+
if name == "main" {
641+
if let Ok(Value::Text(file)) = row.get_value(2) {
642+
if !file.is_empty() {
643+
source_path = Some(file.clone());
644+
}
645+
}
646+
break;
647+
}
648+
}
649+
}
650+
651+
let source_path = source_path
652+
.ok_or_else(|| Error::Snapshot("Cannot snapshot in-memory database".to_string()))?;
653+
654+
// Copy the database file
655+
std::fs::copy(&source_path, dest_path)?;
656+
657+
Ok(())
658+
}
659+
603660
/// Check if overlay is enabled for this filesystem
604661
///
605662
/// Returns the base path if overlay is enabled, None otherwise.
@@ -910,4 +967,135 @@ mod tests {
910967
let _ = std::fs::remove_file(agentfs_dir().join(file_name));
911968
}
912969
}
970+
971+
#[tokio::test]
972+
async fn test_snapshot() {
973+
let temp_dir = std::env::temp_dir();
974+
let source_path = temp_dir.join("test_snapshot_source.db");
975+
let snapshot_path = temp_dir.join("test_snapshot_dest.db");
976+
977+
// Cleanup any existing files
978+
let _ = std::fs::remove_file(&source_path);
979+
let _ = std::fs::remove_file(&snapshot_path);
980+
981+
// Create source database and add some data
982+
{
983+
let agentfs = AgentFS::open(AgentFSOptions::with_path(source_path.to_str().unwrap()))
984+
.await
985+
.unwrap();
986+
987+
// Add KV data
988+
agentfs
989+
.kv
990+
.set("snapshot_key", &"snapshot_value")
991+
.await
992+
.unwrap();
993+
994+
// Add filesystem data
995+
agentfs.fs.mkdir("/snapshot_dir", 0, 0).await.unwrap();
996+
let (_, file) = agentfs
997+
.fs
998+
.create_file("/snapshot_dir/file.txt", DEFAULT_FILE_MODE, 0, 0)
999+
.await
1000+
.unwrap();
1001+
file.pwrite(0, b"snapshot content").await.unwrap();
1002+
1003+
// Create snapshot
1004+
agentfs
1005+
.snapshot(snapshot_path.to_str().unwrap())
1006+
.await
1007+
.unwrap();
1008+
}
1009+
1010+
// Verify snapshot file exists
1011+
assert!(snapshot_path.exists(), "Snapshot file should exist");
1012+
1013+
// Open snapshot and verify data
1014+
{
1015+
let snapshot_agentfs =
1016+
AgentFS::open(AgentFSOptions::with_path(snapshot_path.to_str().unwrap()))
1017+
.await
1018+
.unwrap();
1019+
1020+
// Verify KV data
1021+
let value: Option<String> = snapshot_agentfs.kv.get("snapshot_key").await.unwrap();
1022+
assert_eq!(value, Some("snapshot_value".to_string()));
1023+
1024+
// Verify filesystem data
1025+
let stats = snapshot_agentfs.fs.stat("/snapshot_dir").await.unwrap();
1026+
assert!(stats.is_some());
1027+
assert!(stats.unwrap().is_directory());
1028+
1029+
let content = snapshot_agentfs
1030+
.fs
1031+
.read_file("/snapshot_dir/file.txt")
1032+
.await
1033+
.unwrap();
1034+
assert_eq!(content, Some(b"snapshot content".to_vec()));
1035+
}
1036+
1037+
// Cleanup
1038+
let _ = std::fs::remove_file(&source_path);
1039+
let _ = std::fs::remove_file(&snapshot_path);
1040+
// Also clean up WAL files
1041+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_source.db-shm"));
1042+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_source.db-wal"));
1043+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_dest.db-shm"));
1044+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_dest.db-wal"));
1045+
}
1046+
1047+
#[tokio::test]
1048+
async fn test_snapshot_is_independent() {
1049+
let temp_dir = std::env::temp_dir();
1050+
let source_path = temp_dir.join("test_snapshot_indep_source.db");
1051+
let snapshot_path = temp_dir.join("test_snapshot_indep_dest.db");
1052+
1053+
// Cleanup any existing files
1054+
let _ = std::fs::remove_file(&source_path);
1055+
let _ = std::fs::remove_file(&snapshot_path);
1056+
1057+
// Create source database
1058+
let agentfs = AgentFS::open(AgentFSOptions::with_path(source_path.to_str().unwrap()))
1059+
.await
1060+
.unwrap();
1061+
1062+
// Add initial data
1063+
agentfs.kv.set("key1", &"value1").await.unwrap();
1064+
1065+
// Create snapshot
1066+
agentfs
1067+
.snapshot(snapshot_path.to_str().unwrap())
1068+
.await
1069+
.unwrap();
1070+
1071+
// Modify source after snapshot
1072+
agentfs.kv.set("key2", &"value2").await.unwrap();
1073+
1074+
// Open snapshot and verify it doesn't have the new data
1075+
let snapshot_agentfs =
1076+
AgentFS::open(AgentFSOptions::with_path(snapshot_path.to_str().unwrap()))
1077+
.await
1078+
.unwrap();
1079+
1080+
// Snapshot should have key1
1081+
let value: Option<String> = snapshot_agentfs.kv.get("key1").await.unwrap();
1082+
assert_eq!(value, Some("value1".to_string()));
1083+
1084+
// Snapshot should NOT have key2 (added after snapshot)
1085+
let value: Option<String> = snapshot_agentfs.kv.get("key2").await.unwrap();
1086+
assert_eq!(
1087+
value, None,
1088+
"Snapshot should not contain data added after snapshot was created"
1089+
);
1090+
1091+
// Cleanup
1092+
drop(agentfs);
1093+
drop(snapshot_agentfs);
1094+
let _ = std::fs::remove_file(&source_path);
1095+
let _ = std::fs::remove_file(&snapshot_path);
1096+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_source.db-shm"));
1097+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_source.db-wal"));
1098+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_dest.db-shm"));
1099+
let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_dest.db-wal"));
1100+
}
9131101
}

0 commit comments

Comments
 (0)