Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """Control server for HF Space backup management. | |
| Provides a REST API for controlling OpenViking, gateway status, | |
| and triggering backups. Runs on port 4000 with only Python 3 built-ins. | |
| """ | |
| import http.server | |
| import json | |
| import mimetypes | |
| import os | |
| import signal | |
| import subprocess | |
| import threading | |
| import time | |
| import urllib.parse | |
| from datetime import datetime | |
| # ============================================================ | |
| # Constants | |
| # ============================================================ | |
| CONTROL_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| BACKUP_LOG = "/tmp/backup-control.log" | |
| BACKUP_MANAGER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backup-manager.py") | |
| OPENVIKING_PID_FILE = "/tmp/openviking.pid" | |
| OPENVIKING_CONFIG = os.path.join( | |
| os.environ.get("HOME", "/root"), ".openviking", "ov.conf" | |
| ) | |
| GATEWAY_PORT = os.environ.get("OPENCLAW_GATEWAY_PORT", os.environ.get("PORT", "7860")) | |
| STATE_DIR = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| SERVER_PORT = 4000 | |
| OPENVIKING_PORT = 1933 | |
| # HTTP limits | |
| MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB | |
| DEFAULT_MAX_CONCURRENT = 100 | |
| # Timeouts | |
| BACKUP_CMD_TIMEOUT = 600 # 10 minutes (was 1 hour) | |
| CURL_TIMEOUT = 5 # seconds | |
| STOP_WAIT_ITERATIONS = 20 | |
| STOP_WAIT_INTERVAL = 0.5 # seconds | |
| LOG_MAX_LINES = 200 | |
| LOG_MAX_BYTES = 20 * 1024 # 20 KB | |
| # ============================================================ | |
| # Synchronization | |
| # ============================================================ | |
| _backup_lock = threading.Lock() | |
| # ============================================================ | |
| # Logging | |
| # ============================================================ | |
| def write_log(message: str) -> None: | |
| """Append a timestamped entry to the backup control log.""" | |
| timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]") | |
| line = f"{timestamp} {message}\n" | |
| try: | |
| with open(BACKUP_LOG, "a", encoding="utf-8") as f: | |
| f.write(line) | |
| except OSError as e: | |
| print(f"Log write error: {e}") | |
| def write_separator() -> None: | |
| """Write a separator line to the backup control log.""" | |
| write_log("=" * 40) | |
| def _read_log_tail(max_lines: int = LOG_MAX_LINES, max_bytes: int = LOG_MAX_BYTES) -> list[str]: | |
| """ max_lines max_bytes .""" | |
| try: | |
| log_size = os.path.getsize(BACKUP_LOG) | |
| read_size = min(log_size, max_bytes) | |
| with open(BACKUP_LOG, "rb") as f: | |
| if read_size < log_size: | |
| f.seek(log_size - read_size) | |
| f.readline() # skip partial first line | |
| raw = f.read().decode("utf-8", errors="replace") | |
| all_lines = raw.splitlines() | |
| return [l.rstrip("\r\n") for l in all_lines[-max_lines:]] | |
| except (FileNotFoundError, OSError): | |
| return [] | |
| # ============================================================ | |
| # Process helpers | |
| # ============================================================ | |
| def pid_is_alive(pid_file: str) -> bool: | |
| """Return True if the process identified by *pid_file* is running. | |
| Uses ``kill -0`` which never sends a signal but checks existence. | |
| """ | |
| try: | |
| with open(pid_file, "r") as f: | |
| pid = int(f.read().strip()) | |
| os.kill(pid, 0) | |
| return True | |
| except (FileNotFoundError, ValueError, ProcessLookupError): | |
| return False | |
| except OSError: | |
| return False | |
| def port_is_alive(url: str) -> bool: | |
| """Return True if the given URL responds successfully via curl.""" | |
| try: | |
| result = subprocess.run( | |
| ["curl", "-sf", url], | |
| capture_output=True, | |
| timeout=CURL_TIMEOUT, | |
| ) | |
| return result.returncode == 0 | |
| except (subprocess.TimeoutExpired, FileNotFoundError): | |
| return False | |
| # ============================================================ | |
| # OpenViking lifecycle | |
| # ============================================================ | |
| def stop_openviking() -> bool: | |
| """Stop the OpenViking server. | |
| 1. Read PID from ``/tmp/openviking.pid``. | |
| 2. Send SIGTERM and wait up to 10 seconds. | |
| 3. If still alive, send SIGKILL. | |
| """ | |
| if not os.path.exists(OPENVIKING_PID_FILE): | |
| write_log("OpenViking PID file not found, assuming already stopped") | |
| return True | |
| try: | |
| with open(OPENVIKING_PID_FILE, "r") as f: | |
| pid = int(f.read().strip()) | |
| except (ValueError, OSError) as e: | |
| write_log(f"Cannot read OpenViking PID: {e}") | |
| return True | |
| write_log(f"Stopping OpenViking (PID: {pid})") | |
| # SIGTERM | |
| try: | |
| os.kill(pid, signal.SIGTERM) | |
| except ProcessLookupError: | |
| write_log("OpenViking process not found") | |
| return True | |
| except OSError as e: | |
| write_log(f"Error sending SIGTERM: {e}") | |
| # Wait for graceful shutdown | |
| for _ in range(STOP_WAIT_ITERATIONS): | |
| try: | |
| os.kill(pid, 0) | |
| time.sleep(STOP_WAIT_INTERVAL) | |
| except ProcessLookupError: | |
| write_log("OpenViking stopped gracefully") | |
| return True | |
| except OSError: | |
| time.sleep(0.5) | |
| # SIGKILL | |
| write_log("OpenViking did not stop gracefully, sending SIGKILL") | |
| try: | |
| os.kill(pid, signal.SIGKILL) | |
| time.sleep(STOP_WAIT_INTERVAL) | |
| except OSError as e: | |
| write_log(f"Error sending SIGKILL: {e}") | |
| return False | |
| return True | |
| def start_openviking() -> bool: | |
| """Start the OpenViking server and write its PID file. | |
| Requires ``openviking-server`` on PATH and ``~/.openviking/ov.conf``. | |
| """ | |
| if not os.path.exists(OPENVIKING_CONFIG): | |
| write_log(f"OpenViking config not found: {OPENVIKING_CONFIG}") | |
| return False | |
| env = os.environ.copy() | |
| try: | |
| with open("/tmp/openviking-server.log", "w") as log_f: | |
| proc = subprocess.Popen( | |
| ["nohup", "openviking-server", "--config", OPENVIKING_CONFIG], | |
| stdout=log_f, | |
| stderr=subprocess.STDOUT, | |
| env=env, | |
| preexec_fn=os.setpgrp, | |
| ) | |
| except FileNotFoundError: | |
| write_log("openviking-server binary not found in PATH") | |
| return False | |
| except OSError as e: | |
| write_log(f"Failed to start OpenViking: {e}") | |
| return False | |
| with open(OPENVIKING_PID_FILE, "w") as f: | |
| f.write(str(proc.pid)) | |
| write_log(f"OpenViking started (PID: {proc.pid})") | |
| return True | |
| # ============================================================ | |
| # Gateway lifecycle | |
| # ============================================================ | |
| def restart_gateway() -> bool: | |
| """ OpenClaw Gateway """ | |
| import signal | |
| pid_file = "/tmp/openclaw-gateway.pid" | |
| restart_marker = "/tmp/openclaw-gateway-restart" | |
| gateway_port = os.environ.get("OPENCLAW_INTERNAL_PORT") | |
| if not gateway_port: | |
| base_port = int(os.environ.get("PORT", "7860")) | |
| gateway_port = str(base_port + 1) | |
| if not os.path.exists(pid_file): | |
| write_log("Gateway PID file not found, starting fresh...") | |
| try: | |
| proc = subprocess.Popen( | |
| ["nohup", "openclaw", "gateway", "run", "--port", str(gateway_port)], | |
| stdout=open("/tmp/gateway-restart.log", "w"), | |
| stderr=subprocess.STDOUT, | |
| env=os.environ.copy(), | |
| preexec_fn=os.setpgrp, | |
| ) | |
| with open(pid_file, "w") as f: | |
| f.write(str(proc.pid)) | |
| write_log(f"Gateway started directly (PID: {proc.pid})") | |
| # | |
| for i in range(30): | |
| time.sleep(1) | |
| r = subprocess.run(["curl", "-sf", f"http://127.0.0.1:{gateway_port}/health"], | |
| capture_output=True, timeout=5) | |
| if r.returncode == 0: | |
| write_log(f"Gateway ready on :{gateway_port}") | |
| return True | |
| write_log("Gateway start timeout (30s)") | |
| return False | |
| except Exception as e: | |
| write_log(f"Gateway start failed: {e}") | |
| return False | |
| try: | |
| with open(pid_file) as f: | |
| old_pid = int(f.read().strip()) | |
| # | |
| with open(restart_marker, "w") as f: | |
| f.write("1") | |
| # | |
| os.kill(old_pid, signal.SIGTERM) | |
| write_log(f"Gateway SIGTERM sent to PID {old_pid}") | |
| # | |
| for _ in range(20): | |
| try: | |
| os.kill(old_pid, 0) | |
| time.sleep(0.5) | |
| except ProcessLookupError: | |
| break | |
| # 60 | |
| for i in range(60): | |
| time.sleep(1) | |
| # PID | |
| try: | |
| result = subprocess.run( | |
| ["curl", "-sf", f"http://127.0.0.1:{gateway_port}/health"], | |
| capture_output=True, timeout=5, | |
| ) | |
| if result.returncode == 0: | |
| # PID | |
| if os.path.exists(pid_file): | |
| with open(pid_file) as f: | |
| new_pid = f.read().strip() | |
| write_log(f"Gateway restarted (PID: {new_pid}) on :{gateway_port}") | |
| else: | |
| write_log("Gateway restarted on :{gateway_port}") | |
| return True | |
| except Exception: | |
| pass | |
| write_log("Gateway restart timed out (60s)") | |
| return False | |
| except (FileNotFoundError, ValueError, ProcessLookupError, OSError) as e: | |
| write_log(f"Gateway restart failed: {e}") | |
| return False | |
| def _get_plugin_config(plugin_id: str) -> dict: | |
| """ openclaw.json """ | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| config_path = os.path.join(state_dir, "openclaw.json") | |
| try: | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| return cfg.get("plugins", {}).get("entries", {}).get(plugin_id, {}) | |
| except Exception as e: | |
| write_log(f"Read plugin config failed: {e}") | |
| return {} | |
| def restart_space(factory=False) -> bool: | |
| """ HF API / Spacefactory=True """ | |
| write_log("Restarting Space via HF API...") | |
| try: | |
| from huggingface_hub import HfApi | |
| token = os.environ.get("HF_TOKEN", "") | |
| api = HfApi() | |
| if factory: | |
| # commit HF | |
| import datetime | |
| marker = f"rebuild-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}" | |
| api.upload_file( | |
| path_or_fileobj=marker.encode(), | |
| path_in_repo=".rebuild-marker", | |
| repo_id="sharween/xiaoxiaxia", | |
| repo_type="space", | |
| token=token, | |
| ) | |
| write_log("Space rebuild triggered via commit") | |
| else: | |
| api.restart_space("sharween/xiaoxiaxia", token=token) | |
| write_log("Space restart initiated") | |
| return True | |
| except Exception as e: | |
| write_log(f"Space restart failed: {e}") | |
| return False | |
| # ============================================================ | |
| # Backup operations | |
| # ============================================================ | |
| _BACKUP_LABELS = { | |
| "quick": "", | |
| "full": "", | |
| "webdav_quick": "WEBDAV ", | |
| "webdav_full": "WEBDAV ", | |
| "dataset_quick": "DATASET ", | |
| "dataset_full": "DATASET ", | |
| } | |
| def _run_backup_cmd(cmd_args: list[str], timeout: int = BACKUP_CMD_TIMEOUT) -> bool: | |
| """Execute backup-manager.py <cmd_args> and log output.""" | |
| cmd = ["python3", BACKUP_MANAGER] + cmd_args | |
| write_log(f"Running: {' '.join(cmd)}") | |
| try: | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) | |
| if result.stdout: | |
| for line in result.stdout.strip().split("\n"): | |
| if line.strip(): | |
| write_log(line) | |
| if result.stderr: | |
| for line in result.stderr.strip().split("\n"): | |
| if line.strip(): | |
| write_log(f"[stderr] {line}") | |
| if result.returncode == 0: | |
| write_log("Backup command completed successfully") | |
| else: | |
| write_log(f"Backup command failed with code {result.returncode}") | |
| return result.returncode == 0 | |
| except subprocess.TimeoutExpired: | |
| write_log("Backup command timed out") | |
| return False | |
| except FileNotFoundError: | |
| write_log(f"backup-manager.py not found at {BACKUP_MANAGER}") | |
| return False | |
| except OSError as e: | |
| write_log(f"Backup command error: {e}") | |
| return False | |
| def _background_task(name: str, cmd_args: list[str]) -> None: | |
| """Run a backup command in a background daemon thread. | |
| OV OV vectordb . | |
| """ | |
| def _task() -> None: | |
| if not _backup_lock.acquire(blocking=False): | |
| write_log(f"{name} already in progress, request ignored") | |
| return | |
| try: | |
| write_separator() | |
| write_log(f"Starting {name}...") | |
| stop_openviking() | |
| _run_backup_cmd(cmd_args) | |
| start_openviking() | |
| write_log(f"{name} completed") | |
| write_separator() | |
| except Exception as e: | |
| write_log(f"{name} failed: {e}") | |
| write_separator() | |
| finally: | |
| _backup_lock.release() | |
| thread = threading.Thread(target=_task, daemon=True) | |
| thread.start() | |
| def background_webdav_backup() -> None: | |
| _background_task("WEBDAV ", ["incremental"]) | |
| def background_webdav_full_backup() -> None: | |
| _background_task("WEBDAV ", ["full"]) | |
| def background_dataset_backup() -> None: | |
| _background_task("DATASET ", ["small"]) | |
| def background_dataset_full_backup() -> None: | |
| _background_task("DATASET ", ["large"]) | |
| # ============================================================ | |
| # HTTP handler | |
| # ============================================================ | |
| class BackupHandler(http.server.BaseHTTPRequestHandler): | |
| """HTTP request handler for the backup control server.""" | |
| # Suppress default per-request stderr logging | |
| def log_message(self, fmt: str, *args) -> None: | |
| pass | |
| # ---- helpers ------------------------------------------------------- | |
| def _send_json(self, data, status: int = 200) -> None: | |
| body = json.dumps(data, ensure_ascii=False).encode("utf-8") | |
| self.send_response(status) | |
| self.send_header("Content-Type", "application/json; charset=utf-8") | |
| self.send_header("Access-Control-Allow-Origin", "*") | |
| self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | |
| self.send_header("Access-Control-Allow-Headers", "Content-Type") | |
| self.send_header("Content-Length", str(len(body))) | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def _serve_file(self, file_path: str) -> None: | |
| content_type, _ = mimetypes.guess_type(file_path) | |
| if content_type is None: | |
| content_type = "application/octet-stream" | |
| try: | |
| with open(file_path, "rb") as f: | |
| content = f.read() | |
| except OSError as e: | |
| self._send_json({"error": f"Failed to read file: {e}"}, 500) | |
| return | |
| self.send_response(200) | |
| self.send_header("Content-Type", content_type) | |
| self.send_header("Content-Length", str(len(content))) | |
| self.send_header("Access-Control-Allow-Origin", "*") | |
| self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | |
| self.send_header("Access-Control-Allow-Headers", "Content-Type") | |
| self.end_headers() | |
| self.wfile.write(content) | |
| # ---- CORS preflight ------------------------------------------------ | |
| def do_OPTIONS(self) -> None: | |
| self.send_response(204) | |
| self.send_header("Access-Control-Allow-Origin", "*") | |
| self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | |
| self.send_header("Access-Control-Allow-Headers", "Content-Type") | |
| self.end_headers() | |
| # ---- GET routes ---------------------------------------------------- | |
| def do_GET(self) -> None: | |
| parsed = urllib.parse.urlparse(self.path) | |
| path = parsed.path | |
| if path == "/api/status": | |
| self._handle_status() | |
| elif path == "/api/config": | |
| self._handle_config() | |
| elif path == "/api/models": | |
| self._handle_models() | |
| elif path == "/api/log": | |
| self._handle_log() | |
| elif path == "/api/backup/check": | |
| self._handle_backup_check() | |
| elif path == "/api/backup/list": | |
| self._handle_backup_list() | |
| elif path == "/api/plugins/hindsight/config": | |
| self._handle_hs_config() | |
| elif path == "/api/plugins/openviking/config": | |
| self._handle_ov_config() | |
| else: | |
| self._handle_static(path) | |
| def _handle_status(self) -> None: | |
| ov_running = pid_is_alive(OPENVIKING_PID_FILE) or port_is_alive(f"http://127.0.0.1:{OPENVIKING_PORT}/health") | |
| gw_running = port_is_alive(f"http://127.0.0.1:{GATEWAY_PORT}/health") | |
| # OV/Hindsight | |
| ov_enabled = True | |
| hs_enabled = False | |
| try: | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| oc_path = os.path.join(state_dir, "openclaw.json") | |
| with open(oc_path) as f: | |
| cfg = json.load(f) | |
| entries = cfg.get("plugins", {}).get("entries", {}) | |
| ov_enabled = entries.get("openviking", {}).get("enabled", True) | |
| hs_enabled = entries.get("hindsight-openclaw", {}).get("enabled", False) | |
| except Exception: | |
| pass | |
| # Check whether a backup thread is currently running | |
| bk_running = not _backup_lock.acquire(blocking=False) | |
| if not bk_running: | |
| _backup_lock.release() | |
| # Determine the last successful backup time from the log (read tail only) | |
| last_backup = None | |
| tail_lines = _read_log_tail(max_lines=200, max_bytes=50 * 1024) | |
| for line in reversed(tail_lines): | |
| if line.startswith("[") and "] " in line: | |
| ts_end = line.index("]") | |
| msg = line[ts_end + 2:] # strip off "[ts] " | |
| if "Backup command completed successfully" in msg or \ | |
| "Full backup completed" in msg: | |
| last_backup = line[1:ts_end] | |
| break | |
| self._send_json({ | |
| "openviking": {"status": ("running" if (ov_running and ov_enabled) else "stopped")}, | |
| "openclaw": {"status": ("running" if gw_running else "stopped")}, | |
| "hindsight": {"status": ("running" if hs_enabled else "stopped")}, | |
| "last_backup": last_backup, | |
| "backup_running": bk_running, | |
| }) | |
| def _handle_config(self) -> None: | |
| space_id = os.environ.get("SPACE_ID", "sharween/xiaoxiaxia") | |
| self._send_json({"space_id": space_id}) | |
| def _handle_models(self) -> None: | |
| models = [] | |
| for i in range(1, 7): | |
| model_id = os.environ.get(f"MODEL{i}_ID", "").strip() | |
| base_url = os.environ.get(f"MODEL{i}_BASEURL", "").strip().rstrip("/") | |
| api_key = os.environ.get(f"MODEL{i}_APIKEY", "").strip() | |
| if model_id and base_url and api_key: | |
| models.append({ | |
| "index": i, | |
| "id": model_id, | |
| "name": os.environ.get(f"MODEL{i}_NAME", model_id), | |
| "base_url": base_url, | |
| "has_key": bool(api_key), | |
| }) | |
| # Also get current VLM config from ov.conf | |
| current_vlm = None | |
| try: | |
| with open(os.path.expanduser("~/.openviking/ov.conf")) as f: | |
| cfg = json.load(f) | |
| current_vlm = cfg.get("vlm", {}) | |
| except Exception: | |
| write_log("Failed to read ov.conf for VLM config") | |
| self._send_json({"models": models, "current_vlm": current_vlm}) | |
| def _handle_log(self) -> None: | |
| lines = _read_log_tail(max_lines=200, max_bytes=20 * 1024) | |
| self._send_json({"lines": lines}) | |
| def _handle_backup_check(self) -> None: | |
| """""" | |
| try: | |
| proc = subprocess.run( | |
| ["python3", BACKUP_MANAGER, "check"], | |
| capture_output=True, text=True, timeout=30, | |
| ) | |
| if proc.returncode == 0 and proc.stdout.strip(): | |
| parsed = json.loads(proc.stdout.strip()) | |
| self._send_json(parsed) | |
| else: | |
| self._send_json({"backup_today": False, "last_backup": None}) | |
| except Exception as e: | |
| write_log(f"Backup check error: {e}") | |
| self._send_json({"backup_today": False, "last_backup": None}) | |
| def _handle_backup_list(self) -> None: | |
| """ WebDAV HF Dataset .""" | |
| result = {"webdav": [], "dataset": []} | |
| try: | |
| proc = subprocess.run( | |
| ["python3", BACKUP_MANAGER, "list"], | |
| capture_output=True, text=True, timeout=30, | |
| ) | |
| if proc.returncode == 0 and proc.stdout.strip(): | |
| parsed = json.loads(proc.stdout.strip()) | |
| result["webdav"] = parsed.get("webdav", []) | |
| result["dataset"] = parsed.get("dataset", []) | |
| except Exception as e: | |
| write_log(f"Backup list error: {e}") | |
| self._send_json(result) | |
| def _handle_restore(self) -> None: | |
| """.""" | |
| try: | |
| content_length = int(self.headers.get("Content-Length", 0)) | |
| if content_length > MAX_BODY_SIZE: # 10MB limit | |
| self._send_json({"status": "error", "message": "Request body too large"}, 413) | |
| return | |
| body = json.loads(self.rfile.read(content_length)) if content_length else {} | |
| except (json.JSONDecodeError, ValueError, OSError) as e: | |
| self._send_json({"status": "error", "message": f"Invalid request body: {e}"}, 400) | |
| return | |
| source = body.get("source", "auto") | |
| filename = body.get("filename", "") | |
| if not filename: | |
| self._send_json({"status": "error", "message": "filename is required"}, 400) | |
| return | |
| def _task() -> None: | |
| if not _backup_lock.acquire(blocking=False): | |
| write_log("Restore already in progress, request ignored") | |
| return | |
| try: | |
| write_separator() | |
| write_log(f"Starting restore from {source}: {filename}...") | |
| cmd = ["python3", BACKUP_MANAGER, "restore", | |
| "--source", source, "--filename", filename] | |
| write_log(f"Running: {' '.join(cmd)}") | |
| result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600) | |
| if result.stdout: | |
| for line in result.stdout.strip().split("\n"): | |
| if line.strip(): | |
| write_log(line) | |
| if result.stderr: | |
| for line in result.stderr.strip().split("\n"): | |
| if line.strip(): | |
| write_log(f"[stderr] {line}") | |
| if result.returncode == 0: | |
| write_log("Restore completed successfully") | |
| else: | |
| write_log(f"Restore failed with code {result.returncode}") | |
| write_separator() | |
| finally: | |
| _backup_lock.release() | |
| thread = threading.Thread(target=_task, daemon=True) | |
| thread.start() | |
| self._send_json({"status": "started"}) | |
| def _handle_static(self, path: str) -> None: | |
| # Default to control.html for the root path | |
| if path == "/" or not path: | |
| path = "/control.html" | |
| # Normalize and prevent directory traversal | |
| relative = os.path.normpath(path.lstrip("/")) | |
| file_path = os.path.join(CONTROL_DIR, relative) | |
| real_path = os.path.realpath(file_path) | |
| real_control = os.path.realpath(CONTROL_DIR) | |
| if not real_path.startswith(real_control + os.sep) and \ | |
| real_path != real_control: | |
| self._send_json({"error": "Forbidden"}, 403) | |
| return | |
| if os.path.isfile(file_path): | |
| self._serve_file(file_path) | |
| else: | |
| self._send_json({"error": "Not found"}, 404) | |
| # ---- POST routes --------------------------------------------------- | |
| def do_POST(self) -> None: | |
| parsed = urllib.parse.urlparse(self.path) | |
| path = parsed.path | |
| if path == "/api/openviking/stop": | |
| self._handle_stop_openviking() | |
| elif path == "/api/openviking/start": | |
| self._handle_start_openviking() | |
| elif path == "/api/backup": | |
| self._handle_backup() | |
| elif path == "/api/backup/full": | |
| self._handle_full_backup() | |
| elif path == "/api/backup/webdav/quick": | |
| self._handle_webdav_backup() | |
| elif path == "/api/backup/webdav/full": | |
| self._handle_webdav_full_backup() | |
| elif path == "/api/backup/dataset/quick": | |
| self._handle_dataset_backup() | |
| elif path == "/api/backup/dataset/full": | |
| self._handle_dataset_full_backup() | |
| elif path == "/api/openviking/vlm": | |
| self._handle_set_vlm() | |
| elif path == "/api/restore": | |
| self._handle_restore() | |
| elif path == "/api/gateway/restart": | |
| self._handle_gateway_restart() | |
| elif path == "/api/system/restart": | |
| self._handle_system_restart() | |
| elif path == "/api/system/rebuild": | |
| self._handle_system_rebuild() | |
| elif path == "/api/plugins/hindsight/config": | |
| self._handle_hs_set_config() | |
| elif path == "/api/plugins/hindsight/toggle": | |
| self._handle_hs_toggle() | |
| elif path == "/api/plugins/openviking/toggle": | |
| self._handle_ov_toggle() | |
| elif path == "/api/plugins/hindsight/apply-preset": | |
| self._handle_hs_apply_preset() | |
| else: | |
| self._send_json({"error": "Not found"}, 404) | |
| def _handle_stop_openviking(self) -> None: | |
| stop_openviking() | |
| self._send_json({"status": "ok", "message": "OpenViking stopped"}) | |
| def _handle_start_openviking(self) -> None: | |
| success = start_openviking() | |
| if success: | |
| self._send_json({"status": "ok", "message": "OpenViking started"}) | |
| else: | |
| self._send_json( | |
| {"status": "error", "message": "Failed to start OpenViking"}, | |
| 500, | |
| ) | |
| def _handle_backup(self) -> None: | |
| background_webdav_backup() | |
| self._send_json({"status": "started"}) | |
| def _handle_full_backup(self) -> None: | |
| background_webdav_full_backup() | |
| self._send_json({"status": "started"}) | |
| def _handle_webdav_backup(self) -> None: | |
| background_webdav_backup() | |
| self._send_json({"status": "started"}) | |
| def _handle_webdav_full_backup(self) -> None: | |
| background_webdav_full_backup() | |
| self._send_json({"status": "started"}) | |
| def _handle_dataset_backup(self) -> None: | |
| background_dataset_backup() | |
| self._send_json({"status": "started"}) | |
| def _handle_dataset_full_backup(self) -> None: | |
| background_dataset_full_backup() | |
| self._send_json({"status": "started"}) | |
| def _handle_set_vlm(self) -> None: | |
| try: | |
| content_length = int(self.headers.get("Content-Length", 0)) | |
| if content_length > MAX_BODY_SIZE: | |
| self._send_json({"status": "error", "message": "Request body too large"}, 413) | |
| return | |
| body = json.loads(self.rfile.read(content_length)) if content_length else {} | |
| except (json.JSONDecodeError, ValueError, OSError) as e: | |
| self._send_json({"status": "error", "message": f"Invalid request body: {e}"}, 400) | |
| return | |
| model_id = body.get("model", "").strip() | |
| base_url = body.get("base_url", "").strip().rstrip("/") | |
| api_key = body.get("api_key", "").strip() | |
| provider = body.get("provider", "openai") | |
| max_concurrent = body.get("max_concurrent", DEFAULT_MAX_CONCURRENT) | |
| # If model_index is provided, read config from env vars | |
| if "model_index" in body: | |
| idx = body["model_index"] | |
| model_id = os.environ.get(f"MODEL{idx}_ID", "").strip() | |
| base_url = os.environ.get(f"MODEL{idx}_BASEURL", "").strip().rstrip("/") | |
| api_key = os.environ.get(f"MODEL{idx}_APIKEY", "").strip() | |
| # Detect provider from base_url | |
| bl = base_url.lower() | |
| if "volces" in bl or "volcengine" in bl: | |
| provider = "volcengine" | |
| elif "anthropic" in bl or "claude" in model_id.lower(): | |
| provider = "litellm" | |
| else: | |
| provider = "openai" | |
| if not model_id or not base_url or not api_key: | |
| self._send_json({"status": "error", "message": "model, base_url and api_key are required"}, 400) | |
| return | |
| self._set_vlm_and_restart(model_id, base_url, api_key, provider, max_concurrent) | |
| def _set_vlm_and_restart(self, model_id, base_url, api_key, provider, max_concurrent): | |
| ov_conf_path = os.path.expanduser("~/.openviking/ov.conf") | |
| try: | |
| with open(ov_conf_path) as f: | |
| cfg = json.load(f) | |
| except Exception: | |
| cfg = { | |
| "server": {"host": "0.0.0.0", "port": 1933, "cors_origins": ["*"]}, | |
| "storage": {"workspace": "/root/.openclaw/openviking_data"}, | |
| "log": {"level": "INFO", "output": "stdout"}, | |
| } | |
| cfg["vlm"] = { | |
| "api_base": base_url, | |
| "api_key": api_key, | |
| "provider": provider, | |
| "model": model_id, | |
| "max_concurrent": max_concurrent, | |
| } | |
| os.makedirs(os.path.dirname(ov_conf_path), exist_ok=True) | |
| with open(ov_conf_path, "w") as f: | |
| json.dump(cfg, f, indent=2) | |
| stop_openviking() | |
| time.sleep(2) | |
| start_openviking() | |
| # VLM openclaw.json settings | |
| try: | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| oc_path = os.path.join(state_dir, "openclaw.json") | |
| with open(oc_path) as f: | |
| oc_cfg = json.load(f) | |
| oc_cfg.setdefault("settings", {})["vlm"] = { | |
| "model": model_id, | |
| "base_url": base_url, | |
| "provider": provider, | |
| } | |
| with open(oc_path, "w") as f: | |
| json.dump(oc_cfg, f, indent=2) | |
| except Exception as e: | |
| write_log(f"Save VLM to openclaw.json failed: {e}") | |
| self._send_json({"status": "ok", "message": f"VLM model set to {model_id}, OpenViking restarted"}) | |
| def _handle_gateway_restart(self) -> None: | |
| success = restart_gateway() | |
| if success: | |
| self._send_json({"status": "ok", "message": "Gateway "}) | |
| else: | |
| self._send_json({"status": "error", "message": "Gateway "}, 500) | |
| def _handle_hs_apply_preset(self) -> None: | |
| """ MODEL{1-6} HS """ | |
| try: | |
| cl = int(self.headers.get("Content-Length", 0)) | |
| body = json.loads(self.rfile.read(cl)) if cl else {} | |
| except Exception: | |
| self._send_json({"status": "error", "message": "Invalid body"}, 400) | |
| return | |
| idx = body.get("model_index") | |
| if not idx: | |
| self._send_json({"status": "error", "message": "model_index required"}, 400) | |
| return | |
| model_id = os.environ.get(f"MODEL{idx}_ID", "").strip() | |
| base_url = os.environ.get(f"MODEL{idx}_BASEURL", "").strip().rstrip("/") | |
| api_key = os.environ.get(f"MODEL{idx}_APIKEY", "").strip() | |
| if not model_id or not base_url or not api_key: | |
| self._send_json({"status": "error", "message": f"MODEL{idx} "}) | |
| return | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| config_path = os.path.join(state_dir, "openclaw.json") | |
| try: | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| hs_cfg = cfg.setdefault("plugins", {}).setdefault("entries", {}).setdefault("hindsight-openclaw", {}).setdefault("config", {}) | |
| hs_cfg["llmModel"] = model_id | |
| hs_cfg["llmBaseUrl"] = base_url | |
| hs_cfg["llmApiKey"] = api_key | |
| hs_cfg["llmProvider"] = body.get("llmProvider", "openai") | |
| with open(config_path, "w") as f: | |
| json.dump(cfg, f, indent=2) | |
| self._send_json({"status": "ok", "message": f" MODEL{idx}: {model_id}"}) | |
| except Exception as e: | |
| write_log(f"HS apply preset failed: {e}") | |
| self._send_json({"status": "error", "message": ""}, 500) | |
| def _handle_system_restart(self) -> None: | |
| self._send_json({"status": "ok", "message": "..."}) | |
| # | |
| threading.Timer(2.0, restart_space).start() | |
| def _handle_system_rebuild(self) -> None: | |
| self._send_json({"status": "ok", "message": "..."}) | |
| threading.Timer(2.0, restart_space, kwargs={"factory": True}).start() | |
| def _handle_ov_config(self) -> None: | |
| """ OV """ | |
| cfg = _get_plugin_config("openviking") | |
| self._send_json({ | |
| "enabled": cfg.get("enabled", True), | |
| "version": cfg.get("version", ""), | |
| }) | |
| def _handle_hs_config(self) -> None: | |
| """ Hindsight """ | |
| cfg = _get_plugin_config("hindsight-openclaw") | |
| hs_config = cfg.get("config", {}) | |
| self._send_json({ | |
| "enabled": cfg.get("enabled", False), | |
| "version": cfg.get("version", ""), | |
| "llmProvider": hs_config.get("llmProvider", ""), | |
| "llmModel": hs_config.get("llmModel", ""), | |
| "llmApiKey": bool(hs_config.get("llmApiKey", "")), | |
| "hindsightApiUrl": hs_config.get("hindsightApiUrl", ""), | |
| "hindsightApiToken": bool(hs_config.get("hindsightApiToken", "")), | |
| "autoRecall": hs_config.get("autoRecall", True), | |
| "autoRetain": hs_config.get("autoRetain", True), | |
| "recallBudget": hs_config.get("recallBudget", "mid"), | |
| }) | |
| def _handle_hs_set_config(self) -> None: | |
| try: | |
| cl = int(self.headers.get("Content-Length", 0)) | |
| body = json.loads(self.rfile.read(cl)) if cl else {} | |
| except Exception: | |
| self._send_json({"status": "error", "message": "Invalid body"}, 400) | |
| return | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| config_path = os.path.join(state_dir, "openclaw.json") | |
| try: | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| hs_config = cfg.setdefault("plugins", {}).setdefault("entries", {}).setdefault("hindsight-openclaw", {}).setdefault("config", {}) | |
| for key in ["llmProvider", "llmModel", "llmBaseUrl", "hindsightApiUrl"]: | |
| if key in body: | |
| hs_config[key] = body[key] | |
| for key in ["llmApiKey", "hindsightApiToken"]: | |
| if key in body and body[key] and body[key] != "": | |
| hs_config[key] = body[key] | |
| for key in ["autoRecall", "autoRetain"]: | |
| if key in body: | |
| hs_config[key] = bool(body[key]) | |
| if "recallBudget" in body: | |
| hs_config["recallBudget"] = body["recallBudget"] | |
| with open(config_path, "w") as f: | |
| json.dump(cfg, f, indent=2) | |
| self._send_json({"status": "ok", "message": ""}) | |
| except Exception as e: | |
| write_log(f"HS config save failed: {e}") | |
| self._send_json({"status": "error", "message": ""}, 500) | |
| def _handle_hs_toggle(self) -> None: | |
| """ openclaw.json /""" | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| config_path = os.path.join(state_dir, "openclaw.json") | |
| try: | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| entries = cfg.setdefault("plugins", {}).setdefault("entries", {}) | |
| hs = entries.setdefault("hindsight-openclaw", {}) | |
| current = hs.get("enabled", False) | |
| hs["enabled"] = not current | |
| with open(config_path, "w") as f: | |
| json.dump(cfg, f, indent=2) | |
| new_state = not current | |
| self._send_json({ | |
| "status": "ok", "enabled": new_state, | |
| "message": "Hindsight " + ("" if new_state else "") + " Gateway " | |
| }) | |
| write_log("Hindsight " + ("enabled" if new_state else "disabled")) | |
| except Exception as e: | |
| write_log(f"HS toggle failed: {e}") | |
| self._send_json({"status": "error", "message": ""}, 500) | |
| def _handle_ov_toggle(self) -> None: | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| config_path = os.path.join(state_dir, "openclaw.json") | |
| try: | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| entries = cfg.setdefault("plugins", {}).setdefault("entries", {}) | |
| ov = entries.setdefault("openviking", {}) | |
| current = ov.get("enabled", True) | |
| ov["enabled"] = not current | |
| with open(config_path, "w") as f: | |
| json.dump(cfg, f, indent=2) | |
| new_state = not current | |
| self._send_json({ | |
| "status": "ok", "enabled": new_state, | |
| "message": "OpenViking " + ("" if new_state else "") + " Gateway " | |
| }) | |
| write_log("OpenViking " + ("enabled" if new_state else "disabled")) | |
| except Exception as e: | |
| write_log(f"OV toggle failed: {e}") | |
| self._send_json({"status": "error", "message": ""}, 500) | |
| def _handle_hs_apply_preset(self) -> None: | |
| """ MODEL{1-6} HS """ | |
| try: | |
| cl = int(self.headers.get("Content-Length", 0)) | |
| body = json.loads(self.rfile.read(cl)) if cl else {} | |
| except Exception: | |
| self._send_json({"status": "error", "message": "Invalid body"}, 400) | |
| return | |
| idx = body.get("model_index") | |
| if not idx: | |
| self._send_json({"status": "error", "message": "model_index required"}, 400) | |
| return | |
| model_id = os.environ.get(f"MODEL{idx}_ID", "").strip() | |
| base_url = os.environ.get(f"MODEL{idx}_BASEURL", "").strip().rstrip("/") | |
| api_key = os.environ.get(f"MODEL{idx}_APIKEY", "").strip() | |
| if not model_id or not base_url or not api_key: | |
| self._send_json({"status": "error", "message": f"MODEL{idx} "}) | |
| return | |
| state_dir = os.environ.get("OPENCLAW_STATE_DIR", "/root/.openclaw") | |
| config_path = os.path.join(state_dir, "openclaw.json") | |
| try: | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| hs_cfg = cfg.setdefault("plugins", {}).setdefault("entries", {}).setdefault("hindsight-openclaw", {}).setdefault("config", {}) | |
| hs_cfg["llmModel"] = model_id | |
| hs_cfg["llmBaseUrl"] = base_url | |
| hs_cfg["llmApiKey"] = api_key | |
| hs_cfg["llmProvider"] = body.get("llmProvider", "openai") | |
| with open(config_path, "w") as f: | |
| json.dump(cfg, f, indent=2) | |
| self._send_json({"status": "ok", "message": f" MODEL{idx}: {model_id}"}) | |
| except Exception as e: | |
| write_log(f"HS apply preset failed: {e}") | |
| self._send_json({"status": "error", "message": ""}, 500) | |
| def _handle_system_restart(self) -> None: | |
| self._send_json({"status": "ok", "message": "..."}) | |
| threading.Timer(2.0, restart_space).start() | |
| # ============================================================ | |
| # Entry point | |
| # ============================================================ | |
| def main() -> None: | |
| # Ensure the log file exists | |
| try: | |
| with open(BACKUP_LOG, "a", encoding="utf-8"): | |
| pass | |
| except OSError as e: | |
| print(f"Warning: cannot create log file {BACKUP_LOG}: {e}") | |
| server = http.server.ThreadingHTTPServer( | |
| ("0.0.0.0", SERVER_PORT), BackupHandler | |
| ) | |
| write_log(f"Control server starting on port {SERVER_PORT}") | |
| print(f"Control server listening on http://0.0.0.0:{SERVER_PORT}") | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| write_log("Control server shutting down") | |
| server.shutdown() | |
| if __name__ == "__main__": | |
| main() | |