| |
| """ |
| 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() |
|
|