fix(settings): serialize gateway configuration updates
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user