Skip to content
Open
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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 69 additions & 25 deletions docs/proposals/SNAPSHOTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ impl SnapshotManager {

/// Find snapshot by tag (O(1) lookup via tag ref)
/// Returns single snapshot since tags are immutable
pub async fn find_by_tag(&self, tag: &str) -> Result<Option<SnapshotInfo>>;
pub async fn find_snapshot_by_tag(&self, tag: &str) -> Result<Option<SnapshotInfo>>;

/// Get snapshot by ID
pub async fn get_snapshot(&self, id: &str) -> Result<Snapshot>;
Expand All @@ -206,45 +206,89 @@ impl SnapshotManager {

## Protocol Integration

**Note:** See [Protocol Specification](PROTOCOL.md) for complete message format details.
Snapshot operations are exposed via WebSocket protocol messages. All operations include a `request_id` for matching requests with responses.

**New message types:**
**Message types:**

```rust
pub enum Request {
pub enum Message {
// Create snapshot
CreateSnapshot {
daemon_id: String,
workspace_path: String,
message: String,
tags: Vec<String>,
request_id: String,
workspace: String, // Path to workspace directory
message: Option<String>, // Optional description
tags: Option<Vec<String>>, // Optional tags (must be unique)
},
SnapshotCreated {
request_id: String,
snapshot_id: String, // UUID of created snapshot
file_count: usize, // Number of files captured
total_size: u64, // Total size in bytes
},

// Restore snapshot
RestoreSnapshot {
daemon_id: String,
snapshot_id: String,
destination: String,
request_id: String,
snapshot_id: String, // Snapshot ID or tag name
destination: String, // Path to restore to
},
Comment on lines +231 to +234
SnapshotRestored {
request_id: String,
file_count: usize, // Number of files restored
},

ListSnapshots { daemon_id: String },
DeleteSnapshot { daemon_id: String, snapshot_id: String },
GarbageCollect { daemon_id: String },
}
// List snapshots (with optional tag filter)
ListSnapshots {
request_id: String,
tags: Option<Vec<String>>, // OR filter: snapshots with any of these tags
},
SnapshotList {
request_id: String,
snapshots: Vec<SnapshotInfo>, // Sorted by creation time (newest first)
},

pub enum Response {
SnapshotCreated {
// Find snapshot by tag (O(1) lookup)
FindSnapshotByTag {
request_id: String,
tag: String, // Tag name (immutable)
},
SnapshotFound {
request_id: String,
snapshot: Option<SnapshotInfo>, // None if tag doesn't exist
},

// Get snapshot details
GetSnapshot {
request_id: String,
snapshot_id: String,
file_count: usize,
total_size: u64,
duration_ms: u64,
},
SnapshotDetails {
request_id: String,
snapshot: Snapshot, // Full snapshot metadata
},

SnapshotRestored { file_count: usize, duration_ms: u64 },
Snapshots { snapshots: Vec<SnapshotInfo> },
SnapshotDeleted { freed_bytes: u64 },
GarbageCollected { objects_deleted: usize, bytes_freed: u64 },
// Delete snapshot (also removes tag refs)
DeleteSnapshot {
request_id: String,
snapshot_id: String,
},
SnapshotDeleted {
request_id: String,
},

// Error response
SnapshotError {
request_id: String,
error: String, // Error message (e.g., "Tag 'v1.0.0' already exists")
},
}
```

**Error cases:**
- `CreateSnapshot` with existing tag → `SnapshotError`
- `RestoreSnapshot`/`GetSnapshot`/`DeleteSnapshot` with non-existent ID → `SnapshotError`
- File I/O errors → `SnapshotError`

---


Expand Down Expand Up @@ -275,7 +319,7 @@ async fn main() -> Result<()> {
}

// Find snapshot by tag (O(1) lookup)
if let Some(snapshot) = manager.find_by_tag("pre-task").await? {
if let Some(snapshot) = manager.find_snapshot_by_tag("pre-task").await? {
println!("Found: {}", snapshot.id);
}

Expand Down
4 changes: 2 additions & 2 deletions examples/snapshot_simple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ async fn main() -> Result<()> {

// 6. Find by tag (returns single snapshot since tags are immutable)
println!("\n6. Finding snapshot with 'init' tag:");
if let Some(snap) = manager.find_by_tag("init").await? {
if let Some(snap) = manager.find_snapshot_by_tag("init").await? {
println!(" {} - {}", snap.id, snap.message);
}

println!("\n7. Finding snapshot with 'feature' tag:");
if let Some(snap) = manager.find_by_tag("feature").await? {
if let Some(snap) = manager.find_snapshot_by_tag("feature").await? {
println!(" {} - {}", snap.id, snap.message);
}

Expand Down
38 changes: 37 additions & 1 deletion python/sandd/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Data models for SandD"""

from typing import Dict
from typing import Dict, List
from datetime import datetime

try:
from ._core import PyCommandResult, PyStats
Expand Down Expand Up @@ -119,3 +120,38 @@ def __repr__(self) -> str:
f"ServerStats(total={self.total_daemons}, "
f"platforms={self.by_platform})"
)


class SnapshotInfo:
"""Snapshot metadata

Attributes:
id: Snapshot ID (UUID)
created_at: Creation timestamp
message: Snapshot description
tags: List of tags (immutable)
file_count: Number of files in snapshot
total_size: Total size in bytes
"""

def __init__(
self,
id: str,
created_at: int, # Unix timestamp
message: str,
tags: List[str],
file_count: int,
total_size: int,
):
self.id = id
self.created_at = datetime.fromtimestamp(created_at)
self.message = message
self.tags = tags
self.file_count = file_count
self.total_size = total_size

def __repr__(self) -> str:
return (
f"SnapshotInfo(id={self.id!r}, message={self.message!r}, "
f"tags={self.tags}, files={self.file_count})"
)
Loading
Loading