from __future__ import annotations import json import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Any def utc_now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def _load_json(path: Path, default: dict[str, Any]) -> dict[str, Any]: if not path.exists(): return default try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: return default return data if isinstance(data, dict) else default def _save_json(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") def load_applied_model_config(path: Path) -> dict[str, Any]: return _load_json(path, {"version": 0, "value": {}, "updated_at": None, "applied_at": None}) def save_applied_model_config(path: Path, payload: dict[str, Any]) -> None: _save_json(path, payload) def public_model_config_summary(config: dict[str, Any]) -> dict[str, Any]: value = dict(config.get("value") or {}) value["api_key"] = "" value["api_key_set"] = bool(value.get("api_key_set") or (config.get("value") or {}).get("api_key")) return { "version": int(config.get("version") or 0), "updated_at": config.get("updated_at"), "applied_at": config.get("applied_at"), "value": value, } class RuntimeRestartManager: def __init__(self, path: Path, command: str = ""): self.path = path self.command = command.strip() self.process: subprocess.Popen | None = None self.state = _load_json( path, { "generation": 0, "last_restart_at": None, "reason": "", "model_config_version": 0, "mode": "placeholder", }, ) @property def generation(self) -> int: try: return int(self.state.get("generation") or 0) except (TypeError, ValueError): return 0 def _start_process(self) -> int | None: if not self.command: return None if self.process and self.process.poll() is None: return self.process.pid self.process = subprocess.Popen( self.command, cwd=str(self.path.parent.parent), shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return self.process.pid def stop(self) -> None: if not self.process or self.process.poll() is not None: return self.process.terminate() try: self.process.wait(timeout=5) except subprocess.TimeoutExpired: self.process.kill() self.process.wait(timeout=5) def ensure_started(self, version: int) -> dict[str, Any] | None: if not self.command: return None pid = self._start_process() now = utc_now() self.state = { "generation": self.generation, "last_restart_at": self.state.get("last_restart_at"), "reason": self.state.get("reason") or "startup", "model_config_version": version, "mode": "subprocess", "pid": pid, "command_configured": True, "checked_at": now, } _save_json(self.path, self.state) return self.state def restart_for_model_config(self, version: int) -> dict[str, Any]: now = utc_now() self.stop() pid = self._start_process() self.state = { "generation": self.generation + 1, "last_restart_at": now, "reason": f"model_config_v{version}", "model_config_version": version, "mode": "subprocess" if self.command else "placeholder", "pid": pid, "command_configured": bool(self.command), } _save_json(self.path, self.state) return self.state