"""Frontend ABC and the runtime context handed to ``configure``. A frontend routes inbound traffic into the gateway (an HTTP mount, a poller, or both); ``GatewayRuntime`` carries the built state each needs. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from beaver_gateway.security.auth import BUILTIN_SCOPES if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Mapping, Sequence from starlette.types import ASGIApp from beaver_gateway.app import AgentRegistry, McpRegistry from beaver_gateway.backends.base import Backend from beaver_gateway.conversations.kinds import Kind from beaver_gateway.frontends.turn_record import TurnRecord from beaver_gateway.security.auth import TokenStore from beaver_gateway.storage import Database from beaver_gateway.storage.models import Conversation, ConversationBinding TurnLogHandler = Callable[[TurnRecord], Awaitable[None]] @dataclass(frozen=True, slots=True) class GatewayRuntime: """Post-build state of the gateway, shared with every frontend. Backends are keyed by agent name, not type: one backend instance may serve several agents, so the indirection lives at lookup time. """ agents: AgentRegistry mcps: McpRegistry backends: dict[str, Backend] token_store: TokenStore db: Database mcp_internal_urls: Mapping[str, str] = field(default_factory=dict) admin_user: str = "" """Operator login for the admin console, checked by ``AdminFrontend``.""" admin_pass: str = "" session_secret: str = "" frontends: Sequence[Frontend] = field(default_factory=tuple) """Every frontend in declaration order, for advertising their URLs.""" turn_log_handlers: list[TurnLogHandler] = field(default_factory=list) """Called with a ``TurnRecord`` after each turn; failures never reach the user.""" conversations: Any = None bus: Any = None pool: Any = None scheduler: Any = None public_url: str | None = None """``Gateway.public_url``; ``None`` derives the origin from the request.""" scopes: frozenset[str] = BUILTIN_SCOPES """Every token scope this gateway knows: the builtins plus each frontend's.""" class Frontend(ABC): """Routes inbound traffic into the gateway. HTTP frontends set ``path`` and return their ASGI app from ``app()``; these are mounted under that path on the one gateway port. ``serve()`` is for non-HTTP work (polling, vault mirrors) and defaults to nothing. ``landing`` marks the app that ``/`` redirects to. ``scope`` is the token scope this frontend's routes are gated by; a frontend that names its own scope gets keys nobody else's token opens, and ``POST /api/tokens`` will mint them (see ``security/auth.py``). A frontend that shows conversations declares ``name`` (the binding key) and ``kinds`` (which conversation kinds it shows). The first frontend whose ``materialize`` returns a binding is the *home* of that kind, used by ``spawn`` for new conversations; ``agent_for`` names the default agent for a kind. Stateless frontends (MCP, admin) leave these at their defaults. """ name: str = "" kinds: tuple[Kind, ...] = () path: str | None = None landing: bool = False scope: str | None = None @abstractmethod def configure(self, runtime: GatewayRuntime) -> None: ... def app(self) -> ASGIApp | None: return None async def serve(self) -> None: return def agent_for(self, kind: Kind) -> str | None: # noqa: ARG002 return None async def materialize(self, conv: Conversation) -> ConversationBinding | None: # noqa: ARG002 return None async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002 """Show in the window that the conversation is over (a renamed topic).""" return False