refactor: rename API and frontend projects to Vocero

Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts.

Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
2026-10-06 19:26:55 -03:00
parent e0304b2bdb
commit abd03ed07d
105 changed files with 353 additions and 312 deletions
@@ -0,0 +1,185 @@
from __future__ import annotations
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from vocero_api.api.responses import validated_json_response
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.models.requests import QueueEndPayload
from vocero_api.models.responses import AllocationResponse, QueueCancellationResponse
router = APIRouter()
@router.post("/api/session", response_model=AllocationResponse)
async def session(request: Request) -> JSONResponse:
"""Relay a session grant or queue ticket, keeping the LB address server-side.
The grant's `connect_url` contains the compute host and short-lived token the
browser needs to connect directly to the allocated speech service."""
client_ip = request.client.host if request.client else "?"
if not settings.LOAD_BALANCER_URL:
# No LB configured — this deploy is direct-mode only; the browser should
# never call this. 404 so it's indistinguishable from a missing route.
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug("[EXT][LB] session: client=%s", client_ip)
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/session"
app_logging.logger.debug("[EXT][LB] session POST %s (handshake)", url)
try:
async with httpx.AsyncClient(timeout=15.0) as http:
lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}")
except httpx.RequestError as exc:
app_logging.logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
app_logging.logger.debug(
"[EXT][LB] session RESP status=%s len=%dB",
lb.status_code,
len(lb.content or b""),
)
# The queue is full: the LB replies 503 {state:"at_capacity"}. Relay it as-is
# so the client shows a soft "try again shortly", not a hard error.
if lb.status_code == 503:
body = _safe_json(lb)
if body.get("state") == "at_capacity":
app_logging.logger.debug("[EXT][LB] session: at_capacity — queue full")
return validated_json_response({"state": "at_capacity"}, AllocationResponse, status_code=503)
if lb.status_code != 200:
# The LB's error body may name the reason (e.g. capacity); it carries no
# secret, so relay a trimmed copy.
app_logging.logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"No se pudo establecer la sesión ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
connect_url = (data.get("connect_url") or data.get("connectUrl") or "")[:120]
session_id = data.get("session_id") or data.get("sessionId")
app_logging.logger.debug(
"[EXT][LB] session PARSED state=%s sessionId=%s hasConnectUrl=%s connectUrl=%s",
state,
session_id,
bool(connect_url),
connect_url,
)
# Busy pool: the LB queued us. Relay the ticket for subsequent polling.
if data.get("state") == "queued":
app_logging.logger.debug(
"[EXT][LB] session: queued queueId=%s position=%s",
data.get("queue_id") or data.get("queueId"),
data.get("position"),
)
return validated_json_response(data, AllocationResponse)
@router.get("/api/queue/{queue_id}", response_model=AllocationResponse)
async def queue_status(queue_id: str, request: Request) -> JSONResponse:
"""Relay a waiting ticket's position or its newly claimed session grant."""
client_ip = request.client.host if request.client else "?"
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug(
"[EXT][LB] queue poll: client=%s queueId=%s",
client_ip,
queue_id[:16],
)
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
app_logging.logger.debug("[EXT][LB] queue GET %s", url)
try:
async with httpx.AsyncClient(timeout=15.0) as http:
lb = await http.get(url)
except httpx.RequestError as exc:
app_logging.logger.warning("Load balancer unreachable: %r", exc)
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
app_logging.logger.debug(
"[EXT][LB] queue RESP status=%s len=%dB",
lb.status_code,
len(lb.content or b""),
)
if lb.status_code == 404:
# Ticket unknown/expired (reaped after we stopped polling). Tell the client
# to start over rather than spin.
app_logging.logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
return validated_json_response({"state": "expired"}, AllocationResponse, status_code=404)
if lb.status_code != 200:
app_logging.logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
raise HTTPException(status_code=502, detail=f"No se pudo consultar la cola de espera ({lb.status_code}).")
data = lb.json()
state = data.get("state", "?")
position = data.get("position")
app_logging.logger.debug(
"[EXT][LB] queue PARSED queueId=%s state=%s position=%s sessionId=%s",
queue_id[:16],
state,
position,
data.get("session_id") or data.get("sessionId"),
)
return validated_json_response(data, AllocationResponse)
@router.delete("/api/queue/{queue_id}", response_model=QueueCancellationResponse)
async def queue_leave(queue_id: str) -> JSONResponse:
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
await _lb_leave(queue_id)
return validated_json_response({"ok": True}, QueueCancellationResponse)
@router.post("/api/queue/end", response_model=QueueCancellationResponse)
async def queue_end(request: Request) -> JSONResponse:
"""Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only
POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway."""
if not settings.LOAD_BALANCER_URL:
raise HTTPException(status_code=404, detail="No encontrado.")
qid = await _queue_id(request)
if qid:
app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
await _lb_leave(qid)
return validated_json_response({"ok": True}, QueueCancellationResponse)
async def _lb_leave(queue_id: str) -> None:
"""Best-effort: tell the LB to drop a waiting ticket."""
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
app_logging.logger.debug("[EXT][LB] queue DELETE %s", url)
try:
async with httpx.AsyncClient(timeout=5.0) as http:
resp = await http.delete(url)
app_logging.logger.debug(
"[EXT][LB] queue DELETE RESP queueId=%s status=%s",
queue_id[:16],
resp.status_code,
)
except httpx.RequestError as exc:
app_logging.logger.warning("Queue leave failed: %r", exc)
def _safe_json(response: httpx.Response) -> dict[str, Any]:
try:
body = response.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
async def _queue_id(request: Request) -> Any:
"""Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
try:
data = await request.json()
except Exception:
return ""
return QueueEndPayload.model_validate(data).queueId if isinstance(data, dict) else ""