тест 9

This commit is contained in:
2026-08-24 18:38:12 +03:00
parent aac9e5b8bc
commit 10a65e4d47
6 changed files with 233 additions and 22 deletions

View File

@@ -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(

View File

@@ -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.

View File

@@ -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"));