From 10a65e4d47f0868fdf5f89019ba904df20e4bcea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 24 Aug 2026 18:38:12 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 3 +- hockey_data/agent_bridge.py | 29 ++++- hockey_data/router.py | 19 ++-- hockey_data/static/tournament-menu.js | 103 +++++++++++++++++- tests/test_build109_match_open_nonblocking.py | 8 +- tests/test_build111_agent_match_handoff.py | 93 ++++++++++++++++ 6 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 tests/test_build111_agent_match_handoff.py diff --git a/app.py b/app.py index d54264c..05cb89f 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,8 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.24.13" +BUILD_VERSION = "2026.08.24.14" +# compatibility: BUILD_VERSION = "2026.08.24.13" # compatibility: BUILD_VERSION = "2026.08.24.8" # compatibility: BUILD_VERSION = "2026.08.24.7" # compatibility: BUILD_VERSION = "2026.08.24.6" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 4dbb808..750084a 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -2001,6 +2001,7 @@ class VmixAgentHub: return None now = _utcnow() with self.database.session() as session: + device = None if requested_device_id: requested_device_id = self.normalise_device_id(requested_device_id) device = session.scalar( @@ -2012,9 +2013,12 @@ class VmixAgentHub: ) ) ) - else: - # Legacy/single-Agent fallback only. If several devices are enabled, - # routing must be explicit so a browser cannot control the wrong vMix. + + if device is None: + # BUILD111: a browser may keep a stale localStorage device id after + # reconnect/update. Do not silently leave the new match unassigned. + # Automatic fallback is allowed only when routing is unambiguous: + # exactly one live active Agent, or exactly one active Agent total. candidates = list(session.scalars( select(VmixDevice) .where( @@ -2025,7 +2029,12 @@ class VmixAgentHub: ) .order_by(desc(VmixDevice.last_seen_at)) )) - device = candidates[0] if len(candidates) == 1 else None + live_candidates = [item for item in candidates if item.device_uuid in self._live] + if len(live_candidates) == 1: + device = live_candidates[0] + elif len(candidates) == 1: + device = candidates[0] + if device is None: return None @@ -2114,6 +2123,7 @@ class VmixAgentHub: user: HockeyUser, *, session_token: str = "", + wait_for_mapping: bool = True, ) -> dict[str, Any]: """Bind this browser/operator session to one enabled Agent.""" device_id = self.normalise_device_id(device_id) @@ -2152,6 +2162,7 @@ class VmixAgentHub: tournament_external_id=tournament_id, device_id=device_id, operator_session_token=session_token, + wait_for_mapping=bool(wait_for_mapping), ) return { "ok": True, @@ -3978,6 +3989,9 @@ class RuntimeVmixSequencePayload(BaseModel): class SelectSessionDevicePayload(BaseModel): session_token: str = Field(default="", max_length=128) + # Runtime match switching can assign immediately and apply Mapping only + # after the new roster has been loaded. Existing Agent UI keeps True. + apply_mapping: bool = True class MappingTestValuePayload(BaseModel): @@ -4243,7 +4257,12 @@ def create_hockey_agent_router( payload: SelectSessionDevicePayload, user: HockeyUser = Depends(auth_dependency), ) -> dict[str, Any]: - return await hub.select_device_for_session(device_id, user, session_token=payload.session_token) + return await hub.select_device_for_session( + device_id, + user, + session_token=payload.session_token, + wait_for_mapping=bool(payload.apply_mapping), + ) @router.delete("/api/hockey/agents/devices/{device_id}/pair") async def unpair_device( diff --git a/hockey_data/router.py b/hockey_data/router.py index 84182a5..c2bd1f4 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -5,7 +5,7 @@ from datetime import date from time import perf_counter from typing import Any, Callable -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import text @@ -1151,7 +1151,6 @@ def create_hockey_router( @router.post("/sessions") async def open_session( payload: SessionPayload, - background_tasks: BackgroundTasks, user: HockeyUser = Depends(auth_dependency), ) -> dict[str, Any]: try: @@ -1172,18 +1171,16 @@ def create_hockey_router( tournament_external_id=str(result.get("tournament_external_id") or ""), device_id=vmix_device_id, operator_session_token=str(result.get("token") or ""), - # BUILD109: selecting a match must not wait for a full - # Mapping push to vMix. The Agent receives match.assign - # immediately; Mapping starts only after the HTTP response. + # BUILD111: session opening sends only match.assign. Mapping + # is applied after the selected match roster/details are in + # the database, otherwise vMix can receive stale match data. wait_for_mapping=False, ) assignment = result.get("agent_assignment") or {} - if assignment.get("delivered") and assignment.get("device_id"): - background_tasks.add_task( - agent_hub.apply_mapping_to_device, - str(assignment.get("device_id")), - reason="match_assigned", - ) + result["agent_status"] = ( + "delivered" if assignment.get("delivered") + else ("offline" if assignment.get("device_id") else "unassigned") + ) except Exception as agent_error: # The match/session must remain usable even when a browser # carries a stale Agent id from another WFL account. diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index 274353c..f76d2b1 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -308,6 +308,82 @@ return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem(storage.vmixDevice) || "").trim(); } + function rememberAssignedAgent(deviceId) { + const clean = String(deviceId || "").trim(); + if (!clean) return; + localStorage.setItem(storage.vmixDevice, clean); + try { window.HockeyAgentRuntime?.selectDevice?.(clean); } catch (_) {} + } + + async function ensureSessionAgentAssignment(session) { + const token = String(session?.token || localStorage.getItem(storage.session) || "").trim(); + let assignment = session?.agent_assignment && typeof session.agent_assignment === "object" + ? session.agent_assignment + : null; + + if (assignment?.device_id) rememberAssignedAgent(assignment.device_id); + if (assignment?.delivered) return assignment; + if (!token) return assignment; + + // BUILD111: opening the web match is not proof that Agent received + // match.assign. Recover from stale/empty localStorage only when routing is + // unambiguous: the selected live Agent or exactly one live active Agent. + let devicesPayload = null; + try { + devicesPayload = await request("/api/hockey/agents/devices", { timeoutMs: 3500 }); + } catch (_) { + return assignment; + } + const devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : []; + const live = devices.filter((item) => ( + item?.paired_to_me + && item?.active_for_account + && item?.online + )); + const preferredIds = [ + String(assignment?.device_id || "").trim(), + currentAgentDeviceId(), + ].filter(Boolean); + let target = null; + for (const preferred of preferredIds) { + target = live.find((item) => String(item.device_id || "") === preferred) || null; + if (target) break; + } + if (!target && live.length === 1) target = live[0]; + if (!target?.device_id) return assignment; + + try { + const rebound = await request( + `/api/hockey/agents/devices/${encodeURIComponent(target.device_id)}/select-session`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_token: token, apply_mapping: false }), + timeoutMs: 5000, + } + ); + assignment = rebound?.assignment || assignment; + if (assignment?.device_id) rememberAssignedAgent(assignment.device_id); + if (session && assignment) session.agent_assignment = assignment; + return assignment; + } catch (error) { + console.warn("[Hockey] Agent match.assign retry failed:", error); + return assignment; + } + } + + async function refreshSessionAgentMapping(session) { + const assignment = await ensureSessionAgentAssignment(session); + const deviceId = String(assignment?.device_id || "").trim(); + if (!deviceId || !assignment?.delivered) { + return { ok: false, reason: "agent_match_not_delivered" }; + } + return request( + `/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping`, + { method: "POST", timeoutMs: 60000 } + ); + } + async function ensureAccountScopedRuntimeState() { let user = null; try { @@ -1660,6 +1736,12 @@ function gameCard(game) { } } + // Confirm that the concrete Agent actually received this new match. + // A successful /sessions response alone is not enough: assignment may be + // null/offline when the browser stored a stale device id. + const initialAgentAssignment = await ensureSessionAgentAssignment(session); + if (initialAgentAssignment && session) session.agent_assignment = initialAgentAssignment; + state.lastGameOpenError = ""; state.selectedGameId = String(id); localStorage.setItem(storage.game, state.selectedGameId); @@ -1700,17 +1782,34 @@ function gameCard(game) { if ( generation === state.gameOpenGeneration && String(state.selectedGameId || "") === String(id) - ) startSelectedGamePolling(); + ) { + startSelectedGamePolling(); + void refreshSessionAgentMapping(session).then((mapping) => { + if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") { + notify("Матч открыт, но Agent не получил новый матч", true); + } + }).catch((error) => console.warn("[Hockey] Mapping refresh after roster failed:", error)); + } } else if (!manual) { + // This exact match already has its own cached roster, so vMix can be + // refreshed immediately without waiting for the optional network sync. + void refreshSessionAgentMapping(session).then((mapping) => { + if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") { + notify("Матч открыт, но Agent не получил новый матч", true); + } + }).catch((error) => console.warn("[Hockey] Mapping refresh from cached roster failed:", error)); void enrichOpenedGame(String(id), state.selectedId, generation, { opening: true }) .finally(() => { if ( generation === state.gameOpenGeneration && String(state.selectedGameId || "") === String(id) - ) startSelectedGamePolling(); + ) { + startSelectedGamePolling(); + } }); } else { startSelectedGamePolling(); + void refreshSessionAgentMapping(session).catch((error) => console.warn("[Hockey] Mapping refresh for manual match failed:", error)); } updateMatchLoading(t("loadingMatchFinishing")); diff --git a/tests/test_build109_match_open_nonblocking.py b/tests/test_build109_match_open_nonblocking.py index 2e8b24a..8b3b66b 100644 --- a/tests/test_build109_match_open_nonblocking.py +++ b/tests/test_build109_match_open_nonblocking.py @@ -39,10 +39,12 @@ def test_browser_timeout_no_longer_blames_stat2tv_for_every_endpoint() -> None: def test_session_assignment_defers_full_mapping() -> None: - assert "background_tasks: BackgroundTasks" in ROUTER assert "wait_for_mapping=False" in ROUTER - assert "background_tasks.add_task(" in ROUTER - assert "agent_hub.apply_mapping_to_device" in ROUTER + # BUILD111 keeps BUILD109's non-blocking match open, but Mapping is no + # longer queued here because it must wait for the new roster/details. + open_session = ROUTER[ROUTER.index('@router.post("/sessions")'):ROUTER.index('@router.get("/sessions/{token}")')] + assert "background_tasks.add_task(" not in open_session + assert 'result["agent_status"]' in open_session def test_assign_match_can_return_without_running_mapping(tmp_path: Path) -> None: diff --git a/tests/test_build111_agent_match_handoff.py b/tests/test_build111_agent_match_handoff.py new file mode 100644 index 0000000..0165bf7 --- /dev/null +++ b/tests/test_build111_agent_match_handoff.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8") +AGENT = (ROOT / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8") +ROUTER = (ROOT / "hockey_data" / "router.py").read_text(encoding="utf-8") +APP = (ROOT / "app.py").read_text(encoding="utf-8") + + +class FakeWebSocket: + def __init__(self) -> None: + self.client = SimpleNamespace(host="127.0.0.1") + self.sent: list[dict] = [] + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + async def close(self, **_kwargs) -> None: + return None + + +def test_stale_browser_device_falls_back_to_only_live_active_agent(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build111.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + ws = FakeWebSocket() + user = HockeyUser(id="111", login="operator111", display_name="Operator 111") + + async def scenario() -> None: + await hub.register( + ws, # type: ignore[arg-type] + { + "device_id": "GFX-BUILD111", + "device_secret": "x" * 40, + "device_name": "GFX BUILD111", + "hostname": "GFX-BUILD111", + "agent_version": "1.4.0", + "vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"}, + }, + ) + await hub.pair_device("GFX-BUILD111", user) + result = await hub.assign_match( + wfl_user_id=user.id, + tournament_external_id="1437", + game_external_id="902918", + device_id="STALE-BROWSER-DEVICE", + wait_for_mapping=False, + ) + assert result is not None + assert result["device_id"] == "GFX-BUILD111" + assert result["delivered"] is True + assert result["mapping_apply"]["deferred"] is True + match_assigns = [item for item in ws.sent if item.get("type") == "match.assign"] + assert len(match_assigns) == 1 + assert match_assigns[0]["game_id"] == "902918" + + asyncio.run(scenario()) + + +def test_browser_retries_match_assign_without_mapping_when_needed() -> None: + assert "async function ensureSessionAgentAssignment(session)" in JS + assert 'body: JSON.stringify({ session_token: token, apply_mapping: false })' in JS + assert 'item?.paired_to_me' in JS + assert 'item?.active_for_account' in JS + assert 'item?.online' in JS + assert 'live.length === 1' in JS + + +def test_mapping_is_refreshed_only_after_roster_enrichment() -> None: + assert "async function refreshSessionAgentMapping(session)" in JS + branch = JS.index("if (!manual && !cachedRostersReady)") + enrich = JS.index("await enrichOpenedGame", branch) + mapping = JS.index("refreshSessionAgentMapping(session)", enrich) + assert enrich < mapping + open_session = ROUTER[ROUTER.index('@router.post("/sessions")'):ROUTER.index('@router.get("/sessions/{token}")')] + assert "agent_hub.apply_mapping_to_device" not in open_session + + +def test_select_session_supports_assignment_without_early_mapping() -> None: + assert "apply_mapping: bool = True" in AGENT + assert "wait_for_mapping=bool(payload.apply_mapping)" in AGENT + + +def test_runtime_version_build111() -> None: + assert 'BUILD_VERSION = "2026.08.24.14"' in APP