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 0479d7cdda
commit 662bdcdfda
27 changed files with 2123 additions and 120 deletions
+43 -13
View File
@@ -60,6 +60,7 @@ if TYPE_CHECKING:
from pathlib import Path
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.scheduler import Scheduler
from beaver_gateway.frontends.base import GatewayRuntime
_log = logging.getLogger("beaver_gateway.frontends.api")
@@ -525,19 +526,48 @@ def build_app(runtime: GatewayRuntime, *, memory_root: Path | None = None) -> Fa
await require_token(request, runtime, scope=SCOPE)
raw = request.query_params.get("conversation")
conv = await conv_of(raw) if raw else None
return {
"schedules": [
{
"id": s.id,
"conversation_row": s.conversation_id,
"execute_at": _iso(s.execute_at),
"text": s.text,
"created_at": _iso(s.created_at),
"delivered_at": _iso(s.delivered_at),
}
for s in await conversations.schedules(conv)
]
}
return {"schedules": await conversations.schedules(conv)}
def scheduler_of() -> Scheduler:
if runtime.scheduler is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "no scheduler")
return cast("Scheduler", runtime.scheduler)
@app.get("/jobs")
async def jobs(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
return await scheduler_of().snapshot()
@app.post("/jobs/{name}/run", status_code=status.HTTP_202_ACCEPTED)
async def run_job(name: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
scheduler = scheduler_of()
job = scheduler.job(name)
if job is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no job {name!r}")
return {"job": await scheduler.trigger(job, await body_of(request))}
@app.delete("/jobs/queue/{job_id}")
async def cancel_job(job_id: int, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
if not await scheduler_of().cancel(job_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no queued job {job_id}")
return {"cancelled": job_id}
@app.post(
"/conversations/{public_id}/schedule", status_code=status.HTTP_201_CREATED
)
async def post_schedule(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
data = await body_of(request)
try:
job_id, when = await conversations.schedule(
conv, text_of(data, "at"), text_of(data, "text")
)
except (RuntimeError, ValueError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return {"job": job_id, "execute_at": _iso(when)}
@app.get("/usage")
async def usage(request: Request) -> dict[str, Any]:
+5
View File
@@ -89,6 +89,7 @@ class GatewayRuntime:
conversations: Any = None
bus: Any = None
pool: Any = None
scheduler: Any = None
# External origin the reverse proxy puts in front of the gateway
# (``Gateway.public_url``); ``None`` means "derive from the request".
public_url: str | None = None
@@ -134,3 +135,7 @@ class Frontend(ABC):
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
+8 -3
View File
@@ -15,19 +15,22 @@ from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Mount, Route
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Iterable, Mapping
from starlette.requests import Request
from starlette.types import ASGIApp
from beaver_gateway.frontends.base import Frontend
__all__ = ["build_root_app"]
def build_root_app(frontends: Iterable[Frontend]) -> Starlette:
def build_root_app(
frontends: Iterable[Frontend], *, extra: Mapping[str, ASGIApp] | None = None
) -> Starlette:
mounted = [fe for fe in frontends if fe.path and fe.app() is not None]
landing = next((fe for fe in mounted if fe.landing), None)
paths = [fe.path for fe in mounted]
paths = [*(fe.path for fe in mounted), *(extra or {})]
async def healthz(_request: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "frontends": paths})
@@ -46,4 +49,6 @@ def build_root_app(frontends: Iterable[Frontend]) -> Starlette:
assert app is not None # noqa: S101 - filtered above; narrows for ty
assert fe.path is not None # noqa: S101
routes.append(Mount(fe.path, app=app, name=fe.name or fe.path.strip("/")))
for path, app in (extra or {}).items():
routes.append(Mount(path, app=app, name=path.strip("/")))
return Starlette(routes=routes)
@@ -203,12 +203,11 @@ class TelegramFrontend(Frontend):
conv, frontend=FRONTEND, external_id=self._ext(topic.message_thread_id)
)
async def mark_topic(self, conv: Conversation, prefix: str = "") -> bool:
"""Rotation hook for M3.
async def mark_closed(self, conv: Conversation) -> bool:
return await self.mark_topic(conv)
``closeForumTopic`` does not exist in private chats; the state of a
merged or closed branch lives in its name.
"""
async def mark_topic(self, conv: Conversation, prefix: str = "") -> bool:
"""Rename the topic; closeForumTopic does not exist in private chats."""
target = await self._target_of(conv)
if target is None or target[1] is None:
return False