Avoid blocking settings on CLI Apps catalog refresh
This commit is contained in:
+51
-20
@@ -407,6 +407,19 @@ class CliAppManager:
|
||||
def _cache_path(self, source: str) -> Path:
|
||||
return self.data_dir / f"{source}_registry_cache.json"
|
||||
|
||||
def _cached_registry(self, cache_path: Path) -> tuple[dict[str, Any] | None, float]:
|
||||
cached = _read_json(cache_path)
|
||||
if not cached:
|
||||
return None, 0.0
|
||||
data = cached.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return None, 0.0
|
||||
try:
|
||||
cached_at = float(cached.get("_cached_at", 0))
|
||||
except (TypeError, ValueError):
|
||||
cached_at = 0.0
|
||||
return data, cached_at
|
||||
|
||||
def _load_installed(self) -> dict[str, Any]:
|
||||
data = _read_json(self.installed_path) or {}
|
||||
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
|
||||
@@ -426,39 +439,48 @@ class CliAppManager:
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
cached = _read_json(cache_path)
|
||||
data, cached_at = self._cached_registry(cache_path)
|
||||
if (
|
||||
not force_refresh
|
||||
and cached
|
||||
and _now() - float(cached.get("_cached_at", 0)) < self.runtime.catalog_ttl_seconds
|
||||
and data is not None
|
||||
and _now() - cached_at < self.runtime.catalog_ttl_seconds
|
||||
):
|
||||
data = cached.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return data
|
||||
|
||||
try:
|
||||
response = httpx.get(url, timeout=15.0, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
fetched = response.json()
|
||||
if not isinstance(fetched, dict):
|
||||
raise ValueError("registry response must be an object")
|
||||
except Exception:
|
||||
if cached and isinstance(cached.get("data"), dict):
|
||||
return cached["data"]
|
||||
if data is not None:
|
||||
return data
|
||||
raise
|
||||
|
||||
_write_json(cache_path, {"_cached_at": _now(), "data": data})
|
||||
return data
|
||||
_write_json(cache_path, {"_cached_at": _now(), "data": fetched})
|
||||
return fetched
|
||||
|
||||
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
|
||||
def catalog(
|
||||
self,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
cache_only: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
registries: list[tuple[str, str, dict[str, Any]]] = []
|
||||
for source, url, raw_base, required in _CATALOG_SOURCES:
|
||||
try:
|
||||
registry = self._fetch_registry(
|
||||
url,
|
||||
self._cache_path(source),
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
cache_path = self._cache_path(source)
|
||||
if cache_only:
|
||||
registry, _ = self._cached_registry(cache_path)
|
||||
if registry is None:
|
||||
continue
|
||||
else:
|
||||
registry = self._fetch_registry(
|
||||
url,
|
||||
cache_path,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
except Exception:
|
||||
if required:
|
||||
raise
|
||||
@@ -488,6 +510,15 @@ class CliAppManager:
|
||||
apps_by_name[key] = entry
|
||||
return list(apps_by_name.values()), max(updated_values) if updated_values else None
|
||||
|
||||
def catalog_cache_fresh(self) -> bool:
|
||||
for source, _url, _raw_base, required in _CATALOG_SOURCES:
|
||||
if not required:
|
||||
continue
|
||||
data, cached_at = self._cached_registry(self._cache_path(source))
|
||||
if data is None or _now() - cached_at >= self.runtime.catalog_ttl_seconds:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _manifest_source(self, app: dict[str, Any]) -> str:
|
||||
source = str(app.get("_source") or "harness")
|
||||
if source == "extensions":
|
||||
@@ -674,8 +705,8 @@ class CliAppManager:
|
||||
},
|
||||
)
|
||||
|
||||
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
apps, updated = self.catalog(force_refresh=force_refresh)
|
||||
def payload(self, *, force_refresh: bool = False, cache_only: bool = False) -> dict[str, Any]:
|
||||
apps, updated = self.catalog(force_refresh=force_refresh, cache_only=cache_only)
|
||||
installed = self._load_installed()
|
||||
rows = [self._app_payload(app, installed) for app in apps]
|
||||
rows.sort(key=lambda item: (str(item["category"]), str(item["display_name"]).lower()))
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
@@ -19,6 +21,39 @@ _CLI_APP_ATTACHMENT_KEYS = (
|
||||
"logo_url",
|
||||
"brand_color",
|
||||
)
|
||||
_CATALOG_REFRESH_RETRY_SECONDS = 60.0
|
||||
_catalog_refresh_lock = threading.Lock()
|
||||
_catalog_refresh_running = False
|
||||
_catalog_refresh_last_started = 0.0
|
||||
|
||||
|
||||
def _start_catalog_refresh() -> bool:
|
||||
global _catalog_refresh_last_started, _catalog_refresh_running
|
||||
now = time.monotonic()
|
||||
with _catalog_refresh_lock:
|
||||
if _catalog_refresh_running:
|
||||
return True
|
||||
if now - _catalog_refresh_last_started < _CATALOG_REFRESH_RETRY_SECONDS:
|
||||
return True
|
||||
_catalog_refresh_running = True
|
||||
_catalog_refresh_last_started = now
|
||||
|
||||
def refresh() -> None:
|
||||
global _catalog_refresh_running
|
||||
try:
|
||||
_manager().catalog(force_refresh=True)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
with _catalog_refresh_lock:
|
||||
_catalog_refresh_running = False
|
||||
|
||||
threading.Thread(
|
||||
target=refresh,
|
||||
name="nanobot-cli-app-catalog-refresh",
|
||||
daemon=True,
|
||||
).start()
|
||||
return True
|
||||
|
||||
|
||||
def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
|
||||
@@ -77,7 +112,16 @@ def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||
manager = _manager()
|
||||
if installed_only:
|
||||
return manager.installed_payload()
|
||||
return manager.payload()
|
||||
payload = manager.payload(cache_only=True)
|
||||
refresh_pending = not manager.catalog_cache_fresh()
|
||||
if refresh_pending:
|
||||
_start_catalog_refresh()
|
||||
if not payload["apps"]:
|
||||
installed = manager.installed_payload()
|
||||
if installed["apps"]:
|
||||
payload = installed
|
||||
payload["catalog_refresh_pending"] = refresh_pending
|
||||
return payload
|
||||
|
||||
|
||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user