本次变更是对版本控制功能的大范围重构,核心目标:
- 默认使用浅备份(只追踪 write_file/edit_file 实际修改的文件),避免大工作区首次初始化耗时十几秒;
- 保留可选的完全备份(git write-tree),不污染 git log;
- 回溯时统一选择范围(仅对话 / 对话+工作区)和模式(覆盖 / 复制);
- 修复复制对话、侧边栏同步、新建对话延迟、diff 统计与渲染等连锁问题。
后端改动:
- modules/shallow_versioning.py(新增)
- 实现 ShallowVersioningManager:track_edit、make_snapshot、rewind、list_snapshots。
- 基于 difflib.SequenceMatcher 计算真实 insertions/deletions,replace 场景正确显示 +N/-M。
- diff 详情使用结构化 add/remove/context 行,不再解析 unified_diff 文本。
- modules/versioning_manager.py
- 完全备份从 git commit 改为 git write-tree,字段 commit/parent_commit 改为 tree_hash/parent_tree_hash。
- 恢复改用 read-tree + checkout-index;create_checkpoint 支持 diff_summary 参数。
- get_checkpoint_detail 保留 API 字段名 last_commit/restored_commit 以兼容前端。
- core/main_terminal_parts/tools_execution.py
- write_file/edit_file 在执行前调用浅备份 track_edit,保留编辑前状态。
- server/chat_flow_task_main.py
- 消息轮次结束时根据 backup_mode 选择完全备份 create_checkpoint 或浅备份 make_snapshot。
- 浅备份模式下同时写入 ConversationVersioningManager 检查点行,让前端能统一展示。
- server/conversation.py
- restore/detail/list API 支持 shallow 模式;list/detail 对浅备份实时重新计算统计。
- 创建对话时读取 personalization 的 versioning_backup_mode,shallow 模式固定 tracking_mode 为 conversation_only。
- core/web_terminal.py
- _ensure_conversation_versioning_enabled 读取默认备份模式并写入 versioning meta。
- modules/file_manager/crud_mixin.py / replace_mixin.py
- write_file / edit_file 返回 original_file / new_file / replacement_details(真实旧行/新行)。
- modules/personalization_manager.py
- 新增 versioning_enabled_by_default、versioning_backup_mode(shallow/full)。
- utils/perf_log.py(新增)
- 创建新对话全链路性能日志,用于定位延迟瓶颈。
- utils/conversation_manager/{index,list_search,crud}_mixin.py
- 加性能日志;index_mixin 主动检查新增对话文件,修复侧边栏不同步。
前端改动:
- static/src/components/overlay/VersioningDialog.vue
- 去掉顶部管理范围下拉;底部新增回溯范围与回溯模式选择。
- diff 详情改为 div.diff-line 网格布局(marker + content),支持横向滚动,状态显示缩写 A/M/D。
- 修复 pre 标签空白黑行、容器被挤窄导致折行等问题。
- static/src/app/methods/versioning.ts
- 移除 mismatch 弹窗逻辑;支持 copy 模式;selectVersioningCheckpoint 增强错误捕获。
- static/src/components/personalization/PersonalizationDrawer.vue
- 「工作区与权限」新增版本控制默认开关与备份方式选择。
- static/src/stores/personalization.ts
- 新增 versioning_enabled_by_default、versioning_backup_mode 字段。
- static/src/components/chat/actions/ToolAction.vue / toolRenderers.ts
- edit_file / write_file 工具展示基于 result.details / result.new_file 的真实替换结果。
- static/src/app/state.ts / watchers.ts / App.vue
- 新增 versioningInitializingBackupToastId,完全备份初始化时显示 toast。
- static/src/app/methods/conversation/action.ts / message/send.ts
- host + 完全备份 + 默认开启时,创建新对话/从 /new 发送首条消息显示「正在初始化备份」toast。
验证:
- /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9 -m unittest test.test_server_refactor_smoke 通过。
- npm run build 通过。
- 宿主机与 docker 模式新建对话、编辑文件、回溯、复制对话均验证通过。
498 lines
20 KiB
Python
498 lines
20 KiB
Python
"""Conversation-scoped shallow file versioning for workspace edits.
|
|
|
|
Mirrors Claude Code's file-history approach:
|
|
- Only files touched by write_file / edit_file are tracked.
|
|
- Before each edit the current content is backed up.
|
|
- A snapshot is taken per user message, referencing the latest backup version
|
|
of every tracked file at that point in time.
|
|
- Rewind restores the tracked files to the state recorded for a target message.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
|
|
class ShallowVersioningError(RuntimeError):
|
|
"""Raised when shallow versioning fails."""
|
|
|
|
|
|
@dataclass
|
|
class FileBackup:
|
|
backup_file_name: Optional[str] # null means the file did not exist at that version
|
|
version: int
|
|
backup_time: str
|
|
|
|
|
|
@dataclass
|
|
class ShallowSnapshot:
|
|
message_id: str
|
|
tracked_file_backups: Dict[str, FileBackup]
|
|
timestamp: str
|
|
|
|
|
|
class ShallowVersioningManager:
|
|
"""Lightweight per-conversation backup for files edited by AI tools."""
|
|
|
|
MAX_SNAPSHOTS = 100
|
|
|
|
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
|
|
self.project_path = Path(project_path).expanduser().resolve()
|
|
self.conversation_id = str(conversation_id or "").strip()
|
|
if not self.conversation_id:
|
|
raise ShallowVersioningError("缺少 conversation_id")
|
|
self.save_root = (Path(data_dir).expanduser().resolve() / "save" / self.conversation_id).resolve()
|
|
self.backup_dir = self.save_root / "shallow_backups"
|
|
self.state_file = self.backup_dir / "state.jsonl"
|
|
self._tracked_files: Set[str] = set()
|
|
self._snapshots: List[ShallowSnapshot] = []
|
|
self._snapshot_sequence = 0
|
|
self._load_state()
|
|
|
|
# ----------------------------
|
|
# persistence
|
|
# ----------------------------
|
|
def _load_state(self) -> None:
|
|
if not self.state_file.exists():
|
|
return
|
|
try:
|
|
for raw in self.state_file.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
line = raw.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
row = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
if not isinstance(row, dict):
|
|
continue
|
|
snapshot = self._deserialize_snapshot(row)
|
|
if snapshot:
|
|
self._snapshots.append(snapshot)
|
|
self._snapshot_sequence += 1
|
|
for path in snapshot.tracked_file_backups:
|
|
self._tracked_files.add(path)
|
|
except OSError:
|
|
pass
|
|
|
|
def _persist_snapshot(self, snapshot: ShallowSnapshot) -> None:
|
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
with self.state_file.open("a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(self._serialize_snapshot(snapshot), ensure_ascii=False) + "\n")
|
|
|
|
def _serialize_snapshot(self, snapshot: ShallowSnapshot) -> Dict[str, Any]:
|
|
return {
|
|
"message_id": snapshot.message_id,
|
|
"timestamp": snapshot.timestamp,
|
|
"tracked_file_backups": {
|
|
path: {
|
|
"backup_file_name": backup.backup_file_name,
|
|
"version": backup.version,
|
|
"backup_time": backup.backup_time,
|
|
}
|
|
for path, backup in snapshot.tracked_file_backups.items()
|
|
},
|
|
}
|
|
|
|
def _deserialize_snapshot(self, row: Dict[str, Any]) -> Optional[ShallowSnapshot]:
|
|
if not isinstance(row.get("message_id"), str):
|
|
return None
|
|
backups: Dict[str, FileBackup] = {}
|
|
for path, backup_raw in (row.get("tracked_file_backups") or {}).items():
|
|
if not isinstance(backup_raw, dict):
|
|
continue
|
|
backups[str(path)] = FileBackup(
|
|
backup_file_name=backup_raw.get("backup_file_name"),
|
|
version=int(backup_raw.get("version") or 1),
|
|
backup_time=str(backup_raw.get("backup_time") or datetime.now().isoformat()),
|
|
)
|
|
return ShallowSnapshot(
|
|
message_id=row["message_id"],
|
|
tracked_file_backups=backups,
|
|
timestamp=str(row.get("timestamp") or datetime.now().isoformat()),
|
|
)
|
|
|
|
# ----------------------------
|
|
# path helpers
|
|
# ----------------------------
|
|
def _normalize_path(self, file_path: Path | str) -> str:
|
|
path = Path(file_path).expanduser()
|
|
if not path.is_absolute():
|
|
path = self.project_path / path
|
|
try:
|
|
rel = path.resolve().relative_to(self.project_path)
|
|
return str(rel).replace("\\", "/")
|
|
except ValueError:
|
|
return str(path).replace("\\", "/")
|
|
|
|
def _resolve_file_path(self, tracking_path: str) -> Path:
|
|
if Path(tracking_path).is_absolute():
|
|
return Path(tracking_path).expanduser().resolve()
|
|
return (self.project_path / tracking_path).resolve()
|
|
|
|
def _backup_file_name(self, tracking_path: str, version: int) -> str:
|
|
file_name_hash = hashlib.sha256(tracking_path.encode("utf-8")).hexdigest()[:16]
|
|
return f"{file_name_hash}@v{version}"
|
|
|
|
def _backup_path(self, backup_file_name: str) -> Path:
|
|
return (self.backup_dir / backup_file_name).resolve()
|
|
|
|
# ----------------------------
|
|
# backup operations
|
|
# ----------------------------
|
|
def _create_backup(self, file_path: Path, version: int) -> FileBackup:
|
|
backup_file_name = self._backup_file_name(str(file_path), version)
|
|
backup_path = self._backup_path(backup_file_name)
|
|
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not file_path.exists():
|
|
return FileBackup(backup_file_name=None, version=version, backup_time=datetime.now().isoformat())
|
|
|
|
shutil.copy2(file_path, backup_path)
|
|
return FileBackup(
|
|
backup_file_name=backup_file_name,
|
|
version=version,
|
|
backup_time=datetime.now().isoformat(),
|
|
)
|
|
|
|
def _get_latest_backup_version(self, tracking_path: str) -> Optional[FileBackup]:
|
|
latest: Optional[FileBackup] = None
|
|
for snapshot in self._snapshots:
|
|
backup = snapshot.tracked_file_backups.get(tracking_path)
|
|
if backup and (latest is None or backup.version > latest.version):
|
|
latest = backup
|
|
return latest
|
|
|
|
def _file_changed_since_backup(self, file_path: Path, backup: FileBackup) -> bool:
|
|
if backup.backup_file_name is None:
|
|
return file_path.exists()
|
|
backup_path = self._backup_path(backup.backup_file_name)
|
|
if not file_path.exists():
|
|
return True
|
|
if not backup_path.exists():
|
|
return True
|
|
try:
|
|
if file_path.stat().st_size != backup_path.stat().st_size:
|
|
return True
|
|
current = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
saved = backup_path.read_text(encoding="utf-8", errors="ignore")
|
|
return current != saved
|
|
except Exception:
|
|
return True
|
|
|
|
# ----------------------------
|
|
# public API
|
|
# ----------------------------
|
|
def track_edit(self, file_path: Path | str, message_id: str) -> None:
|
|
"""Track a file before it is edited; backup its current content if not already tracked."""
|
|
tracking_path = self._normalize_path(file_path)
|
|
from utils.perf_log import perf_log
|
|
perf_log("[ShallowVersioning] track_edit called", extra={
|
|
"tracking_path": tracking_path,
|
|
"message_id": message_id,
|
|
"already_tracked": tracking_path in self._tracked_files,
|
|
"snapshots_count": len(self._snapshots),
|
|
})
|
|
if tracking_path in self._tracked_files:
|
|
return
|
|
|
|
resolved = self._resolve_file_path(tracking_path)
|
|
backup = self._create_backup(resolved, 1)
|
|
self._tracked_files.add(tracking_path)
|
|
|
|
# Add to the most recent snapshot if one exists, otherwise create a pre-edit snapshot.
|
|
now = datetime.now().isoformat()
|
|
if self._snapshots:
|
|
latest = self._snapshots[-1]
|
|
latest.tracked_file_backups[tracking_path] = backup
|
|
self._persist_snapshot(latest)
|
|
else:
|
|
snapshot = ShallowSnapshot(
|
|
message_id=message_id,
|
|
tracked_file_backups={tracking_path: backup},
|
|
timestamp=now,
|
|
)
|
|
self._snapshots.append(snapshot)
|
|
self._snapshot_sequence += 1
|
|
self._persist_snapshot(snapshot)
|
|
|
|
def make_snapshot(self, message_id: str) -> Dict[str, Any]:
|
|
"""Create a snapshot for the given message, backing up changed tracked files."""
|
|
from utils.perf_log import perf_log
|
|
perf_log("[ShallowVersioning] make_snapshot called", extra={
|
|
"message_id": message_id,
|
|
"tracked_files_count": len(self._tracked_files),
|
|
"snapshots_count": len(self._snapshots),
|
|
})
|
|
tracked_file_backups: Dict[str, FileBackup] = {}
|
|
now = datetime.now().isoformat()
|
|
|
|
for tracking_path in self._tracked_files:
|
|
resolved = self._resolve_file_path(tracking_path)
|
|
latest_backup = self._get_latest_backup_version(tracking_path)
|
|
next_version = (latest_backup.version + 1) if latest_backup else 1
|
|
|
|
if latest_backup and not self._file_changed_since_backup(resolved, latest_backup):
|
|
tracked_file_backups[tracking_path] = latest_backup
|
|
continue
|
|
|
|
tracked_file_backups[tracking_path] = self._create_backup(resolved, next_version)
|
|
|
|
# Merge in backups from the latest snapshot for any files that may have been added
|
|
# by track_edit during the async window.
|
|
if self._snapshots:
|
|
for tracking_path, backup in self._snapshots[-1].tracked_file_backups.items():
|
|
if tracking_path not in tracked_file_backups:
|
|
tracked_file_backups[tracking_path] = backup
|
|
|
|
snapshot = ShallowSnapshot(
|
|
message_id=message_id,
|
|
tracked_file_backups=tracked_file_backups,
|
|
timestamp=now,
|
|
)
|
|
self._snapshots.append(snapshot)
|
|
self._snapshot_sequence += 1
|
|
self._persist_snapshot(snapshot)
|
|
|
|
# Evict old snapshots while keeping the most recent MAX_SNAPSHOTS.
|
|
if len(self._snapshots) > self.MAX_SNAPSHOTS:
|
|
self._snapshots = self._snapshots[-self.MAX_SNAPSHOTS :]
|
|
|
|
perf_log("[ShallowVersioning] make_snapshot done", extra={
|
|
"message_id": message_id,
|
|
"tracked_files_count": len(tracked_file_backups),
|
|
"backups": {k: v.backup_file_name for k, v in tracked_file_backups.items()},
|
|
})
|
|
return {
|
|
"message_id": message_id,
|
|
"tracked_files_count": len(tracked_file_backups),
|
|
"timestamp": now,
|
|
}
|
|
|
|
def can_restore(self, message_id: str) -> bool:
|
|
return any(snapshot.message_id == message_id for snapshot in self._snapshots)
|
|
|
|
def rewind(self, message_id: str) -> Dict[str, Any]:
|
|
"""Restore tracked files to the state recorded for the target message."""
|
|
target_snapshot: Optional[ShallowSnapshot] = None
|
|
for snapshot in reversed(self._snapshots):
|
|
if snapshot.message_id == message_id:
|
|
target_snapshot = snapshot
|
|
break
|
|
if not target_snapshot:
|
|
raise ShallowVersioningError(f"未找到消息 {message_id} 对应的快照")
|
|
|
|
files_changed: List[str] = []
|
|
for tracking_path in self._tracked_files:
|
|
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
|
|
if not target_backup:
|
|
# File was not tracked at the target snapshot; leave it unchanged.
|
|
continue
|
|
|
|
resolved = self._resolve_file_path(tracking_path)
|
|
if target_backup.backup_file_name is None:
|
|
if resolved.exists():
|
|
resolved.unlink()
|
|
files_changed.append(tracking_path)
|
|
continue
|
|
|
|
backup_path = self._backup_path(target_backup.backup_file_name)
|
|
if not backup_path.exists():
|
|
continue
|
|
if self._file_changed_since_backup(resolved, target_backup):
|
|
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(backup_path, resolved)
|
|
files_changed.append(tracking_path)
|
|
|
|
return {
|
|
"success": True,
|
|
"message_id": message_id,
|
|
"files_changed": files_changed,
|
|
}
|
|
|
|
def _read_backup_lines(self, backup: Optional[FileBackup]) -> List[str]:
|
|
"""Read lines from a backup entry; empty list for missing/deleted files."""
|
|
if not backup or backup.backup_file_name is None:
|
|
return []
|
|
backup_path = self._backup_path(backup.backup_file_name)
|
|
if not backup_path.exists():
|
|
return []
|
|
try:
|
|
return backup_path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
except Exception:
|
|
return []
|
|
|
|
def _count_line_changes(self, previous_lines: List[str], target_lines: List[str]) -> tuple[int, int]:
|
|
"""Count real insertions/deletions between two line lists using SequenceMatcher."""
|
|
if previous_lines == target_lines:
|
|
return 0, 0
|
|
try:
|
|
import difflib
|
|
sm = difflib.SequenceMatcher(None, previous_lines, target_lines)
|
|
except Exception:
|
|
# Fallback to naive delta if difflib fails.
|
|
if len(previous_lines) > len(target_lines):
|
|
return 0, len(previous_lines) - len(target_lines)
|
|
return len(target_lines) - len(previous_lines), 0
|
|
|
|
insertions = 0
|
|
deletions = 0
|
|
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
if tag == "insert":
|
|
insertions += j2 - j1
|
|
elif tag == "delete":
|
|
deletions += i2 - i1
|
|
elif tag == "replace":
|
|
insertions += j2 - j1
|
|
deletions += i2 - i1
|
|
return insertions, deletions
|
|
|
|
def _find_snapshot_pair(self, message_id: str):
|
|
"""Return (target_snapshot, previous_snapshot) for the given message_id."""
|
|
target_snapshot: Optional[ShallowSnapshot] = None
|
|
target_index = -1
|
|
for idx, snapshot in enumerate(self._snapshots):
|
|
if snapshot.message_id == message_id:
|
|
target_snapshot = snapshot
|
|
target_index = idx
|
|
if not target_snapshot or target_index < 0:
|
|
return None, None
|
|
previous_snapshot = self._snapshots[target_index - 1] if target_index > 0 else None
|
|
return target_snapshot, previous_snapshot
|
|
|
|
def get_diff_stats(self, message_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Compute insertions/deletions/files_changed between the target snapshot and the previous snapshot."""
|
|
target_snapshot, previous_snapshot = self._find_snapshot_pair(message_id)
|
|
if not target_snapshot:
|
|
return None
|
|
|
|
files_changed: List[str] = []
|
|
insertions = 0
|
|
deletions = 0
|
|
|
|
for tracking_path in self._tracked_files:
|
|
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
|
|
if not target_backup:
|
|
continue
|
|
|
|
previous_backup = previous_snapshot.tracked_file_backups.get(tracking_path) if previous_snapshot else None
|
|
previous_lines = self._read_backup_lines(previous_backup)
|
|
target_lines = self._read_backup_lines(target_backup)
|
|
|
|
if previous_lines == target_lines:
|
|
continue
|
|
|
|
files_changed.append(tracking_path)
|
|
ins, dels = self._count_line_changes(previous_lines, target_lines)
|
|
insertions += ins
|
|
deletions += dels
|
|
|
|
return {
|
|
"filesChanged": files_changed,
|
|
"insertions": insertions,
|
|
"deletions": deletions,
|
|
}
|
|
|
|
def has_any_changes(self, message_id: str) -> bool:
|
|
stats = self.get_diff_stats(message_id)
|
|
if not stats:
|
|
return False
|
|
return bool(stats.get("filesChanged"))
|
|
|
|
def get_file_diff_stats(self, message_id: str) -> List[Dict[str, Any]]:
|
|
"""Return per-file insertions/deletions/status for a target snapshot vs previous snapshot."""
|
|
target_snapshot, previous_snapshot = self._find_snapshot_pair(message_id)
|
|
if not target_snapshot:
|
|
return []
|
|
|
|
result: List[Dict[str, Any]] = []
|
|
for tracking_path in self._tracked_files:
|
|
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
|
|
if not target_backup:
|
|
continue
|
|
|
|
previous_backup = previous_snapshot.tracked_file_backups.get(tracking_path) if previous_snapshot else None
|
|
previous_lines = self._read_backup_lines(previous_backup)
|
|
target_lines = self._read_backup_lines(target_backup)
|
|
|
|
if previous_lines == target_lines:
|
|
continue
|
|
|
|
insertions, deletions = self._count_line_changes(previous_lines, target_lines)
|
|
|
|
resolved = self._resolve_file_path(tracking_path)
|
|
status = "deleted" if target_backup.backup_file_name is None else ("added" if previous_backup is None or previous_backup.backup_file_name is None else "modified")
|
|
result.append({
|
|
"path": tracking_path,
|
|
"status": status,
|
|
"insertions": insertions,
|
|
"deletions": deletions,
|
|
})
|
|
return result
|
|
|
|
def get_file_patch_lines(
|
|
self,
|
|
tracking_path: str,
|
|
message_id: str,
|
|
max_lines: int = 600,
|
|
) -> Dict[str, Any]:
|
|
"""Return contextual patch lines between target snapshot and previous snapshot."""
|
|
target_snapshot, previous_snapshot = self._find_snapshot_pair(message_id)
|
|
if not target_snapshot:
|
|
return {"lines": [], "truncated": False}
|
|
|
|
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
|
|
if not target_backup:
|
|
return {"lines": [], "truncated": False}
|
|
|
|
previous_backup = previous_snapshot.tracked_file_backups.get(tracking_path) if previous_snapshot else None
|
|
previous_lines = self._read_backup_lines(previous_backup)
|
|
target_lines = self._read_backup_lines(target_backup)
|
|
|
|
if previous_lines == target_lines:
|
|
return {"lines": [], "truncated": False}
|
|
|
|
try:
|
|
import difflib
|
|
sm = difflib.SequenceMatcher(None, previous_lines, target_lines)
|
|
except Exception:
|
|
return {"lines": [], "truncated": False}
|
|
|
|
lines: List[Dict[str, Any]] = []
|
|
truncated = False
|
|
for tag, i1, i2, j1, j2 in sm.get_opcodes():
|
|
if tag == "equal":
|
|
continue
|
|
if tag == "replace" or tag == "delete":
|
|
for line in previous_lines[i1:i2]:
|
|
if len(lines) >= max_lines:
|
|
truncated = True
|
|
break
|
|
lines.append({"type": "remove", "content": line})
|
|
if tag == "replace" or tag == "insert":
|
|
for line in target_lines[j1:j2]:
|
|
if len(lines) >= max_lines:
|
|
truncated = True
|
|
break
|
|
lines.append({"type": "add", "content": line})
|
|
if truncated:
|
|
break
|
|
return {"lines": lines, "truncated": truncated}
|
|
|
|
def get_snapshot_by_seq(self, seq: int) -> Optional[ShallowSnapshot]:
|
|
"""Return the user-message snapshot by 1-based index (skips the initial track_edit snapshot)."""
|
|
user_snapshots = [s for s in self._snapshots if s.message_id != self.conversation_id]
|
|
idx = int(seq) - 1
|
|
if idx < 0 or idx >= len(user_snapshots):
|
|
return None
|
|
return user_snapshots[idx]
|
|
|
|
def list_snapshots(self) -> List[Dict[str, Any]]:
|
|
return [self._serialize_snapshot(s) for s in self._snapshots]
|