|
|
| """
|
| OpenClaw Sync Manager for Hugging Face Spaces
|
| ==============================================
|
|
|
| This script manages the complete lifecycle of OpenClaw in a Hugging Face Space:
|
| 1. Restores state on startup (load)
|
| 2. Runs periodic backups (save)
|
| 3. Ensures clean shutdown with final backup
|
|
|
| This is the main entry point for running OpenClaw in Hugging Face Spaces.
|
|
|
| Usage:
|
| python3 openclaw_sync.py
|
|
|
| Environment Variables:
|
| HF_TOKEN - Hugging Face access token
|
| OPENCLAW_DATASET_REPO - Dataset for persistence (e.g., "username/openclaw")
|
| OPENCLAW_HOME - OpenClaw home directory (default: ~/.openclaw)
|
| SYNC_INTERVAL - Seconds between automatic backups (default: 300)
|
| """
|
|
|
| import os
|
| import sys
|
| import time
|
| import signal
|
| import subprocess
|
| import threading
|
| import json
|
| from datetime import datetime
|
| from pathlib import Path
|
|
|
|
|
| sys.path.insert(0, str(Path(__file__).parent))
|
|
|
| from openclaw_persist import OpenClawPersistence, Config, log
|
|
|
|
|
| class SyncManager:
|
| """Manages sync and app lifecycle"""
|
|
|
| def __init__(self):
|
|
|
| self.sync_interval = int(os.environ.get("SYNC_INTERVAL", "300"))
|
| self.app_dir = Path(os.environ.get("OPENCLAW_APP_DIR", "/app/openclaw"))
|
| self.node_path = os.environ.get("NODE_PATH", f"{self.app_dir}/node_modules")
|
|
|
|
|
| self.running = False
|
| self.stop_event = threading.Event()
|
| self.app_process = None
|
| self.aux_processes = []
|
|
|
|
|
| self.persist = None
|
| try:
|
| self.persist = OpenClawPersistence()
|
| log("INFO", "Persistence initialized",
|
| sync_interval=self.sync_interval)
|
| except Exception as e:
|
| log("WARNING", "Persistence not available, running without backup",
|
| error=str(e))
|
|
|
|
|
|
|
|
|
|
|
| def start(self):
|
| """Main entry point - restore, run app, sync loop"""
|
| log("INFO", "Starting OpenClaw Sync Manager")
|
|
|
|
|
| self.restore_state()
|
|
|
|
|
| self._setup_signals()
|
|
|
|
|
| self.start_aux_services()
|
|
|
|
|
| self.start_application()
|
|
|
|
|
| self.start_background_sync()
|
|
|
|
|
| self.wait_for_exit()
|
|
|
| def restore_state(self):
|
| """Restore state from dataset on startup"""
|
| if not self.persist:
|
| log("INFO", "Skipping restore (persistence not configured)")
|
|
|
| self._ensure_default_config()
|
| return
|
|
|
| log("INFO", "Restoring state from dataset...")
|
|
|
| result = self.persist.load(force=False)
|
|
|
| if result.get("success"):
|
| if result.get("restored"):
|
| log("INFO", "State restored successfully",
|
| backup_file=result.get("backup_file"))
|
| else:
|
| log("INFO", "No previous state found, starting fresh")
|
|
|
| self._ensure_default_config()
|
| else:
|
| log("ERROR", "State restore failed", error=result.get("error"))
|
|
|
| def _ensure_default_config(self):
|
| """Ensure openclaw.json exists with valid config"""
|
| import json
|
| from openclaw_persist import Config
|
|
|
| config_path = Config.OPENCLAW_HOME / "openclaw.json"
|
| default_config_path = Path(__file__).parent / "openclaw.json.default"
|
|
|
| if config_path.exists():
|
| log("INFO", "Config file exists, skipping")
|
| return
|
|
|
| log("INFO", "No config found, creating default")
|
|
|
| config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
| if default_config_path.exists():
|
| try:
|
| with open(default_config_path, 'r') as f:
|
| config = json.load(f)
|
| with open(config_path, 'w') as f:
|
| json.dump(config, f, indent=2)
|
| log("INFO", "Default config created from template")
|
| return
|
| except Exception as e:
|
| log("WARNING", "Could not load default config template", error=str(e))
|
|
|
|
|
| minimal_config = {
|
| "gateway": {
|
| "mode": "local",
|
| "bind": "lan",
|
| "port": 7860,
|
| "auth": {"token": "openclaw-space-default"},
|
| "controlUi": {
|
| "allowInsecureAuth": True,
|
| "allowedOrigins": [
|
| "https://huggingface.co"
|
| ]
|
| }
|
| },
|
| "session": {"scope": "global"},
|
| "models": {
|
| "mode": "merge",
|
| "providers": {}
|
| },
|
| "agents": {
|
| "defaults": {
|
| "workspace": "~/.openclaw/workspace"
|
| }
|
| }
|
| }
|
|
|
| with open(config_path, 'w') as f:
|
| json.dump(minimal_config, f, indent=2)
|
| log("INFO", "Minimal config created")
|
|
|
| def start_application(self):
|
| """Start the main OpenClaw application"""
|
| log("INFO", "Starting OpenClaw application")
|
|
|
|
|
| env = os.environ.copy()
|
| env["NODE_PATH"] = self.node_path
|
| env["NODE_ENV"] = "production"
|
|
|
|
|
| cmd_str = "node dist/entry.js gateway"
|
|
|
| log("INFO", "Executing command",
|
| cmd=cmd_str,
|
| cwd=str(self.app_dir))
|
|
|
|
|
| self.app_process = subprocess.Popen(
|
| cmd_str,
|
| shell=True,
|
| cwd=str(self.app_dir),
|
| env=env,
|
| stdout=sys.stdout,
|
| stderr=sys.stderr,
|
| )
|
|
|
| log("INFO", "Application started", pid=self.app_process.pid)
|
|
|
| def start_aux_services(self):
|
| """Start auxiliary services like WA guardian and QR manager"""
|
| env = os.environ.copy()
|
| env["NODE_PATH"] = self.node_path
|
|
|
|
|
| if os.environ.get("ENABLE_AUX_SERVICES", "false").lower() == "true":
|
|
|
| wa_guardian = Path(__file__).parent / "wa-login-guardian.cjs"
|
| if wa_guardian.exists():
|
| try:
|
| p = subprocess.Popen(
|
| ["node", str(wa_guardian)],
|
| env=env,
|
| stdout=sys.stdout,
|
| stderr=sys.stderr
|
| )
|
| self.aux_processes.append(p)
|
| log("INFO", "WA Guardian started", pid=p.pid)
|
| except Exception as e:
|
| log("WARNING", "Could not start WA Guardian", error=str(e))
|
|
|
|
|
| qr_manager = Path(__file__).parent / "qr-detection-manager.cjs"
|
| space_host = os.environ.get("SPACE_HOST", "")
|
| if qr_manager.exists():
|
| try:
|
| p = subprocess.Popen(
|
| ["node", str(qr_manager), space_host],
|
| env=env,
|
| stdout=sys.stdout,
|
| stderr=sys.stderr
|
| )
|
| self.aux_processes.append(p)
|
| log("INFO", "QR Manager started", pid=p.pid)
|
| except Exception as e:
|
| log("WARNING", "Could not start QR Manager", error=str(e))
|
| else:
|
| log("INFO", "Aux services disabled")
|
|
|
| def start_background_sync(self):
|
| """Start periodic backup in background"""
|
| if not self.persist:
|
| log("INFO", "Skipping background sync (persistence not configured)")
|
| return
|
|
|
| self.running = True
|
|
|
| def sync_loop():
|
| while not self.stop_event.is_set():
|
|
|
| if self.stop_event.wait(timeout=self.sync_interval):
|
| break
|
|
|
|
|
| log("INFO", "Periodic backup triggered")
|
| self.do_backup()
|
|
|
| thread = threading.Thread(target=sync_loop, daemon=True)
|
| thread.start()
|
| log("INFO", "Background sync started",
|
| interval_seconds=self.sync_interval)
|
|
|
| def do_backup(self):
|
| """Perform a backup operation"""
|
| if not self.persist:
|
| return
|
|
|
| try:
|
| result = self.persist.save()
|
| if result.get("success"):
|
| log("INFO", "Backup completed successfully",
|
| operation_id=result.get("operation_id"),
|
| remote_path=result.get("remote_path"))
|
| else:
|
| log("ERROR", "Backup failed", error=result.get("error"))
|
| except Exception as e:
|
| log("ERROR", "Backup exception", error=str(e), exc_info=True)
|
|
|
| def wait_for_exit(self):
|
| """Wait for app process to exit"""
|
| if not self.app_process:
|
| log("ERROR", "No app process to wait for")
|
| return
|
|
|
| log("INFO", "Waiting for application to exit...")
|
|
|
| exit_code = self.app_process.wait()
|
| log("INFO", f"Application exited with code {exit_code}")
|
|
|
|
|
| self.stop_event.set()
|
|
|
|
|
| for p in self.aux_processes:
|
| try:
|
| p.terminate()
|
| p.wait(timeout=2)
|
| except subprocess.TimeoutExpired:
|
| p.kill()
|
| except Exception:
|
| pass
|
|
|
|
|
| log("INFO", "Performing final backup...")
|
| self.do_backup()
|
|
|
| sys.exit(exit_code)
|
|
|
| def _setup_signals(self):
|
| """Setup signal handlers for graceful shutdown"""
|
| def handle_signal(signum, frame):
|
| log("INFO", f"Received signal {signum}, initiating shutdown...")
|
|
|
|
|
| self.stop_event.set()
|
|
|
|
|
| if self.app_process:
|
| log("INFO", "Terminating application...")
|
| self.app_process.terminate()
|
| try:
|
| self.app_process.wait(timeout=5)
|
| except subprocess.TimeoutExpired:
|
| self.app_process.kill()
|
|
|
|
|
| for p in self.aux_processes:
|
| try:
|
| p.terminate()
|
| p.wait(timeout=2)
|
| except subprocess.TimeoutExpired:
|
| p.kill()
|
| except Exception:
|
| pass
|
|
|
|
|
| if self.persist:
|
| log("INFO", "Performing final backup on shutdown...")
|
| self.do_backup()
|
|
|
| sys.exit(0)
|
|
|
| signal.signal(signal.SIGINT, handle_signal)
|
| signal.signal(signal.SIGTERM, handle_signal)
|
|
|
|
|
|
|
|
|
|
|
|
|
| def main():
|
| """Main entry point"""
|
| log("INFO", "OpenClaw Sync Manager starting...")
|
| log("INFO", "Configuration",
|
| home_dir=str(Config.OPENCLAW_HOME),
|
| repo_id=os.environ.get("OPENCLAW_DATASET_REPO", "not set"),
|
| sync_interval=os.environ.get("SYNC_INTERVAL", "300"))
|
|
|
| manager = SyncManager()
|
| manager.start()
|
|
|
|
|
| if __name__ == "__main__":
|
| main()
|
|
|