feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page

This commit is contained in:
hh
2026-08-29 02:29:05 +02:00
parent fd69eae05d
commit bc4233977d
27 changed files with 2123 additions and 120 deletions
+54 -4
View File
@@ -28,9 +28,11 @@ import signal
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING, Any
import psycopg
import uvicorn
import uvloop
from dotenv import load_dotenv
from pgqueuer import PsycopgDriver
from raycast_api import Client as RaycastClient
from raycast_api.config import Config as RaycastConfig
@@ -46,9 +48,13 @@ from beaver_gateway.backends.raycast import RaycastBackend
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.bus import EventBus
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.envelope import Envelope
from beaver_gateway.core.gateway_tools import build_tool_server
from beaver_gateway.core.registry import AgentRegistry, Gateway, McpRegistry
from beaver_gateway.core.rotation import Rotation, RotationPolicy
from beaver_gateway.core.scheduler import Scheduler
from beaver_gateway.core.sessions import SessionPool
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.root import build_root_app
from beaver_gateway.mcp.internal_app import build_internal_app
@@ -60,6 +66,8 @@ if TYPE_CHECKING:
from fastmcp import FastMCP
from fastmcp.tools.base import Tool as FastMCPTool
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.types import ASGIApp
from beaver_gateway.backends.base import Backend
from beaver_gateway.mcp.types import McpServerT
@@ -172,8 +180,19 @@ async def _async_main() -> None:
store=session_store,
texts=gateway.texts,
frontends=gateway.frontends,
envelope=Envelope(watch=gateway.watch, tz=gateway.tz),
)
late.conversations = conversations
scheduler = Scheduler(
conversations=conversations,
jobs=gateway.jobs,
driver=await _pgqueuer_driver(settings.database_url, stack),
budget=gateway.budget,
rotation=Rotation(
conversations, gateway.rotation or RotationPolicy(tz=gateway.tz)
),
)
conversations.scheduler = scheduler
runtime = GatewayRuntime(
agents=agents,
@@ -189,6 +208,7 @@ async def _async_main() -> None:
conversations=conversations,
bus=bus,
pool=pool,
scheduler=scheduler,
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
)
@@ -217,23 +237,53 @@ async def _async_main() -> None:
await conversations.start()
stack.push_async_callback(conversations.stop)
await scheduler.start()
stack.push_async_callback(scheduler.stop)
hooks = scheduler.app(
functools.partial(_authorize_hook, runtime=runtime, scope="api")
)
async with asyncio.TaskGroup() as tg:
tg.create_task(pool.reap_loop())
if internal_app is not None:
tg.create_task(_serve_internal_mcp(internal_app, settings=settings))
tg.create_task(_serve_root(gateway))
tg.create_task(_serve_root(gateway, extra={"/hooks": hooks}))
if gateway.watch is not None:
tg.create_task(gateway.watch.run())
for fe in gateway.frontends:
tg.create_task(fe.serve())
async def _serve_root(gateway: Gateway) -> None:
app = build_root_app(gateway.frontends)
async def _authorize_hook(
request: Request, *, runtime: GatewayRuntime, scope: str
) -> str:
return await require_token(request, runtime, scope=scope)
async def _pgqueuer_driver(url: str, stack: AsyncExitStack) -> PsycopgDriver | None:
"""A dedicated autocommit connection for pgqueuer's LISTEN/NOTIFY."""
plain = _plain_postgres_url(url)
if plain is None:
return None
conn = await psycopg.AsyncConnection.connect(plain, autocommit=True)
stack.push_async_callback(conn.close)
return PsycopgDriver(conn)
def _plain_postgres_url(url: str) -> str | None:
for prefix in ("postgresql+psycopg://", "postgresql://", "postgres://"):
if url.startswith(prefix):
return "postgresql://" + url[len(prefix) :]
return None
async def _serve_root(gateway: Gateway, *, extra: dict[str, ASGIApp]) -> None:
app = build_root_app(gateway.frontends, extra=extra)
config = uvicorn.Config(app, host=gateway.host, port=gateway.port, log_level="info")
_log.info(
"gateway on http://%s:%d - %s",
gateway.host,
gateway.port,
", ".join(fe.path for fe in gateway.frontends if fe.path)
", ".join([*(fe.path for fe in gateway.frontends if fe.path), *extra])
or "no http frontends",
)
await uvicorn.Server(config).serve()