tao-shen
feat: professional README, .env.example, architecture diagram
7cd8723
Raw
History Blame
25.9 kB
#!/usr/bin/env python3
"""
OpenClaw HF Spaces Persistence β€” Full Directory Sync
=====================================================
Simplified persistence: upload/download the entire ~/.openclaw directory
as-is to/from a Hugging Face Dataset repo.
- Startup: snapshot_download β†’ ~/.openclaw
- Periodic: upload_folder β†’ dataset openclaw_data/
- Shutdown: final upload_folder β†’ dataset openclaw_data/
"""
import os
import sys
import time
import threading
import subprocess
import signal
import json
import shutil
import tempfile
import traceback
import re
from pathlib import Path
from datetime import datetime
# Set timeout BEFORE importing huggingface_hub
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "300")
os.environ.setdefault("HF_HUB_UPLOAD_TIMEOUT", "600")
from huggingface_hub import HfApi, snapshot_download
# ── Logging helper ──────────────────────────────────────────────────────────
class TeeLogger:
"""Duplicate output to stream and file."""
def __init__(self, filename, stream):
self.stream = stream
self.file = open(filename, "a", encoding="utf-8")
def write(self, message):
self.stream.write(message)
self.file.write(message)
self.flush()
def flush(self):
self.stream.flush()
self.file.flush()
def fileno(self):
return self.stream.fileno()
# ── Configuration ───────────────────────────────────────────────────────────
HF_REPO_ID = os.environ.get("OPENCLAW_DATASET_REPO", "")
HF_TOKEN = os.environ.get("HF_TOKEN")
OPENCLAW_HOME = Path.home() / ".openclaw"
APP_DIR = Path("/app/openclaw")
# Use ".openclaw" - directly read/write the .openclaw folder in dataset
DATASET_PATH = ".openclaw"
# Telegram credentials (backward-compatible; prefer configuring via Control UI)
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_BOT_NAME = os.environ.get("TELEGRAM_BOT_NAME", "")
TELEGRAM_ALLOW_USER = os.environ.get("TELEGRAM_ALLOW_USER", "")
# OpenRouter API key for LLM access
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
# Gateway password (override via HF Secret OPENCLAW_PASSWORD)
OPENCLAW_PASSWORD = os.environ.get("OPENCLAW_PASSWORD", "huggingclaw")
# Default model for new conversations
OPENCLAW_DEFAULT_MODEL = os.environ.get("OPENCLAW_DEFAULT_MODEL", "openrouter/stepfun/step-3.5-flash:free")
# HF Spaces built-in env vars (auto-set by HF runtime)
SPACE_HOST = os.environ.get("SPACE_HOST", "") # e.g. "tao-shen-huggingclaw.hf.space"
SPACE_ID = os.environ.get("SPACE_ID", "") # e.g. "tao-shen/HuggingClaw"
SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "120"))
# Setup logging
log_dir = OPENCLAW_HOME / "workspace"
log_dir.mkdir(parents=True, exist_ok=True)
sys.stdout = TeeLogger(log_dir / "sync.log", sys.stdout)
sys.stderr = sys.stdout
# ── Sync Manager ────────────────────────────────────────────────────────────
class OpenClawFullSync:
"""Upload/download the entire ~/.openclaw directory to HF Dataset."""
def __init__(self):
self.enabled = False
self.dataset_exists = False
self.api = None
if not HF_TOKEN:
print("[SYNC] WARNING: HF_TOKEN not set. Persistence disabled.")
return
if not HF_REPO_ID:
print("[SYNC] INFO: OPENCLAW_DATASET_REPO not set. Persistence disabled.")
return
self.enabled = True
self.api = HfApi(token=HF_TOKEN)
self.dataset_exists = self._ensure_repo_exists()
# ── Repo management ────────────────────────────────────────────────
def _ensure_repo_exists(self):
"""Check if dataset repo exists; auto-create if not."""
try:
self.api.repo_info(repo_id=HF_REPO_ID, repo_type="dataset")
print(f"[SYNC] Dataset repo found: {HF_REPO_ID}")
return True
except Exception:
print(f"[SYNC] Dataset repo NOT found: {HF_REPO_ID} - creating...")
try:
self.api.create_repo(
repo_id=HF_REPO_ID,
repo_type="dataset",
private=True,
)
print(f"[SYNC] βœ“ Dataset repo created: {HF_REPO_ID}")
return True
except Exception as e:
print(f"[SYNC] βœ— Failed to create dataset repo: {e}")
return False
# ── Restore (startup) ─────────────────────────────────────────────
def load_from_repo(self):
"""Download from dataset β†’ ~/.openclaw"""
if not self.enabled:
print("[SYNC] Persistence disabled - skipping restore")
self._ensure_default_config()
self._patch_config()
self._ensure_telegram_credentials()
return
if not self.dataset_exists:
print(f"[SYNC] Dataset {HF_REPO_ID} does not exist - starting fresh")
self._ensure_default_config()
self._patch_config()
self._ensure_telegram_credentials()
return
print(f"[SYNC] β–Ά Restoring ~/.openclaw from dataset {HF_REPO_ID} ...")
OPENCLAW_HOME.mkdir(parents=True, exist_ok=True)
try:
files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")
openclaw_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]
if not openclaw_files:
print(f"[SYNC] No {DATASET_PATH}/ folder in dataset. Starting fresh.")
self._ensure_default_config()
self._ensure_telegram_credentials()
return
print(f"[SYNC] Found {len(openclaw_files)} files under {DATASET_PATH}/ in dataset")
with tempfile.TemporaryDirectory() as tmpdir:
snapshot_download(
repo_id=HF_REPO_ID,
repo_type="dataset",
allow_patterns=f"{DATASET_PATH}/**",
local_dir=tmpdir,
token=HF_TOKEN,
)
downloaded_root = Path(tmpdir) / DATASET_PATH
if downloaded_root.exists():
for item in downloaded_root.rglob("*"):
if item.is_file():
rel = item.relative_to(downloaded_root)
dest = OPENCLAW_HOME / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(item), str(dest))
print("[SYNC] βœ“ Restore completed.")
else:
print("[SYNC] Downloaded snapshot but dir not found. Starting fresh.")
except Exception as e:
print(f"[SYNC] βœ— Restore failed: {e}")
traceback.print_exc()
# Patch config & telegram after restore
self._patch_config()
self._ensure_telegram_credentials()
self._debug_list_files()
# ── Save (periodic + shutdown) ─────────────────────────────────────
def save_to_repo(self):
"""Upload entire ~/.openclaw directory β†’ dataset (all files, no filtering)"""
if not self.enabled:
return
if not OPENCLAW_HOME.exists():
print("[SYNC] ~/.openclaw does not exist, nothing to save.")
return
# Ensure dataset exists (auto-create if needed)
if not self._ensure_repo_exists():
print(f"[SYNC] Dataset {HF_REPO_ID} unavailable - skipping save")
return
print(f"[SYNC] β–Ά Uploading ~/.openclaw β†’ dataset {HF_REPO_ID}/{DATASET_PATH}/ ...")
try:
# Log what will be uploaded
total_size = 0
file_count = 0
for root, dirs, fls in os.walk(OPENCLAW_HOME):
for fn in fls:
fp = os.path.join(root, fn)
sz = os.path.getsize(fp)
total_size += sz
file_count += 1
rel = os.path.relpath(fp, OPENCLAW_HOME)
print(f"[SYNC] uploading: {rel} ({sz} bytes)")
print(f"[SYNC] Uploading: {file_count} files, {total_size} bytes total")
if file_count == 0:
print("[SYNC] Nothing to upload.")
return
# Upload directory, excluding large log files that trigger LFS rejection
self.api.upload_folder(
folder_path=str(OPENCLAW_HOME),
path_in_repo=DATASET_PATH,
repo_id=HF_REPO_ID,
repo_type="dataset",
token=HF_TOKEN,
commit_message=f"Sync .openclaw β€” {datetime.now().isoformat()}",
ignore_patterns=[
"*.log", # Log files (sync.log, startup.log) β€” regenerated on boot
"*.lock", # Lock files β€” stale after restart
"*.tmp", # Temp files
"*.pid", # PID files
"__pycache__", # Python cache
],
)
print(f"[SYNC] βœ“ Upload completed at {datetime.now().isoformat()}")
# Verify
try:
files = self.api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset")
oc_files = [f for f in files if f.startswith(f"{DATASET_PATH}/")]
print(f"[SYNC] Dataset now has {len(oc_files)} files under {DATASET_PATH}/")
for f in oc_files[:30]:
print(f"[SYNC] {f}")
if len(oc_files) > 30:
print(f"[SYNC] ... and {len(oc_files) - 30} more")
except Exception:
pass
except Exception as e:
print(f"[SYNC] βœ— Upload failed: {e}")
traceback.print_exc()
# ── Config helpers ─────────────────────────────────────────────────
def _ensure_default_config(self):
config_path = OPENCLAW_HOME / "openclaw.json"
if config_path.exists():
return
default_src = Path(__file__).parent / "openclaw.json.default"
if default_src.exists():
shutil.copy2(str(default_src), str(config_path))
# Replace placeholder or remove provider if no API key
try:
with open(config_path, "r") as f:
cfg = json.load(f)
# Replace password placeholder
if "gateway" in cfg and "auth" in cfg["gateway"]:
if cfg["gateway"]["auth"].get("password") == "__OPENCLAW_PASSWORD__":
cfg["gateway"]["auth"]["password"] = OPENCLAW_PASSWORD
if OPENROUTER_API_KEY:
# Replace placeholder with actual key
if "models" in cfg and "providers" in cfg["models"] and "openrouter" in cfg["models"]["providers"]:
cfg["models"]["providers"]["openrouter"]["apiKey"] = OPENROUTER_API_KEY
else:
# No API key: remove provider entirely to avoid config validation error
if "models" in cfg and "providers" in cfg["models"]:
cfg["models"]["providers"].pop("openrouter", None)
print("[SYNC] No OPENROUTER_API_KEY β€” removed openrouter provider from config")
with open(config_path, "w") as f:
json.dump(cfg, f, indent=2)
except Exception as e:
print(f"[SYNC] Warning: failed to patch default config: {e}")
print("[SYNC] Created openclaw.json from default template")
else:
with open(config_path, "w") as f:
json.dump({
"gateway": {
"mode": "local", "bind": "lan", "port": 7860,
"trustedProxies": ["0.0.0.0/0"],
"controlUi": {
"allowInsecureAuth": True,
"allowedOrigins": [
"https://huggingface.co"
]
}
},
"session": {"scope": "global"},
"models": {"mode": "merge", "providers": {}},
"agents": {"defaults": {"workspace": "~/.openclaw/workspace"}}
}, f)
print("[SYNC] Created minimal openclaw.json")
def _patch_config(self):
"""Ensure critical settings after restore."""
config_path = OPENCLAW_HOME / "openclaw.json"
if not config_path.exists():
self._ensure_default_config()
return
print("[SYNC] Patching configuration...")
try:
with open(config_path, "r") as f:
data = json.load(f)
print("[SYNC] Config parsed OK.")
except (json.JSONDecodeError, Exception) as e:
# Config is corrupt β€” back up and start fresh
print(f"[SYNC] Config JSON is corrupt: {e}")
backup = config_path.with_suffix(f".corrupt_{int(time.time())}")
try:
shutil.copy2(config_path, backup)
print(f"[SYNC] Backed up corrupt config to {backup.name}")
except Exception:
pass
data = {}
print("[SYNC] Starting from clean config.")
try:
# Remove /dev/null from plugins.locations
if "plugins" in data and isinstance(data.get("plugins"), dict):
locs = data["plugins"].get("locations", [])
if isinstance(locs, list) and "/dev/null" in locs:
data["plugins"]["locations"] = [l for l in locs if l != "/dev/null"]
# Force full gateway config for HF Spaces
# Password auth: user must enter password in Control UI settings
if not OPENCLAW_PASSWORD:
print("[SYNC] WARNING: OPENCLAW_PASSWORD not set! Gateway will auto-generate a random token.")
auth = {"password": OPENCLAW_PASSWORD} if OPENCLAW_PASSWORD else {}
# Dynamic allowedOrigins from SPACE_HOST (auto-set by HF runtime)
allowed_origins = [
"https://huggingface.co",
"https://*.hf.space",
]
if SPACE_HOST:
allowed_origins.append(f"https://{SPACE_HOST}")
print(f"[SYNC] SPACE_HOST detected: {SPACE_HOST}")
data["gateway"] = {
"mode": "local",
"bind": "lan",
"port": 7860,
"auth": auth,
"trustedProxies": ["0.0.0.0/0"],
"controlUi": {
"allowInsecureAuth": True,
"dangerouslyDisableDeviceAuth": True,
"allowedOrigins": allowed_origins
}
}
print(f"[SYNC] Set gateway config (auth={'password' if OPENCLAW_PASSWORD else 'auto-generated'}, origins={len(allowed_origins)})")
# Ensure agents defaults
data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("model", {})
data.setdefault("session", {})["scope"] = "global"
# Force OpenRouter provider
data.setdefault("models", {}).setdefault("providers", {})
if OPENROUTER_API_KEY:
data["models"]["providers"]["openrouter"] = {
"baseUrl": "https://openrouter.ai/api/v1",
"apiKey": OPENROUTER_API_KEY,
"api": "openai-completions",
"models": [
{"id": "stepfun/step-3.5-flash:free", "name": "Step-3.5-Flash (Free)"},
{"id": "deepseek/deepseek-chat:free", "name": "DeepSeek V3 (Free)"}
]
}
else:
print("[SYNC] WARNING: OPENROUTER_API_KEY not set, skipping provider config")
# Remove old gemini provider if present
data["models"]["providers"].pop("gemini", None)
data["agents"]["defaults"]["model"]["primary"] = OPENCLAW_DEFAULT_MODEL
# Plugin whitelist (only load telegram + whatsapp to speed up startup)
data.setdefault("plugins", {}).setdefault("entries", {})
data["plugins"]["allow"] = ["telegram", "whatsapp"]
if "telegram" not in data["plugins"]["entries"]:
data["plugins"]["entries"]["telegram"] = {"enabled": True}
elif isinstance(data["plugins"]["entries"]["telegram"], dict):
data["plugins"]["entries"]["telegram"]["enabled"] = True
with open(config_path, "w") as f:
json.dump(data, f, indent=2)
print("[SYNC] Config patched and saved.")
# Verify write
with open(config_path, "r") as f:
verify_data = json.load(f)
gw = verify_data.get("gateway", {})
providers = list(verify_data.get("models", {}).get("providers", {}).keys())
primary = verify_data.get("agents", {}).get("defaults", {}).get("model", {}).get("primary")
print(f"[SYNC] VERIFY: gateway.port={gw.get('port')}, providers={providers}, primary={primary}")
except Exception as e:
print(f"[SYNC] Failed to patch config: {e}")
traceback.print_exc()
def _ensure_telegram_credentials(self):
"""Configure Telegram bot token and allowed users."""
creds_dir = OPENCLAW_HOME / "credentials"
creds_dir.mkdir(parents=True, exist_ok=True)
if TELEGRAM_BOT_TOKEN:
bot_file = creds_dir / "telegram-bot-token.json"
with open(bot_file, "w") as f:
json.dump({"token": TELEGRAM_BOT_TOKEN, "bot": TELEGRAM_BOT_NAME}, f, indent=2)
print(f"[SYNC] Telegram bot configured: {TELEGRAM_BOT_NAME}")
allow_file = creds_dir / "telegram-allowFrom.json"
if not allow_file.exists():
with open(allow_file, "w") as f:
json.dump([TELEGRAM_ALLOW_USER], f, indent=2)
print(f"[SYNC] Created telegram-allowFrom.json for {TELEGRAM_ALLOW_USER}")
else:
try:
with open(allow_file, "r") as f:
data = json.load(f)
if not isinstance(data, list):
data = [TELEGRAM_ALLOW_USER]
elif TELEGRAM_ALLOW_USER not in data:
data.append(TELEGRAM_ALLOW_USER)
with open(allow_file, "w") as f:
json.dump(data, f, indent=2)
except Exception:
with open(allow_file, "w") as f:
json.dump([TELEGRAM_ALLOW_USER], f, indent=2)
def _debug_list_files(self):
print(f"[SYNC] Local ~/.openclaw tree:")
try:
count = 0
for root, dirs, files in os.walk(OPENCLAW_HOME):
dirs[:] = [d for d in dirs if d not in {".cache", "node_modules", "__pycache__"}]
for name in sorted(files):
rel = os.path.relpath(os.path.join(root, name), OPENCLAW_HOME)
print(f"[SYNC] {rel}")
count += 1
if count > 50:
print("[SYNC] ... (truncated)")
return
except Exception as e:
print(f"[SYNC] listing failed: {e}")
# ── Background sync loop ──────────────────────────────────────────
def background_sync_loop(self, stop_event):
print(f"[SYNC] Background sync started (interval={SYNC_INTERVAL}s)")
while not stop_event.is_set():
if stop_event.wait(timeout=SYNC_INTERVAL):
break
print(f"[SYNC] ── Periodic sync triggered at {datetime.now().isoformat()} ──")
self.save_to_repo()
# ── Application runner ─────────────────────────────────────────────
def run_openclaw(self):
log_file = OPENCLAW_HOME / "workspace" / "startup.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
# Debug: check if app directory exists
if not Path(APP_DIR).exists():
print(f"[SYNC] ERROR: App directory does not exist: {APP_DIR}")
return None
# Debug: check if dist/entry.js exists
entry_js = Path(APP_DIR) / "dist" / "entry.js"
if not entry_js.exists():
print(f"[SYNC] ERROR: dist/entry.js not found in {APP_DIR}")
return None
# Use subprocess.run with direct output, no shell pipe
print(f"[SYNC] Launching: node dist/entry.js gateway")
print(f"[SYNC] Working directory: {APP_DIR}")
print(f"[SYNC] Entry point exists: {entry_js}")
print(f"[SYNC] Log file: {log_file}")
# Open log file
log_fh = open(log_file, "a")
# Prepare environment with required variables
env = os.environ.copy()
if OPENROUTER_API_KEY:
env["OPENROUTER_API_KEY"] = OPENROUTER_API_KEY
print(f"[SYNC] Setting OPENROUTER_API_KEY environment variable")
else:
print(f"[SYNC] WARNING: OPENROUTER_API_KEY not set, LLM features may not work")
try:
# Use Popen without shell to avoid pipe issues
# auth disabled in config β€” no token needed
process = subprocess.Popen(
["node", "dist/entry.js", "gateway"],
cwd=str(APP_DIR),
stdout=subprocess.PIPE, # Capture so we can log it
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # Line buffered
env=env # Pass environment with OPENROUTER_API_KEY
)
# Create a thread to copy output to both log file and stdout
def copy_output():
try:
for line in process.stdout:
log_fh.write(line)
log_fh.flush()
print(line, end='') # Also print to console
except Exception as e:
print(f"[SYNC] Output copy error: {e}")
finally:
log_fh.close()
thread = threading.Thread(target=copy_output, daemon=True)
thread.start()
print(f"[SYNC] Process started with PID: {process.pid}")
return process
except Exception as e:
log_fh.close()
print(f"[SYNC] ERROR: Failed to start process: {e}")
traceback.print_exc()
return None
# ── Main ────────────────────────────────────────────────────────────────────
def main():
try:
t_main_start = time.time()
t0 = time.time()
sync = OpenClawFullSync()
print(f"[TIMER] sync_hf init: {time.time() - t0:.1f}s")
# 1. Restore
t0 = time.time()
sync.load_from_repo()
print(f"[TIMER] load_from_repo (restore): {time.time() - t0:.1f}s")
# 2. Background sync
stop_event = threading.Event()
t = threading.Thread(target=sync.background_sync_loop, args=(stop_event,), daemon=True)
t.start()
# 3. Start application
t0 = time.time()
process = sync.run_openclaw()
print(f"[TIMER] run_openclaw launch: {time.time() - t0:.1f}s")
print(f"[TIMER] Total startup (init β†’ app launched): {time.time() - t_main_start:.1f}s")
# Signal handler
def handle_signal(sig, frame):
print(f"\n[SYNC] Signal {sig} received. Shutting down...")
stop_event.set()
# Wait for background sync to finish if it's running
t.join(timeout=10)
if process:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
print("[SYNC] Final sync...")
sync.save_to_repo()
sys.exit(0)
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
# Wait
if process is None:
print("[SYNC] ERROR: Failed to start OpenClaw process. Exiting.")
stop_event.set()
t.join(timeout=5)
sys.exit(1)
exit_code = process.wait()
print(f"[SYNC] OpenClaw exited with code {exit_code}")
stop_event.set()
t.join(timeout=10)
print("[SYNC] Final sync...")
sync.save_to_repo()
sys.exit(exit_code)
except Exception as e:
print(f"[SYNC] FATAL ERROR in main: {e}")
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()