fix(settings): serialize gateway configuration updates

This commit is contained in:
Xubin Ren
2026-08-16 11:50:56 +08:00
parent 731b8fc2ed
commit dec89a49a3
13 changed files with 347 additions and 75 deletions
+42 -28
View File
@@ -380,6 +380,10 @@ class WebSocketChannel(BaseChannel):
tuple[ServerConnection, str],
asyncio.Task[None],
] = {}
# Preserve request/response order for non-replayable mutations from one
# UI. Without this, an earlier slow settings response can overwrite a
# newer settings snapshot in the client.
self._webui_request_locks: dict[ServerConnection, asyncio.Lock] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
@@ -476,6 +480,7 @@ class WebSocketChannel(BaseChannel):
await self._discard_connection_owned_chat(connection, cid)
self._conn_default.pop(connection, None)
self._webui_connections.discard(connection)
self._webui_request_locks.pop(connection, None)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
@@ -900,7 +905,10 @@ class WebSocketChannel(BaseChannel):
)
return
if t == "transcribe_audio":
event, payload = await webui_transcription_event(envelope)
event, payload = await webui_transcription_event(
envelope,
config_path=self.gateway.settings.config.path,
)
await self._send_event(connection, event, **payload)
return
if t == "message":
@@ -1039,7 +1047,10 @@ class WebSocketChannel(BaseChannel):
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
mcp_presets = normalize_mcp_preset_mentions(
envelope.get("mcp_presets"),
config_path=self.gateway.settings.config.path,
)
if mcp_presets:
metadata["mcp_presets"] = mcp_presets
session_mentions: list[SessionMention] = []
@@ -1198,41 +1209,43 @@ class WebSocketChannel(BaseChannel):
payload: dict[str, Any],
) -> None:
try:
response = await self._http_router.dispatch_webui_mutation(
connection,
action,
payload,
)
status = response.status_code
body = bytes(response.body).decode("utf-8", errors="replace").strip()
if 200 <= status < 300:
try:
result = json.loads(body)
except json.JSONDecodeError:
lock = self._webui_request_locks.setdefault(connection, asyncio.Lock())
async with lock:
response = await self._http_router.dispatch_webui_mutation(
connection,
action,
payload,
)
status = response.status_code
body = bytes(response.body).decode("utf-8", errors="replace").strip()
if 200 <= status < 300:
try:
result = json.loads(body)
except json.JSONDecodeError:
await self._send_webui_response(
connection,
request_id,
status=502,
message="WebUI mutation returned an invalid response",
)
return
if action == "sidebar.update" and isinstance(result, dict):
await self._broadcast_webui_event(
"sidebar_state_updated",
state=result,
)
await self._send_webui_response(
connection,
request_id,
status=502,
message="WebUI mutation returned an invalid response",
result=result,
)
return
if action == "sidebar.update" and isinstance(result, dict):
await self._broadcast_webui_event(
"sidebar_state_updated",
state=result,
)
await self._send_webui_response(
connection,
request_id,
result=result,
status=status,
message=body or response.reason_phrase,
)
return
await self._send_webui_response(
connection,
request_id,
status=status,
message=body or response.reason_phrase,
)
except asyncio.CancelledError:
raise
except Exception:
@@ -1321,6 +1334,7 @@ class WebSocketChannel(BaseChannel):
if mutation_tasks:
await asyncio.gather(*mutation_tasks, return_exceptions=True)
self._webui_request_tasks.clear()
self._webui_request_locks.clear()
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
@@ -943,6 +943,65 @@ async def test_authenticated_webui_request_returns_correlated_success(bus: Magic
}
@pytest.mark.asyncio
async def test_webui_mutations_preserve_request_and_response_order(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
first_started = asyncio.Event()
release_first = asyncio.Event()
dispatch_order: list[str] = []
async def dispatch(
_connection: object,
action: str,
_payload: dict[str, object],
) -> Any:
dispatch_order.append(action)
if action == "settings.provider.update":
first_started.set()
await release_first.wait()
return _http_json_response({"action": action})
channel.gateway.http.dispatch_webui_mutation = dispatch
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-first",
"action": "settings.provider.update",
"payload": {},
},
)
await first_started.wait()
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-second",
"action": "settings.agent.update",
"payload": {},
},
)
await asyncio.sleep(0)
assert dispatch_order == ["settings.provider.update"]
release_first.set()
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
assert dispatch_order == [
"settings.provider.update",
"settings.agent.update",
]
responses = [json.loads(call.args[0]) for call in conn.send.await_args_list]
assert [response["request_id"] for response in responses] == [
"request-first",
"request-second",
]
@pytest.mark.asyncio
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
channel = _ch(bus)
@@ -16,6 +16,7 @@ import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
from nanobot.config.loader import load_config, save_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.optional_features import InstallResult
@@ -636,6 +637,7 @@ async def test_webui_skill_management_routes(
*,
enabled: bool,
disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]:
assert workspace == tmp_path
assert name == "custom-skill"
@@ -648,6 +650,7 @@ async def test_webui_skill_management_routes(
name: str,
*,
disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]:
assert workspace == tmp_path
assert name == "custom-skill"
@@ -926,10 +929,6 @@ async def test_webui_skill_install_honors_remote_install_opt_in(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
policy = MagicMock()
policy.tools.webui_allow_remote_package_install = True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
async def install(
source: str,
skill_id: str,
@@ -956,6 +955,9 @@ async def test_webui_skill_install_honors_remote_install_opt_in(
workspace_path=tmp_path,
port=_free_port(),
)
policy = load_config(channel.gateway.settings.config.path)
policy.tools.webui_allow_remote_package_install = True
save_config(policy, channel.gateway.settings.config.path)
response = await _webui_mutate(
channel,
"skill.install",
@@ -3699,7 +3701,7 @@ def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> N
def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config",
lambda: "from-disk",
lambda _config_path=None: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ")
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
@@ -3711,7 +3713,7 @@ def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytes
def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config",
lambda: "from-disk",
lambda _config_path=None: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ")
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
@@ -3723,7 +3725,7 @@ def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeyp
def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config",
lambda: "from-disk",
lambda _config_path=None: "from-disk",
)
def boom():