Fix WebUI startup blocking on slow gateway routes
This commit is contained in:
@@ -685,6 +685,30 @@ class CliAppManager:
|
|||||||
"catalog_updated_at": updated,
|
"catalog_updated_at": updated,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def installed_payload(self) -> dict[str, Any]:
|
||||||
|
installed = self._load_installed()
|
||||||
|
rows = []
|
||||||
|
for name, raw_entry in sorted(installed.items()):
|
||||||
|
entry = raw_entry if isinstance(raw_entry, dict) else {}
|
||||||
|
strategy = str(entry.get("strategy") or "bundled")
|
||||||
|
app = {
|
||||||
|
"name": str(name),
|
||||||
|
"display_name": str(entry.get("display_name") or name),
|
||||||
|
"category": str(entry.get("category") or "installed"),
|
||||||
|
"description": str(entry.get("description") or ""),
|
||||||
|
"requires": str(entry.get("requires") or ""),
|
||||||
|
"_source": str(entry.get("source") or "local"),
|
||||||
|
"entry_point": str(entry.get("entry_point") or ""),
|
||||||
|
"package_manager": strategy,
|
||||||
|
"install_strategy": strategy,
|
||||||
|
}
|
||||||
|
rows.append(self._app_payload(app, installed))
|
||||||
|
return {
|
||||||
|
"apps": rows,
|
||||||
|
"installed_count": len(rows),
|
||||||
|
"catalog_updated_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
|
def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
|
||||||
install_cmd = str(app.get("install_cmd") or "")
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -736,6 +736,45 @@ class SessionManager:
|
|||||||
return self._session_payload(repaired)
|
return self._session_payload(repaired)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def read_session_metadata(self, key: str) -> dict[str, Any] | None:
|
||||||
|
"""Load only the metadata record from a session file.
|
||||||
|
|
||||||
|
This is used by WebUI routes that need session-level metadata but not the
|
||||||
|
full conversation transcript.
|
||||||
|
"""
|
||||||
|
path = self._get_session_path(key)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
data = json.loads(line)
|
||||||
|
if data.get("_type") != "metadata":
|
||||||
|
return None
|
||||||
|
metadata = data.get("metadata", {})
|
||||||
|
return {
|
||||||
|
"key": data.get("key") or key,
|
||||||
|
"created_at": data.get("created_at"),
|
||||||
|
"updated_at": data.get("updated_at"),
|
||||||
|
"metadata": metadata if isinstance(metadata, dict) else {},
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to read session metadata {}: {}", key, e)
|
||||||
|
repaired = self._repair(key)
|
||||||
|
if repaired is not None:
|
||||||
|
logger.info("Recovered read-only session metadata {} from corrupt file", key)
|
||||||
|
return {
|
||||||
|
"key": repaired.key,
|
||||||
|
"created_at": repaired.created_at.isoformat(),
|
||||||
|
"updated_at": repaired.updated_at.isoformat(),
|
||||||
|
"metadata": repaired.metadata,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict[str, Any]]:
|
def list_sessions(self) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List all sessions.
|
List all sessions.
|
||||||
|
|||||||
@@ -73,8 +73,11 @@ def _manager() -> CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def cli_apps_payload() -> dict[str, Any]:
|
def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||||
return _manager().payload()
|
manager = _manager()
|
||||||
|
if installed_only:
|
||||||
|
return manager.installed_payload()
|
||||||
|
return manager.payload()
|
||||||
|
|
||||||
|
|
||||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class WebUISettingsRouter:
|
|||||||
if path == "/api/settings/network-safety/update":
|
if path == "/api/settings/network-safety/update":
|
||||||
return self._handle_settings_network_safety_update(request)
|
return self._handle_settings_network_safety_update(request)
|
||||||
if path == "/api/settings/cli-apps":
|
if path == "/api/settings/cli-apps":
|
||||||
return self._handle_settings_cli_apps(request)
|
return await self._handle_settings_cli_apps(request)
|
||||||
if path == "/api/settings/cli-apps/install":
|
if path == "/api/settings/cli-apps/install":
|
||||||
return await self._handle_settings_cli_apps_action(request, "install")
|
return await self._handle_settings_cli_apps_action(request, "install")
|
||||||
if path == "/api/settings/cli-apps/update":
|
if path == "/api/settings/cli-apps/update":
|
||||||
@@ -299,11 +299,20 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
|
|
||||||
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
async def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
|
query = self._query(request)
|
||||||
|
installed_only = (query.get("installed_only") or [""])[0].lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
payload = cli_apps_payload()
|
if installed_only:
|
||||||
|
payload = await asyncio.to_thread(cli_apps_payload, installed_only=True)
|
||||||
|
else:
|
||||||
|
payload = await asyncio.to_thread(cli_apps_payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load CLI Apps payload")
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
return self._error_response(500, "failed to load CLI Apps")
|
return self._error_response(500, "failed to load CLI Apps")
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ class WebUIWorkspaceController:
|
|||||||
def scope_for_session_key(self, session_key: str) -> WorkspaceScope:
|
def scope_for_session_key(self, session_key: str) -> WorkspaceScope:
|
||||||
if self._sessions is None:
|
if self._sessions is None:
|
||||||
return self.default_scope()
|
return self.default_scope()
|
||||||
|
metadata_reader = getattr(self._sessions, "read_session_metadata", None)
|
||||||
|
if callable(metadata_reader):
|
||||||
|
data = metadata_reader(session_key)
|
||||||
|
else:
|
||||||
data = self._sessions.read_session_file(session_key)
|
data = self._sessions.read_session_file(session_key)
|
||||||
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
|
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
|
||||||
if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata:
|
if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata:
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ Also houses shared HTTP utility functions used by both this module and
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -76,6 +78,8 @@ from nanobot.webui.thread_disk import delete_webui_thread
|
|||||||
from nanobot.webui.transcript import build_webui_thread_response
|
from nanobot.webui.transcript import build_webui_thread_response
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
|
|
||||||
|
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
@@ -192,7 +196,21 @@ class GatewayHTTPHandler:
|
|||||||
async def dispatch(self, connection: Any, request: WsRequest) -> Any | None:
|
async def dispatch(self, connection: Any, request: WsRequest) -> Any | None:
|
||||||
"""Route an HTTP request. Returns Response or None."""
|
"""Route an HTTP request. Returns Response or None."""
|
||||||
got, _ = _parse_request_path(request.path)
|
got, _ = _parse_request_path(request.path)
|
||||||
|
started = time.perf_counter()
|
||||||
|
response: Any | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._dispatch_resolved(connection, request, got)
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
self._log_slow_http(got, response, started)
|
||||||
|
|
||||||
|
async def _dispatch_resolved(
|
||||||
|
self,
|
||||||
|
connection: Any,
|
||||||
|
request: WsRequest,
|
||||||
|
got: str,
|
||||||
|
) -> Any | None:
|
||||||
# Token issue endpoint
|
# Token issue endpoint
|
||||||
if self.config.token_issue_path:
|
if self.config.token_issue_path:
|
||||||
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
issue_expected = _normalize_config_path(self.config.token_issue_path)
|
||||||
@@ -209,7 +227,7 @@ class GatewayHTTPHandler:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
# Session routes
|
# Session routes
|
||||||
response = self._dispatch_session_routes(request, got)
|
response = await self._dispatch_session_routes(request, got)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -219,7 +237,7 @@ class GatewayHTTPHandler:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
# Misc routes
|
# Misc routes
|
||||||
response = self._dispatch_misc_routes(connection, request, got)
|
response = await self._dispatch_misc_routes(connection, request, got)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -235,6 +253,20 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
return connection.respond(404, "Not Found")
|
return connection.respond(404, "Not Found")
|
||||||
|
|
||||||
|
def _log_slow_http(self, path: str, response: Any | None, started: float) -> None:
|
||||||
|
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
if elapsed_ms < _SLOW_WEBUI_HTTP_LOG_MS:
|
||||||
|
return
|
||||||
|
if not (path.startswith("/api/") or path == "/webui/bootstrap"):
|
||||||
|
return
|
||||||
|
status = getattr(response, "status_code", None)
|
||||||
|
self._log.warning(
|
||||||
|
"slow webui http route path={} status={} duration_ms={}",
|
||||||
|
path,
|
||||||
|
status if status is not None else "none",
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
|
||||||
# -- Token issue --------------------------------------------------------
|
# -- Token issue --------------------------------------------------------
|
||||||
|
|
||||||
def _handle_token_issue(self, connection: Any, request: Any) -> Any:
|
def _handle_token_issue(self, connection: Any, request: Any) -> Any:
|
||||||
@@ -302,7 +334,7 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
# -- Session routes -----------------------------------------------------
|
# -- Session routes -----------------------------------------------------
|
||||||
|
|
||||||
def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||||
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||||
if m:
|
if m:
|
||||||
return self._handle_session_messages(request, m.group(1))
|
return self._handle_session_messages(request, m.group(1))
|
||||||
@@ -325,11 +357,16 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _handle_sessions_list(self, request: WsRequest) -> Response:
|
async def _handle_sessions_list(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if self.session_manager is None:
|
if self.session_manager is None:
|
||||||
return _http_error(503, "session manager unavailable")
|
return _http_error(503, "session manager unavailable")
|
||||||
|
payload = await asyncio.to_thread(self._sessions_list_payload)
|
||||||
|
return _http_json_response(payload)
|
||||||
|
|
||||||
|
def _sessions_list_payload(self) -> dict[str, Any]:
|
||||||
|
assert self.session_manager is not None
|
||||||
sessions = list_webui_sessions(self.session_manager)
|
sessions = list_webui_sessions(self.session_manager)
|
||||||
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
||||||
|
|
||||||
@@ -346,7 +383,7 @@ class GatewayHTTPHandler:
|
|||||||
scope = self.workspaces.scope_for_session_key(key)
|
scope = self.workspaces.scope_for_session_key(key)
|
||||||
row["workspace_scope"] = scope.payload()
|
row["workspace_scope"] = scope.payload()
|
||||||
cleaned.append(row)
|
cleaned.append(row)
|
||||||
return _http_json_response({"sessions": cleaned})
|
return {"sessions": cleaned}
|
||||||
|
|
||||||
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
|
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
@@ -496,11 +533,11 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
# -- Misc routes --------------------------------------------------------
|
# -- Misc routes --------------------------------------------------------
|
||||||
|
|
||||||
def _dispatch_misc_routes(
|
async def _dispatch_misc_routes(
|
||||||
self, connection: Any, request: WsRequest, got: str
|
self, connection: Any, request: WsRequest, got: str
|
||||||
) -> Response | None:
|
) -> Response | None:
|
||||||
if got == "/api/sessions":
|
if got == "/api/sessions":
|
||||||
return self._handle_sessions_list(request)
|
return await self._handle_sessions_list(request)
|
||||||
if got == "/api/commands":
|
if got == "/api/commands":
|
||||||
return self._handle_commands(request)
|
return self._handle_commands(request)
|
||||||
if got == "/api/workspaces":
|
if got == "/api/workspaces":
|
||||||
|
|||||||
@@ -2654,7 +2654,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
|||||||
try:
|
try:
|
||||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
|
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
|
||||||
req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")]))
|
req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")]))
|
||||||
resp = channel.gateway.http._handle_sessions_list(req)
|
resp = asyncio.run(channel.gateway.http._handle_sessions_list(req))
|
||||||
finally:
|
finally:
|
||||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -462,6 +464,85 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cli_apps_catalog_does_not_block_other_webui_http_routes(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
entered = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
|
||||||
|
def slow_payload() -> dict[str, Any]:
|
||||||
|
entered.set()
|
||||||
|
release.wait(2.0)
|
||||||
|
return {"apps": [], "installed_count": 0, "catalog_updated_at": None}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", slow_payload)
|
||||||
|
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29935)
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
try:
|
||||||
|
boot = await _http_get("http://127.0.0.1:29935/webui/bootstrap")
|
||||||
|
token = boot.json()["token"]
|
||||||
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
catalog_task = asyncio.create_task(
|
||||||
|
_http_get("http://127.0.0.1:29935/api/settings/cli-apps", headers=auth)
|
||||||
|
)
|
||||||
|
assert await asyncio.to_thread(entered.wait, 2.0)
|
||||||
|
assert time.perf_counter() - started < 1.0
|
||||||
|
|
||||||
|
workspaces_started = time.perf_counter()
|
||||||
|
workspaces = await _http_get("http://127.0.0.1:29935/api/workspaces", headers=auth)
|
||||||
|
assert time.perf_counter() - workspaces_started < 1.0
|
||||||
|
assert workspaces.status_code == 200
|
||||||
|
|
||||||
|
release.set()
|
||||||
|
catalog = await catalog_task
|
||||||
|
assert catalog.status_code == 200
|
||||||
|
assert catalog.json()["apps"] == []
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cli_apps_route_supports_installed_only_payload(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
calls: list[bool] = []
|
||||||
|
|
||||||
|
def payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||||
|
calls.append(installed_only)
|
||||||
|
return {"apps": [], "installed_count": 0, "catalog_updated_at": None}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", payload)
|
||||||
|
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29936)
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
try:
|
||||||
|
boot = await _http_get("http://127.0.0.1:29936/webui/bootstrap")
|
||||||
|
token = boot.json()["token"]
|
||||||
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = await _http_get(
|
||||||
|
"http://127.0.0.1:29936/api/settings/cli-apps?installed_only=1",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["apps"] == []
|
||||||
|
assert calls == [True]
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_presets_routes_require_token_and_return_payload(
|
async def test_mcp_presets_routes_require_token_and_return_payload(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from nanobot.webui.workspaces import (
|
|||||||
read_webui_default_access_mode,
|
read_webui_default_access_mode,
|
||||||
read_webui_workspace_state,
|
read_webui_workspace_state,
|
||||||
webui_workspace_state_path,
|
webui_workspace_state_path,
|
||||||
write_webui_default_access_mode,
|
|
||||||
workspaces_payload,
|
workspaces_payload,
|
||||||
|
write_webui_default_access_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -152,3 +152,32 @@ def test_webui_default_access_does_not_override_explicit_session_scope(tmp_path,
|
|||||||
|
|
||||||
assert scope.project_path == project.resolve()
|
assert scope.project_path == project.resolve()
|
||||||
assert scope.access_mode == "full"
|
assert scope.access_mode == "full"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scope_for_session_key_reads_metadata_without_full_history(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||||
|
default = tmp_path / "default"
|
||||||
|
project = tmp_path / "project"
|
||||||
|
default.mkdir()
|
||||||
|
project.mkdir()
|
||||||
|
sessions = SessionManager(tmp_path / "sessions")
|
||||||
|
controller = WebUIWorkspaceController(
|
||||||
|
session_manager=sessions,
|
||||||
|
default_workspace=default,
|
||||||
|
default_restrict_to_workspace=True,
|
||||||
|
)
|
||||||
|
explicit = default_workspace_scope(project, restrict_to_workspace=False)
|
||||||
|
controller.persist_scope("metadata-only", explicit)
|
||||||
|
|
||||||
|
def fail_full_read(_key: str) -> None:
|
||||||
|
raise AssertionError("scope lookup should not read full session history")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions, "read_session_file", fail_full_read)
|
||||||
|
|
||||||
|
scope = controller.scope_for_session_key("websocket:metadata-only")
|
||||||
|
|
||||||
|
assert scope.project_path == project.resolve()
|
||||||
|
assert scope.access_mode == "full"
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
|||||||
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
|
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
|
||||||
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
|
||||||
import { useSessionHistory } from "@/hooks/useSessions";
|
import { useSessionHistory } from "@/hooks/useSessions";
|
||||||
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
|
import {
|
||||||
|
fetchInstalledCliApps,
|
||||||
|
fetchMcpPresets,
|
||||||
|
fetchSettings,
|
||||||
|
listSlashCommands,
|
||||||
|
} from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
CLI_APPS_CHANGED_EVENT,
|
CLI_APPS_CHANGED_EVENT,
|
||||||
installedCliAppsFromPayload,
|
installedCliAppsFromPayload,
|
||||||
@@ -265,7 +270,7 @@ export function ThreadShell({
|
|||||||
const cliApps = useInstalledSettingItems({
|
const cliApps = useInstalledSettingItems({
|
||||||
token,
|
token,
|
||||||
eventName: CLI_APPS_CHANGED_EVENT,
|
eventName: CLI_APPS_CHANGED_EVENT,
|
||||||
fetchPayload: fetchCliApps,
|
fetchPayload: fetchInstalledCliApps,
|
||||||
isPayload: isCliAppsPayload,
|
isPayload: isCliAppsPayload,
|
||||||
selectItems: installedCliAppsFromPayload,
|
selectItems: installedCliAppsFromPayload,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -294,6 +294,18 @@ export async function fetchCliApps(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchInstalledCliApps(
|
||||||
|
token: string,
|
||||||
|
base: string = "",
|
||||||
|
): Promise<CliAppsPayload> {
|
||||||
|
return request<CliAppsPayload>(
|
||||||
|
`${base}/api/settings/cli-apps?installed_only=1`,
|
||||||
|
token,
|
||||||
|
undefined,
|
||||||
|
API_READ_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCliAppAction(
|
export async function runCliAppAction(
|
||||||
token: string,
|
token: string,
|
||||||
action: "install" | "update" | "uninstall" | "test",
|
action: "install" | "update" | "uninstall" | "test",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
fetchFilePreview,
|
fetchFilePreview,
|
||||||
fetchCliApps,
|
fetchCliApps,
|
||||||
|
fetchInstalledCliApps,
|
||||||
fetchMcpPresets,
|
fetchMcpPresets,
|
||||||
fetchProviderModels,
|
fetchProviderModels,
|
||||||
fetchSessionAutomations,
|
fetchSessionAutomations,
|
||||||
@@ -375,6 +376,25 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads installed CLI Apps without fetching the full catalog", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
apps: [],
|
||||||
|
installed_count: 0,
|
||||||
|
catalog_updated_at: null,
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(fetchInstalledCliApps("tok")).resolves.toMatchObject({ apps: [] });
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/cli-apps?installed_only=1",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("reads MCP presets and serializes actions", async () => {
|
it("reads MCP presets and serializes actions", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user