Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
46 changes: 46 additions & 0 deletions cli/src/cmd/snapshot.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
17 changes: 17 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions cli/src/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Cipher algorithm for encryption (required with --key).
/// Options: aegis128l, aegis128x2, aegis128x4, aegis256, aegis256x2, aegis256x4, aes128gcm, aes256gcm
#[arg(long, env = "AGENTFS_CIPHER")]
cipher: Option<String>,
},
/// Prune unused resources
Prune {
#[command(subcommand)]
Expand Down
49 changes: 49 additions & 0 deletions cli/src/sandbox/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -455,6 +463,47 @@ fn setup_run_directory(session_id: Option<String>) -> Result<RunSession> {
})
}

/// 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].
Expand Down
12 changes: 12 additions & 0 deletions issue-61-comment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
The snapshot functionality has been implemented. This adds:

- `snapshot()` method to the SDK
- `agentfs snapshot <ID_OR_PATH> <DEST_PATH>` CLI command

You can snapshot an active session's database by pointing to its delta.db file:

```
agentfs snapshot ~/.agentfs/run/<session>/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.
4 changes: 4 additions & 0 deletions sdk/rust/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
188 changes: 188 additions & 0 deletions sdk/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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.
Expand Down Expand Up @@ -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<String> = 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<String> = 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<String> = 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"));
}
}