Skip to content
Merged
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.

5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ docker-down:
lint: $(RUFF)
$(RUFF) check .

.PHONY: format
format: $(RUFF)
$(RUFF) check --fix .
$(RUFF) format .

$(RUFF):
@echo "Installing ruff..."
@python3 -m venv .venv || true
Expand Down
89 changes: 64 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,84 @@ 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
destination: String, // Path to restore to
},
Comment thread
kerthcet marked this conversation as resolved.
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)
},
// Get snapshot details
GetSnapshot {
request_id: String,
snapshot_id: String,
},
SnapshotDetails {
request_id: String,
snapshot: Option<SnapshotInfo>, // Snapshot metadata, None if doesn't exist
},

// Delete snapshot (also removes tag refs)
DeleteSnapshot {
request_id: String,
snapshot_id: String,
file_count: usize,
total_size: u64,
duration_ms: u64,
},
SnapshotDeleted {
request_id: String,
},

SnapshotRestored { file_count: usize, duration_ms: u64 },
Snapshots { snapshots: Vec<SnapshotInfo> },
SnapshotDeleted { freed_bytes: u64 },
GarbageCollected { objects_deleted: usize, bytes_freed: u64 },
// 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 +314,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
36 changes: 25 additions & 11 deletions examples/exec_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
daemons = server.list_daemons()
stats = server.get_stats()

print(f"\rConnected: {stats.total_daemons} | Platforms: {stats.by_platform}", end="", flush=True)
print(
f"\rConnected: {stats.total_daemons} | Platforms: {stats.by_platform}",
end="",
flush=True,
)

if daemons and len(daemons) > 0:
for daemon in daemons:
Expand All @@ -27,28 +31,38 @@
result = server.exec(
daemon_id,
"python3 -c 'import sys; print(f\"Python {sys.version_info.major}.{sys.version_info.minor}\")'",
timeout=5
timeout=5,
)
if result.success:
print(f"\n✓ Python test passed on {daemon_id}: {result.stdout.strip()}")
print(
f"\n✓ Python test passed on {daemon_id}: {result.stdout.strip()}"
)
else:
print(f"\n✗ Python test failed on {daemon_id}: exit_code={result.exit_code}")
print(
f"\n✗ Python test failed on {daemon_id}: exit_code={result.exit_code}"
)

# Test 2: Wrong Python script (intentional error)
result = server.exec(
daemon_id,
"python3 -c 'undefined_variable'",
timeout=5
daemon_id, "python3 -c 'undefined_variable'", timeout=5
)
if not result.success:
print(f"✓ Error handling test passed on {daemon_id}, stderr: {result.stderr.strip()}")
print(
f"✓ Error handling test passed on {daemon_id}, stderr: {result.stderr.strip()}"
)
else:
print(f"✗ Error handling test failed on {daemon_id}: expected error but got success")
print(
f"✗ Error handling test failed on {daemon_id}: expected error but got success"
)

# Test 3: Echo command
result = server.exec(daemon_id, "echo 'Hello from daemon!'", timeout=5)
result = server.exec(
daemon_id, "echo 'Hello from daemon!'", timeout=5
)
if result.success:
print(f"✓ Echo test passed on {daemon_id}: {result.stdout.strip()}")
print(
f"✓ Echo test passed on {daemon_id}: {result.stdout.strip()}"
)

except Exception as e:
print(f"\n✗ Command failed on {daemon_id}: {e}")
Expand Down
20 changes: 11 additions & 9 deletions examples/install_htop.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ def install_htop(server, daemon_id):
else:
# Linux - detect distribution
distro_result = server.exec(
daemon_id,
"cat /etc/os-release 2>/dev/null || echo 'unknown'",
timeout=5
daemon_id, "cat /etc/os-release 2>/dev/null || echo 'unknown'", timeout=5
)

if not distro_result.success:
Expand All @@ -56,7 +54,9 @@ def install_htop(server, daemon_id):
elif "ubuntu" in distro or "debian" in distro:
cmd = "apt-get update && apt-get install -y htop"
elif "rocky" in distro or "rhel" in distro or "centos" in distro:
cmd = "microdnf install -y htop || dnf install -y htop || yum install -y htop"
cmd = (
"microdnf install -y htop || dnf install -y htop || yum install -y htop"
)
elif "fedora" in distro:
cmd = "dnf install -y htop"
else:
Expand Down Expand Up @@ -84,7 +84,9 @@ def main():

# Wait for at least one daemon
print("Waiting for daemons to connect...")
print("(Start a daemon with: ./target/release/sandd --server-url ws://127.0.0.1:8765/ws)")
print(
"(Start a daemon with: ./target/release/sandd --server-url ws://127.0.0.1:8765/ws)"
)
daemons = server.list_daemons()
while not daemons:
time.sleep(1)
Expand All @@ -103,7 +105,7 @@ def main():
result = server.exec(daemon_id, "htop --version", timeout=5)
if result.success:
# htop version is usually first line
version_line = result.stdout.split('\n')[0]
version_line = result.stdout.split("\n")[0]
print(f" {version_line}")
else:
print("✗ htop is not installed")
Expand All @@ -114,7 +116,7 @@ def main():
# Verify installation
result = server.exec(daemon_id, "htop --version", timeout=5)
if result.success:
version_line = result.stdout.split('\n')[0]
version_line = result.stdout.split("\n")[0]
print(f" {version_line}")
else:
print("Failed to install htop")
Expand All @@ -139,10 +141,10 @@ def main():
if result.success:
print(f"✓ {description}")
# Show first few lines of output
output_lines = result.stdout.strip().split('\n')[:3]
output_lines = result.stdout.strip().split("\n")[:3]
for line in output_lines:
print(f" {line}")
if len(result.stdout.strip().split('\n')) > 3:
if len(result.stdout.strip().split("\n")) > 3:
print(" ...")
print()
else:
Expand Down
4 changes: 2 additions & 2 deletions examples/programmatic_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ def main():
# Example 4: Create new session for long-running task
print("\n=== Example 4: Long-Running Task ===")
session2 = server.new_session(daemon_id)
session2.write(b"for i in 1 2 3; do echo \"Step $i\"; sleep 1; done\n")
session2.write(b'for i in 1 2 3; do echo "Step $i"; sleep 1; done\n')

# Stream output as it arrives
start = time.time()
while time.time() - start < 5:
output = session2.read(timeout=0.5)
if output:
print(output.decode(), end='', flush=True)
print(output.decode(), end="", flush=True)
else:
break

Expand Down
Loading
Loading