From dd2cb4ca91d3aade01952388897c94b08c89a7f0 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Fri, 19 Jun 2026 00:29:57 +0800 Subject: [PATCH] fix: refresh optional CLI Apps catalogs maintainer edit: CLI Apps settings now treats optional catalog caches as refresh candidates without blocking the initial payload, and pending polling stops when refresh is throttled instead of running indefinitely. --- nanobot/apps/cli/service.py | 4 +- nanobot/webui/cli_apps_api.py | 8 +-- tests/cli_apps/test_service.py | 15 ++++ tests/webui/test_cli_apps_api.py | 70 +++++++++++++++++-- .../src/components/settings/SettingsView.tsx | 11 ++- 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index b4d0c20e..f366a15b 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -510,9 +510,9 @@ 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: + def catalog_cache_fresh(self, *, include_optional: bool = False) -> bool: for source, _url, _raw_base, required in _CATALOG_SOURCES: - if not required: + if not required and not include_optional: continue data, cached_at = self._cached_registry(self._cache_path(source)) if data is None or _now() - cached_at >= self.runtime.catalog_ttl_seconds: diff --git a/nanobot/webui/cli_apps_api.py b/nanobot/webui/cli_apps_api.py index 306453d7..c0a4bf0a 100644 --- a/nanobot/webui/cli_apps_api.py +++ b/nanobot/webui/cli_apps_api.py @@ -34,7 +34,7 @@ def _start_catalog_refresh() -> bool: if _catalog_refresh_running: return True if now - _catalog_refresh_last_started < _CATALOG_REFRESH_RETRY_SECONDS: - return True + return False _catalog_refresh_running = True _catalog_refresh_last_started = now @@ -113,9 +113,9 @@ def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]: if installed_only: return manager.installed_payload() payload = manager.payload(cache_only=True) - refresh_pending = not manager.catalog_cache_fresh() - if refresh_pending: - _start_catalog_refresh() + refresh_pending = False + if not manager.catalog_cache_fresh(include_optional=True): + refresh_pending = _start_catalog_refresh() if not payload["apps"]: installed = manager.installed_payload() if installed["apps"]: diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index dd7d3abc..a42fa430 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -286,6 +286,21 @@ def test_payload_cache_only_does_not_fetch_catalog(tmp_path: Path, monkeypatch: assert manager.catalog_cache_fresh() is True +def test_catalog_cache_fresh_can_include_optional_sources(tmp_path: Path) -> None: + manager = _manager(tmp_path) + _write_cache( + manager._cache_path("harness"), + {"meta": {"updated": "2026-04-16"}, "clis": []}, + ) + _write_cache( + manager._cache_path("public"), + {"meta": {"updated": "2026-04-18"}, "clis": []}, + ) + + assert manager.catalog_cache_fresh() is True + assert manager.catalog_cache_fresh(include_optional=True) is False + + def test_payload_cache_only_without_cache_returns_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: manager = _manager(tmp_path) diff --git a/tests/webui/test_cli_apps_api.py b/tests/webui/test_cli_apps_api.py index e3b58e16..41df402d 100644 --- a/tests/webui/test_cli_apps_api.py +++ b/tests/webui/test_cli_apps_api.py @@ -6,10 +6,18 @@ from nanobot.webui import cli_apps_api class _FakeManager: - def __init__(self, *, fresh: bool, apps: list[dict[str, Any]] | None = None) -> None: + def __init__( + self, + *, + fresh: bool, + apps: list[dict[str, Any]] | None = None, + all_sources_fresh: bool | None = None, + ) -> None: self.fresh = fresh + self.all_sources_fresh = fresh if all_sources_fresh is None else all_sources_fresh self.apps = apps or [] self.payload_calls: list[bool] = [] + self.fresh_checks: list[bool] = [] def payload(self, *, cache_only: bool = False) -> dict[str, Any]: self.payload_calls.append(cache_only) @@ -19,8 +27,9 @@ class _FakeManager: "catalog_updated_at": "2026-04-18" if self.apps else None, } - def catalog_cache_fresh(self) -> bool: - return self.fresh + def catalog_cache_fresh(self, *, include_optional: bool = False) -> bool: + self.fresh_checks.append(include_optional) + return self.all_sources_fresh if include_optional else self.fresh def installed_payload(self) -> dict[str, Any]: return { @@ -51,11 +60,12 @@ def test_cli_apps_payload_uses_cache_and_marks_refresh_pending(monkeypatch) -> N 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)) + monkeypatch.setattr(cli_apps_api, "_start_catalog_refresh", lambda: refreshes.append(True) or True) payload = cli_apps_api.cli_apps_payload() assert manager.payload_calls == [True] + assert manager.fresh_checks == [True] assert refreshes == [True] assert payload["catalog_refresh_pending"] is True assert payload["apps"][0]["name"] == "gimp" @@ -85,11 +95,61 @@ def test_cli_apps_payload_skips_refresh_when_cache_is_fresh(monkeypatch) -> None ) refreshes = [] monkeypatch.setattr(cli_apps_api, "_manager", lambda: manager) - monkeypatch.setattr(cli_apps_api, "_start_catalog_refresh", lambda: refreshes.append(True)) + monkeypatch.setattr(cli_apps_api, "_start_catalog_refresh", lambda: refreshes.append(True) or True) payload = cli_apps_api.cli_apps_payload() assert manager.payload_calls == [True] + assert manager.fresh_checks == [True] assert refreshes == [] assert payload["catalog_refresh_pending"] is False assert payload["apps"][0]["source"] == "harness" + + +def test_cli_apps_payload_refreshes_when_optional_cache_is_stale(monkeypatch) -> None: + manager = _FakeManager( + fresh=True, + all_sources_fresh=False, + 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) or True) + + payload = cli_apps_api.cli_apps_payload() + + assert manager.payload_calls == [True] + assert manager.fresh_checks == [True] + assert refreshes == [True] + assert payload["catalog_refresh_pending"] is True + assert payload["apps"][0]["source"] == "harness" + + +def test_cli_apps_payload_reports_not_pending_when_refresh_is_throttled(monkeypatch) -> None: + manager = _FakeManager(fresh=False) + monkeypatch.setattr(cli_apps_api, "_manager", lambda: manager) + monkeypatch.setattr(cli_apps_api, "_start_catalog_refresh", lambda: False) + + payload = cli_apps_api.cli_apps_payload() + + assert manager.payload_calls == [True] + assert manager.fresh_checks == [True] + assert payload["catalog_refresh_pending"] is False + assert payload["apps"][0]["name"] == "gimp" diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index f94d2645..d25a32d9 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -213,6 +213,8 @@ const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "volcengine_coding_plan", ]); const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2; +const CLI_APPS_REFRESH_RETRY_MS = 2_000; +const CLI_APPS_REFRESH_MAX_RETRIES = 30; const FALLBACK_TIMEZONES = [ "UTC", @@ -696,13 +698,18 @@ export function SettingsView({ if (activeSection !== "apps") return; let cancelled = false; let retry: number | null = null; + let retryCount = 0; const loadCliApps = (showLoading: boolean) => { if (showLoading) setCliAppsLoading(true); fetchCliApps(token) .then((payload) => { if (cancelled) return; - if (payload.catalog_refresh_pending) { - retry = window.setTimeout(() => loadCliApps(false), 2000); + if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) { + retryCount += 1; + retry = window.setTimeout(() => { + retry = null; + loadCliApps(false); + }, CLI_APPS_REFRESH_RETRY_MS); } setCliApps(payload); setCliAppsError(null);