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:
|
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()))
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user