Skip to content

Commit b3d8c9a

Browse files
authored
feat: add snapshot in py server (#24)
* add snapshot in server Signed-off-by: kerthcet <kerthcet@gmail.com> * add bindings Signed-off-by: kerthcet <kerthcet@gmail.com> * big refactor to reuse the command queueing Signed-off-by: kerthcet <kerthcet@gmail.com> * fix lint Signed-off-by: kerthcet <kerthcet@gmail.com> * fix the format of created_at Signed-off-by: kerthcet <kerthcet@gmail.com> * fix test Signed-off-by: kerthcet <kerthcet@gmail.com> * fix test Signed-off-by: kerthcet <kerthcet@gmail.com> * fix test Signed-off-by: kerthcet <kerthcet@gmail.com> * fix return type Signed-off-by: kerthcet <kerthcet@gmail.com> * add python example for snapshot Signed-off-by: kerthcet <kerthcet@gmail.com> * format Signed-off-by: kerthcet <kerthcet@gmail.com> --------- Signed-off-by: kerthcet <kerthcet@gmail.com>
1 parent c27926d commit b3d8c9a

24 files changed

Lines changed: 1617 additions & 259 deletions

‎Cargo.lock‎

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Makefile‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ docker-down:
7474
lint: $(RUFF)
7575
$(RUFF) check .
7676

77+
.PHONY: format
78+
format: $(RUFF)
79+
$(RUFF) check --fix .
80+
$(RUFF) format .
81+
7782
$(RUFF):
7883
@echo "Installing ruff..."
7984
@python3 -m venv .venv || true

‎docs/proposals/SNAPSHOTS.md‎

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ impl SnapshotManager {
192192

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

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

207207
## Protocol Integration
208208

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

211-
**New message types:**
211+
**Message types:**
212212

213213
```rust
214-
pub enum Request {
214+
pub enum Message {
215+
// Create snapshot
215216
CreateSnapshot {
216-
daemon_id: String,
217-
workspace_path: String,
218-
message: String,
219-
tags: Vec<String>,
217+
request_id: String,
218+
workspace: String, // Path to workspace directory
219+
message: Option<String>, // Optional description
220+
tags: Option<Vec<String>>, // Optional tags (must be unique)
221+
},
222+
SnapshotCreated {
223+
request_id: String,
224+
snapshot_id: String, // UUID of created snapshot
225+
file_count: usize, // Number of files captured
226+
total_size: u64, // Total size in bytes
220227
},
221228

229+
// Restore snapshot
222230
RestoreSnapshot {
223-
daemon_id: String,
224-
snapshot_id: String,
225-
destination: String,
231+
request_id: String,
232+
snapshot_id: String, // Snapshot ID
233+
destination: String, // Path to restore to
234+
},
235+
SnapshotRestored {
236+
request_id: String,
237+
file_count: usize, // Number of files restored
226238
},
227239

228-
ListSnapshots { daemon_id: String },
229-
DeleteSnapshot { daemon_id: String, snapshot_id: String },
230-
GarbageCollect { daemon_id: String },
231-
}
240+
// List snapshots (with optional tag filter)
241+
ListSnapshots {
242+
request_id: String,
243+
tags: Option<Vec<String>>, // OR filter: snapshots with any of these tags
244+
},
245+
SnapshotList {
246+
request_id: String,
247+
snapshots: Vec<SnapshotInfo>, // Sorted by creation time (newest first)
248+
},
232249

233-
pub enum Response {
234-
SnapshotCreated {
250+
// Find snapshot by tag (O(1) lookup)
251+
FindSnapshotByTag {
252+
request_id: String,
253+
tag: String, // Tag name (immutable)
254+
},
255+
// Get snapshot details
256+
GetSnapshot {
257+
request_id: String,
258+
snapshot_id: String,
259+
},
260+
SnapshotDetails {
261+
request_id: String,
262+
snapshot: Option<SnapshotInfo>, // Snapshot metadata, None if doesn't exist
263+
},
264+
265+
// Delete snapshot (also removes tag refs)
266+
DeleteSnapshot {
267+
request_id: String,
235268
snapshot_id: String,
236-
file_count: usize,
237-
total_size: u64,
238-
duration_ms: u64,
269+
},
270+
SnapshotDeleted {
271+
request_id: String,
239272
},
240273

241-
SnapshotRestored { file_count: usize, duration_ms: u64 },
242-
Snapshots { snapshots: Vec<SnapshotInfo> },
243-
SnapshotDeleted { freed_bytes: u64 },
244-
GarbageCollected { objects_deleted: usize, bytes_freed: u64 },
274+
// Error response
275+
SnapshotError {
276+
request_id: String,
277+
error: String, // Error message (e.g., "Tag 'v1.0.0' already exists")
278+
},
245279
}
246280
```
247281

282+
**Error cases:**
283+
- `CreateSnapshot` with existing tag → `SnapshotError`
284+
- `RestoreSnapshot`/`GetSnapshot`/`DeleteSnapshot` with non-existent ID → `SnapshotError`
285+
- File I/O errors → `SnapshotError`
286+
248287
---
249288

250289

@@ -275,7 +314,7 @@ async fn main() -> Result<()> {
275314
}
276315

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

‎examples/exec_commands.py‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
daemons = server.list_daemons()
1818
stats = server.get_stats()
1919

20-
print(f"\rConnected: {stats.total_daemons} | Platforms: {stats.by_platform}", end="", flush=True)
20+
print(
21+
f"\rConnected: {stats.total_daemons} | Platforms: {stats.by_platform}",
22+
end="",
23+
flush=True,
24+
)
2125

2226
if daemons and len(daemons) > 0:
2327
for daemon in daemons:
@@ -27,28 +31,38 @@
2731
result = server.exec(
2832
daemon_id,
2933
"python3 -c 'import sys; print(f\"Python {sys.version_info.major}.{sys.version_info.minor}\")'",
30-
timeout=5
34+
timeout=5,
3135
)
3236
if result.success:
33-
print(f"\n✓ Python test passed on {daemon_id}: {result.stdout.strip()}")
37+
print(
38+
f"\n✓ Python test passed on {daemon_id}: {result.stdout.strip()}"
39+
)
3440
else:
35-
print(f"\n✗ Python test failed on {daemon_id}: exit_code={result.exit_code}")
41+
print(
42+
f"\n✗ Python test failed on {daemon_id}: exit_code={result.exit_code}"
43+
)
3644

3745
# Test 2: Wrong Python script (intentional error)
3846
result = server.exec(
39-
daemon_id,
40-
"python3 -c 'undefined_variable'",
41-
timeout=5
47+
daemon_id, "python3 -c 'undefined_variable'", timeout=5
4248
)
4349
if not result.success:
44-
print(f"✓ Error handling test passed on {daemon_id}, stderr: {result.stderr.strip()}")
50+
print(
51+
f"✓ Error handling test passed on {daemon_id}, stderr: {result.stderr.strip()}"
52+
)
4553
else:
46-
print(f"✗ Error handling test failed on {daemon_id}: expected error but got success")
54+
print(
55+
f"✗ Error handling test failed on {daemon_id}: expected error but got success"
56+
)
4757

4858
# Test 3: Echo command
49-
result = server.exec(daemon_id, "echo 'Hello from daemon!'", timeout=5)
59+
result = server.exec(
60+
daemon_id, "echo 'Hello from daemon!'", timeout=5
61+
)
5062
if result.success:
51-
print(f"✓ Echo test passed on {daemon_id}: {result.stdout.strip()}")
63+
print(
64+
f"✓ Echo test passed on {daemon_id}: {result.stdout.strip()}"
65+
)
5266

5367
except Exception as e:
5468
print(f"\n✗ Command failed on {daemon_id}: {e}")

‎examples/install_htop.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,7 @@ def install_htop(server, daemon_id):
3939
else:
4040
# Linux - detect distribution
4141
distro_result = server.exec(
42-
daemon_id,
43-
"cat /etc/os-release 2>/dev/null || echo 'unknown'",
44-
timeout=5
42+
daemon_id, "cat /etc/os-release 2>/dev/null || echo 'unknown'", timeout=5
4543
)
4644

4745
if not distro_result.success:
@@ -56,7 +54,9 @@ def install_htop(server, daemon_id):
5654
elif "ubuntu" in distro or "debian" in distro:
5755
cmd = "apt-get update && apt-get install -y htop"
5856
elif "rocky" in distro or "rhel" in distro or "centos" in distro:
59-
cmd = "microdnf install -y htop || dnf install -y htop || yum install -y htop"
57+
cmd = (
58+
"microdnf install -y htop || dnf install -y htop || yum install -y htop"
59+
)
6060
elif "fedora" in distro:
6161
cmd = "dnf install -y htop"
6262
else:
@@ -84,7 +84,9 @@ def main():
8484

8585
# Wait for at least one daemon
8686
print("Waiting for daemons to connect...")
87-
print("(Start a daemon with: ./target/release/sandd --server-url ws://127.0.0.1:8765/ws)")
87+
print(
88+
"(Start a daemon with: ./target/release/sandd --server-url ws://127.0.0.1:8765/ws)"
89+
)
8890
daemons = server.list_daemons()
8991
while not daemons:
9092
time.sleep(1)
@@ -103,7 +105,7 @@ def main():
103105
result = server.exec(daemon_id, "htop --version", timeout=5)
104106
if result.success:
105107
# htop version is usually first line
106-
version_line = result.stdout.split('\n')[0]
108+
version_line = result.stdout.split("\n")[0]
107109
print(f" {version_line}")
108110
else:
109111
print("✗ htop is not installed")
@@ -114,7 +116,7 @@ def main():
114116
# Verify installation
115117
result = server.exec(daemon_id, "htop --version", timeout=5)
116118
if result.success:
117-
version_line = result.stdout.split('\n')[0]
119+
version_line = result.stdout.split("\n")[0]
118120
print(f" {version_line}")
119121
else:
120122
print("Failed to install htop")
@@ -139,10 +141,10 @@ def main():
139141
if result.success:
140142
print(f"✓ {description}")
141143
# Show first few lines of output
142-
output_lines = result.stdout.strip().split('\n')[:3]
144+
output_lines = result.stdout.strip().split("\n")[:3]
143145
for line in output_lines:
144146
print(f" {line}")
145-
if len(result.stdout.strip().split('\n')) > 3:
147+
if len(result.stdout.strip().split("\n")) > 3:
146148
print(" ...")
147149
print()
148150
else:

‎examples/programmatic_session.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,14 @@ def main():
7878
# Example 4: Create new session for long-running task
7979
print("\n=== Example 4: Long-Running Task ===")
8080
session2 = server.new_session(daemon_id)
81-
session2.write(b"for i in 1 2 3; do echo \"Step $i\"; sleep 1; done\n")
81+
session2.write(b'for i in 1 2 3; do echo "Step $i"; sleep 1; done\n')
8282

8383
# Stream output as it arrives
8484
start = time.time()
8585
while time.time() - start < 5:
8686
output = session2.read(timeout=0.5)
8787
if output:
88-
print(output.decode(), end='', flush=True)
88+
print(output.decode(), end="", flush=True)
8989
else:
9090
break
9191

0 commit comments

Comments
 (0)