ckriti/HuggingClaw
0
1#!/usr/bin/env python32"""3OpenClaw Full Directory Persistence for Hugging Face Spaces4========================================================5 6This script provides atomic, complete persistence of the entire ~/.openclaw directory.7It implements the comprehensive persistence plan:8 9- Config & Credentials (openclaw.json, credentials/)10- Workspace (workspace/ with AGENTS.md, SOUL.md, TOOLS.md, MEMORY.md, skills/, memory/)11- Sessions (agents/*/sessions/*.jsonl)12- Memory Index (memory/*.sqlite)13- QMD Backend (agents/*/qmd/)14- Extensions (extensions/)15- All other state in ~/.openclaw16 17Usage:18 # Backup (save)19 python3 openclaw_persist.py save20 21 # Restore (load)22 python3 openclaw_persist.py load23 24Environment Variables:25 HF_TOKEN - Hugging Face access token with write permissions26 OPENCLAW_DATASET_REPO - Dataset repo ID (e.g., "username/openclaw-state")27 OPENCLAW_HOME - OpenClaw home directory (default: ~/.openclaw)28"""29 30import os31import sys32import json33import tarfile34import tempfile35import shutil36import hashlib37import time38import signal39from datetime import datetime40from pathlib import Path41from typing import Optional, List, Set, Dict, Any42 43from huggingface_hub import HfApi, hf_hub_download44from huggingface_hub.utils import RepositoryNotFoundError45 46 47# ============================================================================48# Configuration49# ============================================================================50 51class Config:52 """Configuration for persistence system"""53 54 # Paths55 OPENCLAW_HOME = Path(os.environ.get("OPENCLAW_HOME", "~/.openclaw")).expanduser()56 BACKUP_FILENAME = "openclaw-full.tar.gz"57 BACKUP_STATE_FILE = ".persistence-state.json"58 LOCK_FILE = ".persistence.lock"59 60 # Backup rotation settings61 MAX_BACKUPS = 562 BACKUP_PREFIX = "backup-"63 64 # Patterns to exclude from backup65 EXCLUDE_PATTERNS = [66 "*.lock",67 "*.tmp",68 "*.pyc",69 "*__pycache__*",70 "*.socket",71 "*.pid",72 "node_modules",73 ".DS_Store",74 ".git",75 ]76 77 # Directories to skip entirely (relative to OPENCLAW_HOME)78 SKIP_DIRS = {79 ".cache",80 "logs",81 "temp",82 "tmp",83 }84 85 86# ============================================================================87# Utility Functions88# ============================================================================89 90def log(level: str, message: str, **kwargs):91 """Structured logging"""92 timestamp = datetime.now().isoformat()93 log_entry = {94 "timestamp": timestamp,95 "level": level,96 "message": message,97 **kwargs98 }99 print(json.dumps(log_entry), flush=True)100 101 102def calculate_file_hash(filepath: Path) -> str:103 """Calculate SHA256 hash of a file"""104 sha256 = hashlib.sha256()105 try:106 with open(filepath, "rb") as f:107 for chunk in iter(lambda: f.read(65536), b""):108 sha256.update(chunk)109 return sha256.hexdigest()110 except Exception:111 return ""112 113 114def get_directory_size(directory: Path) -> int:115 """Calculate total size of directory in bytes"""116 total_size = 0117 try:118 for dirpath, dirnames, filenames in os.walk(directory):119 for filename in filenames:120 filepath = Path(dirpath) / filename121 try:122 total_size += filepath.stat().st_size123 except Exception:124 pass125 except Exception:126 pass127 return total_size128 129 130def should_exclude(path: str, exclude_patterns: List[str]) -> bool:131 """Check if a path should be excluded based on patterns"""132 path_normalized = path.replace("\\", "/")133 134 for pattern in exclude_patterns:135 pattern = pattern.lstrip("/")136 if pattern.startswith("*"):137 suffix = pattern[1:]138 if path_normalized.endswith(suffix):139 return True140 elif pattern in path_normalized:141 return True142 143 return False144 145 146# ============================================================================147# Persistence Manager148# ============================================================================149 150class OpenClawPersistence:151 """152 Manages persistence of OpenClaw state to Hugging Face Dataset153 154 Features:155 - Atomic full-directory backup/restore156 - Proper exclusion of lock files and temporary data157 - Safe handling of SQLite databases158 - Backup rotation159 - Integrity verification160 """161 162 def __init__(self):163 self.api = None164 self.repo_id = os.environ.get("OPENCLAW_DATASET_REPO")165 self.token = os.environ.get("HF_TOKEN")166 self.home_dir = Config.OPENCLAW_HOME167 self.lock_file = self.home_dir / Config.LOCK_FILE168 self.state_file = self.home_dir / Config.BACKUP_STATE_FILE169 170 # Validate configuration171 if not self.repo_id:172 log("ERROR", "OPENCLAW_DATASET_REPO not set")173 raise ValueError("OPENCLAW_DATASET_REPO environment variable required")174 175 if not self.token:176 log("ERROR", "HF_TOKEN not set")177 raise ValueError("HF_TOKEN environment variable required")178 179 # Initialize API180 self.api = HfApi(token=self.token)181 182 log("INFO", "Initialized persistence manager",183 repo_id=self.repo_id,184 home_dir=str(self.home_dir))185 186 # -----------------------------------------------------------------------187 # Backup Operations188 # -----------------------------------------------------------------------189 190 def save(self) -> Dict[str, Any]:191 """192 Save current state to Hugging Face Dataset193 194 Creates a complete backup of ~/.openclaw directory as a tar.gz file.195 """196 operation_id = f"save-{int(time.time())}"197 start_time = time.time()198 199 log("INFO", "Starting save operation", operation_id=operation_id)200 201 # Check if home directory exists202 if not self.home_dir.exists():203 log("WARNING", "OpenClaw home directory does not exist, creating")204 self.home_dir.mkdir(parents=True, exist_ok=True)205 206 # Check for existing lock207 if self.lock_file.exists():208 log("WARNING", "Lock file exists, another operation may be in progress")209 # Continue anyway, but log warning210 211 # Create lock file212 try:213 self.lock_file.write_text(str(os.getpid()))214 except Exception as e:215 log("WARNING", "Could not create lock file", error=str(e))216 217 try:218 # Get directory info219 dir_size = get_directory_size(self.home_dir)220 log("INFO", "Directory size calculated",221 size_bytes=dir_size,222 size_mb=f"{dir_size / (1024*1024):.2f}")223 224 # Create tar archive225 with tempfile.TemporaryDirectory() as tmpdir:226 tar_path = Path(tmpdir) / Config.BACKUP_FILENAME227 manifest = self._create_tar_archive(tar_path)228 229 # Read archive info230 tar_size = tar_path.stat().st_size231 log("INFO", "Archive created",232 size_bytes=tar_size,233 size_mb=f"{tar_size / (1024*1024):.2f}",234 files_count=manifest["file_count"])235 236 # Upload to dataset237 remote_path = f"{Config.BACKUP_PREFIX}{datetime.now().strftime('%Y%m%d_%H%M%S')}.tar.gz"238 upload_result = self._upload_archive(tar_path, remote_path)239 240 # Update state file241 self._update_state({242 "last_save_time": datetime.now().isoformat(),243 "last_save_operation": operation_id,244 "last_save_remote_path": remote_path,245 "last_save_commit": upload_result.get("commit_id"),246 "last_save_manifest": manifest,247 })248 249 # Rotate old backups250 self._rotate_backups()251 252 duration = time.time() - start_time253 log("INFO", "Save completed successfully",254 operation_id=operation_id,255 duration_seconds=f"{duration:.2f}")256 257 return {258 "success": True,259 "operation_id": operation_id,260 "remote_path": remote_path,261 "commit_id": upload_result.get("commit_id"),262 "duration": duration,263 "manifest": manifest264 }265 266 except Exception as e:267 log("ERROR", "Save operation failed",268 operation_id=operation_id,269 error=str(e),270 exc_info=True)271 return {272 "success": False,273 "operation_id": operation_id,274 "error": str(e)275 }276 finally:277 # Remove lock file278 if self.lock_file.exists():279 try:280 self.lock_file.unlink()281 except Exception:282 pass283 284 def _create_tar_archive(self, tar_path: Path) -> Dict[str, Any]:285 """Create tar.gz archive of OpenClaw home directory"""286 manifest = {287 "created_at": datetime.now().isoformat(),288 "version": "1.0",289 "file_count": 0,290 "excluded_patterns": [],291 "included_dirs": [],292 "skipped_dirs": [],293 }294 295 excluded_count = 0296 297 def tar_filter(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]:298 nonlocal excluded_count, manifest299 300 # Skip lock file itself301 if tarinfo.name.endswith(Config.LOCK_FILE):302 excluded_count += 1303 return None304 305 # Skip state file (will be written after backup)306 if tarinfo.name.endswith(Config.BACKUP_STATE_FILE):307 return None308 309 # Get relative path310 rel_path = tarinfo.name311 if rel_path.startswith("./"):312 rel_path = rel_path[2:]313 314 # Check exclusion patterns315 if should_exclude(rel_path, Config.EXCLUDE_PATTERNS):316 excluded_count += 1317 manifest["excluded_patterns"].append(rel_path)318 return None319 320 # Check if parent directory should be skipped321 path_parts = Path(rel_path).parts322 if path_parts and path_parts[0] in Config.SKIP_DIRS:323 excluded_count += 1324 return None325 326 # Track included327 manifest["file_count"] += 1328 if path_parts and path_parts[0] not in manifest["included_dirs"]:329 manifest["included_dirs"].append(path_parts[0])330 331 return tarinfo332 333 # Create archive334 with tarfile.open(tar_path, "w:gz") as tar:335 tar.add(self.home_dir, arcname=".", filter=tar_filter)336 337 manifest["excluded_count"] = excluded_count338 manifest["skipped_dirs"] = list(Config.SKIP_DIRS)339 340 return manifest341 342 def _upload_archive(self, local_path: Path, remote_path: str) -> Dict[str, Any]:343 """Upload archive to Hugging Face Dataset"""344 try:345 # Ensure repo exists346 try:347 self.api.repo_info(repo_id=self.repo_id, repo_type="dataset")348 except RepositoryNotFoundError:349 log("INFO", "Creating new dataset repository")350 self.api.create_repo(351 repo_id=self.repo_id,352 repo_type="dataset",353 private=True354 )355 356 # Upload file357 commit_info = self.api.upload_file(358 path_or_fileobj=str(local_path),359 path_in_repo=remote_path,360 repo_id=self.repo_id,361 repo_type="dataset",362 commit_message=f"OpenClaw state backup - {datetime.now().isoformat()}"363 )364 365 log("INFO", "File uploaded successfully",366 remote_path=remote_path,367 commit_url=commit_info.commit_url)368 369 return {370 "success": True,371 "commit_id": commit_info.oid,372 "commit_url": commit_info.commit_url373 }374 375 except Exception as e:376 log("ERROR", "Upload failed", error=str(e))377 raise378 379 def _update_state(self, state_update: Dict[str, Any]):380 """Update persistence state file"""381 try:382 current_state = {}383 if self.state_file.exists():384 with open(self.state_file, 'r') as f:385 current_state = json.load(f)386 387 current_state.update(state_update)388 389 self.state_file.parent.mkdir(parents=True, exist_ok=True)390 with open(self.state_file, 'w') as f:391 json.dump(current_state, f, indent=2)392 393 except Exception as e:394 log("WARNING", "Could not update state file", error=str(e))395 396 def _rotate_backups(self):397 """Rotate old backups, keeping only MAX_BACKUPS most recent"""398 try:399 files = self.api.list_repo_files(400 repo_id=self.repo_id,401 repo_type="dataset"402 )403 404 # Get backup files405 backups = [406 f for f in files407 if f.startswith(Config.BACKUP_PREFIX) and f.endswith(".tar.gz")408 ]409 410 # Sort by name (which includes timestamp)411 backups = sorted(backups)412 413 # Delete old backups414 if len(backups) > Config.MAX_BACKUPS:415 to_delete = backups[:-Config.MAX_BACKUPS]416 log("INFO", "Rotating backups",417 total=len(backups),418 keeping=Config.MAX_BACKUPS,419 deleting=len(to_delete))420 421 for old_backup in to_delete:422 try:423 self.api.delete_file(424 path_in_repo=old_backup,425 repo_id=self.repo_id,426 repo_type="dataset"427 )428 log("INFO", "Deleted old backup", file=old_backup)429 except Exception as e:430 log("WARNING", "Could not delete backup",431 file=old_backup,432 error=str(e))433 434 except Exception as e:435 log("WARNING", "Backup rotation failed", error=str(e))436 437 # -----------------------------------------------------------------------438 # Restore Operations439 # -----------------------------------------------------------------------440 441 def load(self, force: bool = False) -> Dict[str, Any]:442 """443 Load state from Hugging Face Dataset444 445 Restores the most recent backup. If force is False and local state446 exists, it will create a backup before restoring.447 """448 operation_id = f"load-{int(time.time())}"449 start_time = time.time()450 451 log("INFO", "Starting load operation",452 operation_id=operation_id,453 force=force)454 455 try:456 # Get latest backup457 backup_info = self._find_latest_backup()458 459 if not backup_info:460 log("WARNING", "No backups found, starting fresh")461 # Ensure home directory exists462 self.home_dir.mkdir(parents=True, exist_ok=True)463 return {464 "success": True,465 "operation_id": operation_id,466 "restored": False,467 "message": "No backups found, starting fresh"468 }469 470 log("INFO", "Found backup to restore",471 backup_file=backup_info["filename"],472 timestamp=backup_info.get("timestamp"))473 474 # Create local backup if state exists475 if self.home_dir.exists() and not force:476 backup_dir = self._create_local_backup()477 log("INFO", "Created local backup", backup_dir=str(backup_dir))478 479 # Download and extract480 with tempfile.TemporaryDirectory() as tmpdir:481 tar_path = Path(tmpdir) / "backup.tar.gz"482 483 # Download backup484 log("INFO", "Downloading backup...")485 downloaded_path = hf_hub_download(486 repo_id=self.repo_id,487 filename=backup_info["filename"],488 repo_type="dataset",489 token=self.token,490 local_dir=tmpdir,491 local_dir_use_symlinks=False492 )493 494 # Extract archive495 log("INFO", "Extracting archive...")496 self._extract_archive(downloaded_path)497 498 duration = time.time() - start_time499 log("INFO", "Load completed successfully",500 operation_id=operation_id,501 duration_seconds=f"{duration:.2f}")502 503 return {504 "success": True,505 "operation_id": operation_id,506 "restored": True,507 "backup_file": backup_info["filename"],508 "duration": duration509 }510 511 except Exception as e:512 log("ERROR", "Load operation failed",513 operation_id=operation_id,514 error=str(e),515 exc_info=True)516 return {517 "success": False,518 "operation_id": operation_id,519 "error": str(e)520 }521 522 def _find_latest_backup(self) -> Optional[Dict[str, Any]]:523 """Find the latest backup file in the dataset"""524 try:525 files = self.api.list_repo_files(526 repo_id=self.repo_id,527 repo_type="dataset"528 )529 530 # Get backup files sorted by name (timestamp)531 backups = sorted(532 [f for f in files if f.startswith(Config.BACKUP_PREFIX) and f.endswith(".tar.gz")],533 reverse=True534 )535 536 if not backups:537 return None538 539 latest = backups[0]540 541 # Extract timestamp from filename542 timestamp_str = latest.replace(Config.BACKUP_PREFIX, "").replace(".tar.gz", "")543 try:544 timestamp = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S").isoformat()545 except ValueError:546 timestamp = None547 548 return {549 "filename": latest,550 "timestamp": timestamp551 }552 553 except Exception as e:554 log("ERROR", "Could not find latest backup", error=str(e))555 return None556 557 def _create_local_backup(self) -> Optional[Path]:558 """Create a backup of local state before restore"""559 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")560 backup_dir = self.home_dir.parent / f"{self.home_dir.name}_backup_{timestamp}"561 562 try:563 if self.home_dir.exists():564 shutil.copytree(self.home_dir, backup_dir)565 return backup_dir566 except Exception as e:567 log("WARNING", "Could not create local backup", error=str(e))568 569 return None570 571 def _extract_archive(self, tar_path: Path):572 """Extract tar.gz archive to home directory"""573 # Ensure home directory exists574 self.home_dir.mkdir(parents=True, exist_ok=True)575 576 # Extract archive577 with tarfile.open(tar_path, "r:gz") as tar:578 tar.extractall(self.home_dir)579 580 log("INFO", "Archive extracted successfully",581 destination=str(self.home_dir))582 583 584# ============================================================================585# CLI Interface586# ============================================================================587 588def main():589 if len(sys.argv) < 2:590 print("Usage: python openclaw_persist.py [save|load|status]", file=sys.stderr)591 print("", file=sys.stderr)592 print("Commands:", file=sys.stderr)593 print(" save - Save current state to dataset", file=sys.stderr)594 print(" load - Load state from dataset", file=sys.stderr)595 print(" status - Show persistence status", file=sys.stderr)596 sys.exit(1)597 598 command = sys.argv[1].lower()599 600 try:601 manager = OpenClawPersistence()602 603 if command == "save":604 result = manager.save()605 print(json.dumps(result, indent=2))606 sys.exit(0 if result.get("success") else 1)607 608 elif command == "load":609 force = "--force" in sys.argv or "-f" in sys.argv610 result = manager.load(force=force)611 print(json.dumps(result, indent=2))612 sys.exit(0 if result.get("success") else 1)613 614 elif command == "status":615 # Show status information616 status = {617 "configured": True,618 "repo_id": manager.repo_id,619 "home_dir": str(manager.home_dir),620 "home_exists": manager.home_dir.exists(),621 }622 623 # Load state file624 if manager.state_file.exists():625 with open(manager.state_file, 'r') as f:626 state = json.load(f)627 status["state"] = state628 629 # List backups630 backups = manager._find_latest_backup()631 status["latest_backup"] = backups632 633 print(json.dumps(status, indent=2))634 sys.exit(0)635 636 else:637 print(f"Unknown command: {command}", file=sys.stderr)638 sys.exit(1)639 640 except Exception as e:641 print(json.dumps({642 "success": False,643 "error": str(e)644 }, indent=2))645 sys.exit(1)646 647 648if __name__ == "__main__":649 main()650 