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: def _cache_path(self, source: str) -> Path:
return self.data_dir / f"{source}_registry_cache.json" 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]: def _load_installed(self) -> dict[str, Any]:
data = _read_json(self.installed_path) or {} data = _read_json(self.installed_path) or {}
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
@@ -426,39 +439,48 @@ class CliAppManager:
*, *,
force_refresh: bool = False, force_refresh: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
cached = _read_json(cache_path) data, cached_at = self._cached_registry(cache_path)
if ( if (
not force_refresh not force_refresh
and cached and data is not None
and _now() - float(cached.get("_cached_at", 0)) < self.runtime.catalog_ttl_seconds and _now() - cached_at < self.runtime.catalog_ttl_seconds
): ):
data = cached.get("data") return data
if isinstance(data, dict):
return data
try: try:
response = httpx.get(url, timeout=15.0, follow_redirects=True) response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status() response.raise_for_status()
data = response.json() fetched = response.json()
if not isinstance(data, dict): if not isinstance(fetched, dict):
raise ValueError("registry response must be an object") raise ValueError("registry response must be an object")
except Exception: except Exception:
if cached and isinstance(cached.get("data"), dict): if data is not None:
return cached["data"] return data
raise raise
_write_json(cache_path, {"_cached_at": _now(), "data": data}) _write_json(cache_path, {"_cached_at": _now(), "data": fetched})
return data 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]]] = [] registries: list[tuple[str, str, dict[str, Any]]] = []
for source, url, raw_base, required in _CATALOG_SOURCES: for source, url, raw_base, required in _CATALOG_SOURCES:
try: try:
registry = self._fetch_registry( cache_path = self._cache_path(source)
url, if cache_only:
self._cache_path(source), registry, _ = self._cached_registry(cache_path)
force_refresh=force_refresh, if registry is None:
) continue
else:
registry = self._fetch_registry(
url,
cache_path,
force_refresh=force_refresh,
)
except Exception: except Exception:
if required: if required:
raise raise
@@ -488,6 +510,15 @@ class CliAppManager:
apps_by_name[key] = entry apps_by_name[key] = entry
return list(apps_by_name.values()), max(updated_values) if updated_values else None 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: def _manifest_source(self, app: dict[str, Any]) -> str:
source = str(app.get("_source") or "harness") source = str(app.get("_source") or "harness")
if source == "extensions": if source == "extensions":
@@ -674,8 +705,8 @@ class CliAppManager:
}, },
) )
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]: def payload(self, *, force_refresh: bool = False, cache_only: bool = False) -> dict[str, Any]:
apps, updated = self.catalog(force_refresh=force_refresh) apps, updated = self.catalog(force_refresh=force_refresh, cache_only=cache_only)
installed = self._load_installed() installed = self._load_installed()
rows = [self._app_payload(app, installed) for app in apps] rows = [self._app_payload(app, installed) for app in apps]
rows.sort(key=lambda item: (str(item["category"]), str(item["display_name"]).lower())) 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 from __future__ import annotations
import re import re
import threading
import time
from typing import Any from typing import Any
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
@@ -19,6 +21,39 @@ _CLI_APP_ATTACHMENT_KEYS = (
"logo_url", "logo_url",
"brand_color", "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: 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() manager = _manager()
if installed_only: if installed_only:
return manager.installed_payload() 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]: def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
+30
View File
@@ -270,6 +270,36 @@ def test_optional_extension_registry_failure_does_not_break_payload(
assert [app["name"] for app in payload["apps"]] == ["gimp"] assert [app["name"] for app in payload["apps"]] == ["gimp"]
def test_payload_cache_only_does_not_fetch_catalog(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
def fail_get(*args, **kwargs):
raise AssertionError("network should not be used")
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
payload = manager.payload(cache_only=True)
assert payload["catalog_updated_at"] == "2026-04-18"
assert {app["name"] for app in payload["apps"]} >= {"gimp", "feishu"}
assert manager.catalog_cache_fresh() is True
def test_payload_cache_only_without_cache_returns_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manager = _manager(tmp_path)
def fail_get(*args, **kwargs):
raise AssertionError("network should not be used")
monkeypatch.setattr("nanobot.apps.cli.service.httpx.get", fail_get)
payload = manager.payload(cache_only=True)
assert payload == {"apps": [], "installed_count": 0, "catalog_updated_at": None}
assert manager.catalog_cache_fresh() is False
def test_install_dispatches_safe_pip_and_installs_skill( def test_install_dispatches_safe_pip_and_installs_skill(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from typing import Any
from nanobot.webui import cli_apps_api
class _FakeManager:
def __init__(self, *, fresh: bool, apps: list[dict[str, Any]] | None = None) -> None:
self.fresh = fresh
self.apps = apps or []
self.payload_calls: list[bool] = []
def payload(self, *, cache_only: bool = False) -> dict[str, Any]:
self.payload_calls.append(cache_only)
return {
"apps": list(self.apps),
"installed_count": 0,
"catalog_updated_at": "2026-04-18" if self.apps else None,
}
def catalog_cache_fresh(self) -> bool:
return self.fresh
def installed_payload(self) -> dict[str, Any]:
return {
"apps": [
{
"name": "gimp",
"display_name": "GIMP",
"category": "image",
"description": "Image editing",
"requires": "Python",
"source": "local",
"entry_point": "cli-anything-gimp",
"install_supported": True,
"installed": True,
"available": True,
"status": "installed",
"logo_url": None,
"brand_color": None,
"skill_installed": True,
}
],
"installed_count": 1,
"catalog_updated_at": None,
}
def test_cli_apps_payload_uses_cache_and_marks_refresh_pending(monkeypatch) -> None:
manager = _FakeManager(fresh=False)
refreshes = []
monkeypatch.setattr(cli_apps_api, "_manager", lambda: manager)
monkeypatch.setattr(cli_apps_api, "_start_catalog_refresh", lambda: refreshes.append(True))
payload = cli_apps_api.cli_apps_payload()
assert manager.payload_calls == [True]
assert refreshes == [True]
assert payload["catalog_refresh_pending"] is True
assert payload["apps"][0]["name"] == "gimp"
def test_cli_apps_payload_skips_refresh_when_cache_is_fresh(monkeypatch) -> None:
manager = _FakeManager(
fresh=True,
apps=[
{
"name": "gimp",
"display_name": "GIMP",
"category": "image",
"description": "Image editing",
"requires": "Python",
"source": "harness",
"entry_point": "cli-anything-gimp",
"install_supported": True,
"installed": False,
"available": False,
"status": "not_installed",
"logo_url": None,
"brand_color": None,
"skill_installed": False,
}
],
)
refreshes = []
monkeypatch.setattr(cli_apps_api, "_manager", lambda: manager)
monkeypatch.setattr(cli_apps_api, "_start_catalog_refresh", lambda: refreshes.append(True))
payload = cli_apps_api.cli_apps_payload()
assert manager.payload_calls == [True]
assert refreshes == []
assert payload["catalog_refresh_pending"] is False
assert payload["apps"][0]["source"] == "harness"
+21 -12
View File
@@ -695,22 +695,31 @@ export function SettingsView({
useEffect(() => { useEffect(() => {
if (activeSection !== "apps") return; if (activeSection !== "apps") return;
let cancelled = false; let cancelled = false;
setCliAppsLoading(true); let retry: number | null = null;
fetchCliApps(token) const loadCliApps = (showLoading: boolean) => {
.then((payload) => { if (showLoading) setCliAppsLoading(true);
if (!cancelled) { fetchCliApps(token)
.then((payload) => {
if (cancelled) return;
if (payload.catalog_refresh_pending) {
retry = window.setTimeout(() => loadCliApps(false), 2000);
if (payload.apps.length === 0) return;
}
setCliApps(payload); setCliApps(payload);
setCliAppsError(null); setCliAppsError(null);
} setCliAppsLoading(false);
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) setCliAppsError((err as Error).message); if (!cancelled) {
}) setCliAppsError((err as Error).message);
.finally(() => { setCliAppsLoading(false);
if (!cancelled) setCliAppsLoading(false); }
}); });
};
loadCliApps(true);
return () => { return () => {
cancelled = true; cancelled = true;
if (retry !== null) window.clearTimeout(retry);
}; };
}, [activeSection, token]); }, [activeSection, token]);
+1
View File
@@ -605,6 +605,7 @@ export interface CliAppsPayload {
apps: CliAppInfo[]; apps: CliAppInfo[];
installed_count: number; installed_count: number;
catalog_updated_at?: string | null; catalog_updated_at?: string | null;
catalog_refresh_pending?: boolean;
last_action?: { last_action?: {
ok: boolean; ok: boolean;
message: string; message: string;