diff --git a/cli/src/cmd/mod.rs b/cli/src/cmd/mod.rs index 3c06b1e2..a8ddabdb 100644 --- a/cli/src/cmd/mod.rs +++ b/cli/src/cmd/mod.rs @@ -4,6 +4,7 @@ pub mod init; pub mod mcp_server; pub mod migrate; pub mod ps; +pub mod snapshot; pub mod sync; pub mod timeline; diff --git a/cli/src/cmd/snapshot.rs b/cli/src/cmd/snapshot.rs new file mode 100644 index 00000000..56580876 --- /dev/null +++ b/cli/src/cmd/snapshot.rs @@ -0,0 +1,46 @@ +//! Snapshot command - create point-in-time copies of agent filesystems. + +use agentfs_sdk::{AgentFS, AgentFSOptions, EncryptionConfig}; +use anyhow::{Context, Result}; +use std::path::Path; + +/// Create a snapshot of an agent filesystem. +/// +/// This creates a consistent point-in-time copy of the database using +/// SQLite's VACUUM INTO command, which ensures atomicity and consistency. +pub async fn handle_snapshot_command( + id_or_path: String, + dest_path: &Path, + encryption: Option<(&str, &str)>, +) -> Result<()> { + // Resolve the source database + let mut options = + AgentFSOptions::resolve(&id_or_path).context("Failed to resolve agent ID or path")?; + + // Apply encryption if provided + if let Some((key, cipher)) = encryption { + options = options.with_encryption(EncryptionConfig { + hex_key: key.to_string(), + cipher: cipher.to_string(), + }); + } + + let agentfs = AgentFS::open(options) + .await + .context("Failed to open agent filesystem")?; + + // Convert destination path to string + let dest_path_str = dest_path + .to_str() + .context("Destination path contains non-UTF8 characters")?; + + // Create the snapshot + agentfs + .snapshot(dest_path_str) + .await + .context("Failed to create snapshot")?; + + eprintln!("Snapshot created: {}", dest_path.display()); + + Ok(()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index aa244eee..8e85e53b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -313,6 +313,23 @@ fn main() { std::process::exit(1); } } + Command::Snapshot { + id_or_path, + dest_path, + key, + cipher, + } => { + let encryption = parse_encryption(key, cipher); + let rt = get_runtime(); + if let Err(e) = rt.block_on(cmd::snapshot::handle_snapshot_command( + id_or_path, + &dest_path, + encryption.as_ref().map(|(k, c)| (k.as_str(), c.as_str())), + )) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } Command::Prune { command } => match command { PruneCommand::Mounts { force } => { if let Err(e) = cmd::mount::prune_mounts(force) { diff --git a/cli/src/opts.rs b/cli/src/opts.rs index 3d9f9dc0..96a90756 100644 --- a/cli/src/opts.rs +++ b/cli/src/opts.rs @@ -318,6 +318,25 @@ pub enum Command { }, /// List active agentfs run sessions Ps, + /// Create a snapshot of an agent filesystem + Snapshot { + /// Agent ID or database path to snapshot + #[arg(value_name = "ID_OR_PATH", add = ArgValueCompleter::new(id_or_path_completer))] + id_or_path: String, + + /// Destination path for the snapshot + #[arg(value_name = "DEST_PATH", add = ArgValueCompleter::new(PathCompleter::any()))] + dest_path: PathBuf, + + /// Hex-encoded encryption key for encrypted databases. + #[arg(long, env = "AGENTFS_KEY")] + key: Option, + + /// Cipher algorithm for encryption (required with --key). + /// Options: aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4, aes128gcm, aes256gcm + #[arg(long, env = "AGENTFS_CIPHER")] + cipher: Option, + }, /// Prune unused resources Prune { #[command(subcommand)] diff --git a/cli/src/sandbox/linux.rs b/cli/src/sandbox/linux.rs index 0bfc915a..02b0c158 100644 --- a/cli/src/sandbox/linux.rs +++ b/cli/src/sandbox/linux.rs @@ -162,6 +162,14 @@ pub async fn run_cmd( .context("Failed to read session base path")?; let overlay_base = PathBuf::from(overlay_base.trim()); + // Check if we're running inside an existing agentfs session (nested run) + // If so, create an automatic snapshot before joining + if std::env::var("AGENTFS_SESSION").is_ok() { + if let Err(e) = create_auto_snapshot(&session.db_path).await { + eprintln!("Warning: Failed to create automatic snapshot: {}", e); + } + } + eprintln!("Joining existing session: {}", session.run_id); eprintln!(); return run_in_existing_session( @@ -455,6 +463,47 @@ fn setup_run_directory(session_id: Option) -> Result { }) } +/// Create an automatic snapshot before a nested run joins an existing session. +/// +/// This creates a point-in-time copy of the delta database, allowing agents +/// to branch off from a known state. The snapshot is saved in the same run +/// directory with a timestamp suffix. +async fn create_auto_snapshot(db_path: &Path) -> Result<()> { + use chrono::Utc; + + // Generate snapshot filename with timestamp + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let snapshot_name = format!("snapshot_{}.db", timestamp); + + let snapshot_path = db_path + .parent() + .context("Failed to get parent directory of database")? + .join(&snapshot_name); + + let db_path_str = db_path + .to_str() + .context("Database path contains non-UTF8 characters")?; + + let snapshot_path_str = snapshot_path + .to_str() + .context("Snapshot path contains non-UTF8 characters")?; + + // Open the existing database and create a snapshot + let options = AgentFSOptions::with_path(db_path_str); + let agentfs = AgentFS::open(options) + .await + .context("Failed to open database for snapshot")?; + + agentfs + .snapshot(snapshot_path_str) + .await + .context("Failed to create snapshot")?; + + eprintln!("📸 Auto-snapshot created: {}", snapshot_path.display()); + + Ok(()) +} + /// Create a pair of pipes for parent-child synchronization. /// /// Returns (child_pipe, parent_pipe) where each is [read_fd, write_fd]. diff --git a/issue-61-comment.md b/issue-61-comment.md new file mode 100644 index 00000000..ed738d5f --- /dev/null +++ b/issue-61-comment.md @@ -0,0 +1,12 @@ +The snapshot functionality has been implemented. This adds: + +- `snapshot()` method to the SDK +- `agentfs snapshot ` CLI command + +You can snapshot an active session's database by pointing to its delta.db file: + +``` +agentfs snapshot ~/.agentfs/run//delta.db /path/to/snapshot.db +``` + +The implementation uses WAL checkpoint + file copy. Note that there's a small race window between checkpoint and copy where concurrent writes from the FUSE process might not be captured. For fully consistent snapshots of an active mount, a future improvement could have the FUSE process handle the snapshot internally. diff --git a/sdk/rust/src/error.rs b/sdk/rust/src/error.rs index 023af5dd..940c1bd3 100644 --- a/sdk/rust/src/error.rs +++ b/sdk/rust/src/error.rs @@ -72,6 +72,10 @@ pub enum Error { /// Schema version mismatch - database schema version doesn't match expected version #[error("schema version mismatch: database is version {found}, expected {expected}")] SchemaVersionMismatch { found: String, expected: String }, + + /// Snapshot error + #[error("snapshot error: {0}")] + Snapshot(String), } /// Result type alias using the SDK Error type. diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 137ba075..5a564d71 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -608,6 +608,63 @@ impl AgentFS { Ok(whiteouts) } + /// Create a snapshot of the current database state. + /// + /// This creates a point-in-time copy of the database by checkpointing the WAL + /// and copying the database file. The snapshot is consistent because we + /// checkpoint first to ensure all data is written to the main database file. + /// + /// # Arguments + /// * `dest_path` - Path where the snapshot will be saved (e.g., "/path/to/snapshot.db") + /// + /// # Example + /// ```no_run + /// use agentfs_sdk::{AgentFS, AgentFSOptions}; + /// + /// # async fn example() -> agentfs_sdk::error::Result<()> { + /// let agent = AgentFS::open(AgentFSOptions::with_id("my-agent")).await?; + /// agent.snapshot("/tmp/my-agent-snapshot.db").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn snapshot(&self, dest_path: &str) -> Result<()> { + let conn = self.pool.get_connection().await?; + + // Checkpoint the WAL to ensure all data is in the main database file + // PRAGMA wal_checkpoint(TRUNCATE) writes all WAL content to the database + // and truncates the WAL file + let mut checkpoint_rows = conn.query("PRAGMA wal_checkpoint(TRUNCATE)", ()).await?; + // Consume the result rows + while checkpoint_rows.next().await?.is_some() {} + + // Get the source database path by querying the database filename + let mut rows = conn.query("PRAGMA database_list", ()).await?; + let mut source_path: Option = None; + + while let Some(row) = rows.next().await? { + // database_list returns: seq, name, file + // We want the 'file' column (index 2) for the 'main' database + if let Ok(Value::Text(name)) = row.get_value(1) { + if name == "main" { + if let Ok(Value::Text(file)) = row.get_value(2) { + if !file.is_empty() { + source_path = Some(file.clone()); + } + } + break; + } + } + } + + let source_path = source_path + .ok_or_else(|| Error::Snapshot("Cannot snapshot in-memory database".to_string()))?; + + // Copy the database file + std::fs::copy(&source_path, dest_path)?; + + Ok(()) + } + /// Check if overlay is enabled for this filesystem /// /// Returns the base path if overlay is enabled, None otherwise. @@ -919,4 +976,135 @@ mod tests { let _ = std::fs::remove_file(agentfs_dir().join(file_name)); } } + + #[tokio::test] + async fn test_snapshot() { + let temp_dir = std::env::temp_dir(); + let source_path = temp_dir.join("test_snapshot_source.db"); + let snapshot_path = temp_dir.join("test_snapshot_dest.db"); + + // Cleanup any existing files + let _ = std::fs::remove_file(&source_path); + let _ = std::fs::remove_file(&snapshot_path); + + // Create source database and add some data + { + let agentfs = AgentFS::open(AgentFSOptions::with_path(source_path.to_str().unwrap())) + .await + .unwrap(); + + // Add KV data + agentfs + .kv + .set("snapshot_key", &"snapshot_value") + .await + .unwrap(); + + // Add filesystem data + agentfs.fs.mkdir("/snapshot_dir", 0, 0).await.unwrap(); + let (_, file) = agentfs + .fs + .create_file("/snapshot_dir/file.txt", DEFAULT_FILE_MODE, 0, 0) + .await + .unwrap(); + file.pwrite(0, b"snapshot content").await.unwrap(); + + // Create snapshot + agentfs + .snapshot(snapshot_path.to_str().unwrap()) + .await + .unwrap(); + } + + // Verify snapshot file exists + assert!(snapshot_path.exists(), "Snapshot file should exist"); + + // Open snapshot and verify data + { + let snapshot_agentfs = + AgentFS::open(AgentFSOptions::with_path(snapshot_path.to_str().unwrap())) + .await + .unwrap(); + + // Verify KV data + let value: Option = snapshot_agentfs.kv.get("snapshot_key").await.unwrap(); + assert_eq!(value, Some("snapshot_value".to_string())); + + // Verify filesystem data + let stats = snapshot_agentfs.fs.stat("/snapshot_dir").await.unwrap(); + assert!(stats.is_some()); + assert!(stats.unwrap().is_directory()); + + let content = snapshot_agentfs + .fs + .read_file("/snapshot_dir/file.txt") + .await + .unwrap(); + assert_eq!(content, Some(b"snapshot content".to_vec())); + } + + // Cleanup + let _ = std::fs::remove_file(&source_path); + let _ = std::fs::remove_file(&snapshot_path); + // Also clean up WAL files + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_source.db-shm")); + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_source.db-wal")); + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_dest.db-shm")); + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_dest.db-wal")); + } + + #[tokio::test] + async fn test_snapshot_is_independent() { + let temp_dir = std::env::temp_dir(); + let source_path = temp_dir.join("test_snapshot_indep_source.db"); + let snapshot_path = temp_dir.join("test_snapshot_indep_dest.db"); + + // Cleanup any existing files + let _ = std::fs::remove_file(&source_path); + let _ = std::fs::remove_file(&snapshot_path); + + // Create source database + let agentfs = AgentFS::open(AgentFSOptions::with_path(source_path.to_str().unwrap())) + .await + .unwrap(); + + // Add initial data + agentfs.kv.set("key1", &"value1").await.unwrap(); + + // Create snapshot + agentfs + .snapshot(snapshot_path.to_str().unwrap()) + .await + .unwrap(); + + // Modify source after snapshot + agentfs.kv.set("key2", &"value2").await.unwrap(); + + // Open snapshot and verify it doesn't have the new data + let snapshot_agentfs = + AgentFS::open(AgentFSOptions::with_path(snapshot_path.to_str().unwrap())) + .await + .unwrap(); + + // Snapshot should have key1 + let value: Option = snapshot_agentfs.kv.get("key1").await.unwrap(); + assert_eq!(value, Some("value1".to_string())); + + // Snapshot should NOT have key2 (added after snapshot) + let value: Option = snapshot_agentfs.kv.get("key2").await.unwrap(); + assert_eq!( + value, None, + "Snapshot should not contain data added after snapshot was created" + ); + + // Cleanup + drop(agentfs); + drop(snapshot_agentfs); + let _ = std::fs::remove_file(&source_path); + let _ = std::fs::remove_file(&snapshot_path); + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_source.db-shm")); + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_source.db-wal")); + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_dest.db-shm")); + let _ = std::fs::remove_file(temp_dir.join("test_snapshot_indep_dest.db-wal")); + } }