Files

124 lines
3.8 KiB
Python

"""Cross-user roundtable room admission.
Routes (prefix ``/api/roundtable-rooms``)::
GET /summary occupied room count across every account
POST /acquire atomically reserve a room, or 409 when full
POST /heartbeat renew the same durable lease
POST /release release the foreground lease
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.roundtable_room_gate import (
admit_roundtable_room,
backend_occupancy_keys,
release_roundtable_room,
renew_roundtable_room,
)
from deerflow.config.system_settings import get_roundtable_room_limit_settings
router = APIRouter(prefix="/api/roundtable-rooms", tags=["roundtable-rooms"])
class RoomScopeRequest(BaseModel):
draftId: str | None = Field(default=None, max_length=64)
taskId: str | None = Field(default=None, max_length=128)
roomSessionId: str | None = Field(default=None, max_length=128)
class RoomAdmissionResponse(BaseModel):
ok: bool = True
enabled: bool = True
max_rooms: int = 3
active_count: int = 0
renewed: bool = False
code: str = ""
message: str = ""
class RoomSummaryResponse(BaseModel):
enabled: bool = True
max_rooms: int = 3
active_count: int = 0
def _response(decision: dict) -> RoomAdmissionResponse:
return RoomAdmissionResponse(
ok=bool(decision.get("ok")),
enabled=bool(decision.get("enabled")),
max_rooms=int(decision.get("max_rooms") or 0),
active_count=int(decision.get("active_count") or 0),
renewed=bool(decision.get("renewed")),
code=str(decision.get("code") or ""),
message=str(decision.get("message") or ""),
)
def _require_store(request: Request):
store = getattr(request.app.state, "roundtable_room_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable room store not available")
return store
@router.get("/summary", response_model=RoomSummaryResponse)
async def room_summary(request: Request) -> RoomSummaryResponse:
settings = get_roundtable_room_limit_settings()
store = getattr(request.app.state, "roundtable_room_store", None)
extra = await backend_occupancy_keys(request)
active = (
0
if store is None
else await store.summary(
extra_keys=extra,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
)
)
return RoomSummaryResponse(
enabled=settings.enabled,
max_rooms=settings.max_rooms,
active_count=active,
)
@router.post("/acquire", response_model=RoomAdmissionResponse)
async def acquire_room(request: Request, body: RoomScopeRequest) -> RoomAdmissionResponse:
decision = await admit_roundtable_room(
request,
draft_id=body.draftId,
task_id=body.taskId,
room_session_id=body.roomSessionId,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return _response(decision)
@router.post("/heartbeat", response_model=RoomAdmissionResponse)
async def heartbeat_room(request: Request, body: RoomScopeRequest) -> RoomAdmissionResponse:
decision = await renew_roundtable_room(
request,
draft_id=body.draftId,
task_id=body.taskId,
room_session_id=body.roomSessionId,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return _response(decision)
@router.post("/release")
async def release_room(request: Request, body: RoomScopeRequest) -> dict[str, bool]:
_require_store(request)
released = await release_roundtable_room(
request,
draft_id=body.draftId,
task_id=body.taskId,
room_session_id=body.roomSessionId,
)
return {"released": released}