Avoid blocking settings on CLI Apps catalog refresh

This commit is contained in:
chengyongru
2026-06-22 17:14:13 +08:00
committed by Xubin Ren
parent 7b153c5aa9
commit b8abe5542c
6 changed files with 243 additions and 33 deletions
+51 -20
View File
@@ -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()))
+45 -1
View File
@@ -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]: