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()
+15
View File
@@ -16,11 +16,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from croniter import croniter
from beaver_gateway.agents.base import BaseAgent, ExposedMcp
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.core.registry import Gateway
from beaver_gateway.core.scheduler import Job
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import HttpMcp, McpServer, PythonToolMcp, StdioMcp
@@ -99,3 +102,15 @@ def _validate(gw: Gateway, path: Path) -> None:
f"got {type(f).__name__}"
)
raise ConfigError(msg)
names: set[str] = set()
for i, j in enumerate(gw.jobs):
if not isinstance(j, Job):
msg = f"{path}: gateway.jobs[{i}] must be a Job, got {type(j).__name__}"
raise ConfigError(msg)
if j.name in names:
msg = f"{path}: duplicate job name {j.name!r}"
raise ConfigError(msg)
names.add(j.name)
if j.cron is not None and not croniter.is_valid(j.cron):
msg = f"{path}: job {j.name!r} has an invalid cron {j.cron!r}"
raise ConfigError(msg)
+131 -21
View File
@@ -57,7 +57,7 @@ from beaver_gateway.storage.models import (
ConversationMessage,
InjectQueueItem,
RateLimit,
Schedule,
Usage,
)
if TYPE_CHECKING:
@@ -68,9 +68,12 @@ if TYPE_CHECKING:
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.core.bus import EventBus
from beaver_gateway.core.envelope import Envelope
from beaver_gateway.core.events import MessageStreamEvent
from beaver_gateway.core.injects import Priority
from beaver_gateway.core.registry import AgentRegistry
from beaver_gateway.core.rotation import HandoutContext
from beaver_gateway.core.scheduler import Scheduler
from beaver_gateway.core.sessions import SessionPool
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.storage.db import Database
@@ -124,6 +127,11 @@ class ConversationTexts:
"заверши тёрн сейчас, ответ придёт следующим сообщением."
)
seed: Callable[[SeedContext], Awaitable[str | None] | str | None] | None = None
handout: Callable[[HandoutContext], Awaitable[str] | str] | str = (
"Этот мастер закрывается ({reason}). Напиши хендаут за {day}: справку "
"на утро, не задание - прошедшее время, без повелительного наклонения."
)
new_day: str = "Новый день: мастер сменился, хендаут за {day} записан."
@dataclass(frozen=True, slots=True)
@@ -182,6 +190,7 @@ class Conversations:
idle_days: Sequence[int] = (2,),
idle_interval: float = 3600.0,
question_timeout: float = 600.0,
envelope: Envelope | None = None,
) -> None:
self._db = db
self._agents = agents
@@ -195,6 +204,8 @@ class Conversations:
self._idle_days = tuple(sorted(idle_days))
self._idle_interval = idle_interval
self._question_timeout = question_timeout
self._envelope = envelope
self.scheduler: Scheduler | None = None
self._questions: dict[str, _Question] = {}
self._queue = InjectQueue(db)
self._runners: dict[int, _Runner] = {}
@@ -481,6 +492,45 @@ class Conversations:
)
return list(result.all())
async def context_tokens(self, conv: Conversation) -> int:
"""Size of the context the last turn ran with, from its usage row."""
async with self._db.session() as session:
row = (
await session.exec(
select(Usage)
.where(Usage.conversation_id == conv.external_id)
.order_by(col(Usage.id).desc())
.limit(1)
)
).first()
if row is None:
return 0
return row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
async def usage_tokens(self, since: datetime) -> int:
async with self._db.session() as session:
rows = (
await session.exec(
select(Usage).where(
col(Usage.ts) >= since.astimezone(UTC).replace(tzinfo=None)
)
)
).all()
return sum(
r.input_tokens + r.output_tokens + r.cache_creation_tokens for r in rows
)
async def busy(self, conv: Conversation) -> bool:
"""A turn is running, a question is open or a message waits to run."""
row = await self.get_row(cast("int", conv.id)) or conv
if row.running_turn or row.pending_question:
return True
live = self._pool.get(row.external_id)
if live is not None and live.busy:
return True
pending = await self._queue.pending(cast("int", row.id))
return any(i.priority in ("user", "urgent") for i in pending)
# ---- routing -------------------------------------------------------
@property
@@ -541,6 +591,8 @@ class Conversations:
raise ValueError(msg)
if agent is None and kind == "branch" and parent is not None:
agent = parent.agent_name
if kind == "branch" and parent is not None and parent.kind == "master":
await self.set_flags(parent, {"streak": 0})
agent = agent or self.default_agent(kind)
if agent is None:
msg = f"no default agent for kind {kind!r}; pass `agent`"
@@ -712,6 +764,7 @@ class Conversations:
*,
urgency: Priority = "normal",
origin: str = "system",
interrupt: bool = True,
) -> InjectQueueItem:
item = await self._queue.push(
conversation_id=cast("int", conv.id),
@@ -726,7 +779,7 @@ class Conversations:
priority=urgency,
origin=origin,
)
if urgency == "urgent":
if urgency == "urgent" and interrupt:
backend = self._backend(conv.agent_name)
if await backend.interrupt(conv.external_id):
_log.info(
@@ -788,22 +841,74 @@ class Conversations:
)
return result
async def schedule(self, conv: Conversation, at: str, text: str) -> Schedule:
row = Schedule(
conversation_id=cast("int", conv.id), execute_at=parse_at(at), text=text
)
async with self._db.session() as session:
session.add(row)
await session.commit()
await session.refresh(row)
async def schedule(
self, conv: Conversation, at: str, text: str, *, dedupe_key: str | None = None
) -> tuple[int | None, datetime]:
if self.scheduler is None:
msg = "no scheduler; `schedule` is unavailable"
raise RuntimeError(msg)
return await self.scheduler.schedule(conv, at, text, dedupe_key=dedupe_key)
async def schedules(self, conv: Conversation | None = None) -> list[dict[str, Any]]:
return await self.scheduler.scheduled(conv) if self.scheduler else []
# ---- §4.5 rotation -------------------------------------------------
async def handout(self, conv: Conversation, ctx: HandoutContext) -> str:
"""The closing master's last turn: the handout prompt from the config."""
source = self._texts.handout
if isinstance(source, str):
prompt = source.format(day=ctx.day.isoformat(), reason=ctx.reason)
else:
produced: Any = source(ctx)
prompt = await produced if inspect.isawaitable(produced) else produced
self._bus.publish(
"schedule.created",
conversation_id=conv.external_id,
schedule=row.id,
execute_at=_iso(row.execute_at),
"handout.start", conversation_id=conv.external_id, day=ctx.day.isoformat()
)
try:
text, _ = await self.run_text_turn(conv, prompt, origin="handout")
except Exception: # noqa: BLE001
_log.exception("handout turn on %s failed", conv.external_id)
text = ""
self._bus.publish(
"handout.end",
conversation_id=conv.external_id,
day=ctx.day.isoformat(),
text=text[:2000],
)
return text
async def close(self, conv: Conversation) -> Conversation:
row = await self.set_status(conv, "closed")
with contextlib.suppress(LookupError):
await self._backend(conv.agent_name).close(conv.external_id)
return row
async def reparent(self, conv: Conversation, parent: Conversation) -> Conversation:
async def apply(row: Conversation) -> None:
row.parent_id = parent.id
return await self._update(conv, apply)
async def mark_closed(self, conv: Conversation) -> bool:
marked = False
for fe in self._frontends:
if conv.kind in fe.kinds:
try:
marked = await fe.mark_closed(conv) or marked
except Exception: # noqa: BLE001
_log.exception("%s could not mark %s", fe.name, conv.external_id)
return marked
async def new_day(self, conv: Conversation, *, moved: int = 0) -> InjectQueueItem:
day = datetime.now(UTC).astimezone().date().isoformat()
text = self._texts.new_day.format(day=day)
if moved:
text += f" Инжектов переехало из старого мастера: {moved}."
return await self.inject(
conv, text, urgency="urgent", origin="ротация", interrupt=False
)
# ---- §3.7 questions ------------------------------------------------
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
@@ -884,13 +989,6 @@ class Conversations:
with contextlib.suppress(LookupError):
await self._update(conv, apply)
async def schedules(self, conv: Conversation | None = None) -> list[Schedule]:
stmt = select(Schedule).order_by(col(Schedule.execute_at))
if conv is not None:
stmt = stmt.where(Schedule.conversation_id == conv.id)
async with self._db.session() as session:
return list((await session.exec(stmt)).all())
# ---- turns ---------------------------------------------------------
async def turn(
@@ -1193,6 +1291,9 @@ class Conversations:
if head.priority == "user":
origin = "user"
prompt = head.text
envelope = await self._envelope_for(conv, injects=len(batch) - 1)
if envelope:
prompt += "\n\n" + envelope
if len(batch) > 1:
prompt += "\n\n" + _bundle(batch[1:])
else:
@@ -1225,6 +1326,15 @@ class Conversations:
text=text,
)
async def _envelope_for(self, conv: Conversation, *, injects: int) -> str | None:
if conv.kind != "master":
return None
streak = int(conv.flags.get("streak", 0) or 0)
await self.set_flags(conv, {"streak": streak + 1})
if self._envelope is None:
return None
return self._envelope.build(streak=streak, injects=injects)
def _observer(
self, conv: Conversation, runner: _Runner, turn_id: str, origin: str
) -> Callable[[Any], None]:
+122
View File
@@ -0,0 +1,122 @@
"""The envelope (§3.3): a background block after the user's text.
Assembled when the turn starts, never when the message is queued: the
time, how many replies the master gave in a row without opening a branch,
what changed in the vault since the last envelope (added lines for the
``full`` files, names and counts for the rest) and how many normal
injects ride along below. Ceilings keep it a signal, not a document.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from collections.abc import Sequence
from beaver_gateway.core.watch import Change, VaultWatch
__all__ = ["Envelope", "render"]
HEADER = (
"[конверт - фоновый сигнал, не обращение; "
"реагируй, только если относится к вопросу]"
)
@dataclass(slots=True)
class Envelope:
watch: VaultWatch | None = None
tz: str = "UTC"
max_lines: int = 120
per_file: int = 30
names_only_within: float = 600.0
last_at: datetime | None = None
def build(self, *, streak: int, injects: int, now: datetime | None = None) -> str:
now = now or datetime.now(UTC)
changes = self.watch.take() if self.watch is not None else []
names_only = (
self.last_at is not None
and (now - self.last_at).total_seconds() < self.names_only_within
)
text = render(
now=now,
tz=self.tz,
streak=streak,
changes=changes,
since=self.last_at,
names_only=names_only,
injects=injects,
max_lines=self.max_lines,
per_file=self.per_file,
)
self.last_at = now
return text
def render(
*,
now: datetime,
tz: str,
streak: int,
changes: Sequence[Change],
since: datetime | None,
names_only: bool,
injects: int,
max_lines: int = 120,
per_file: int = 30,
) -> str:
zone = ZoneInfo(tz)
stamp = now.astimezone(zone)
lines = [
HEADER,
f"время: {stamp:%Y-%m-%d %H:%M} ({_zone_label(tz)})",
f"мастер: {_replies(streak)} подряд без ветки",
]
ordered = sorted(changes, key=lambda c: (not c.full, c.path))
since_label = (
f"с {since.astimezone(zone):%H:%M}" if since is not None else "со старта" # noqa: RUF001
)
if ordered:
names = ", ".join(f"{c.path} (+{c.added_count})" for c in ordered)
lines.append(f"vault, изменено {since_label} (mtime): {names}")
if injects:
lines.append(f"инжекты {since_label}: ({injects}) ниже")
if not names_only:
_append_diffs(lines, ordered, max_lines=max_lines, per_file=per_file)
return "\n".join(lines[:max_lines])
def _append_diffs(
lines: list[str], changes: Sequence[Change], *, max_lines: int, per_file: int
) -> None:
budget = max_lines - len(lines) - 1
for change in changes:
if not change.full or not change.added:
continue
if budget < 3:
lines.append("… (потолок конверта)")
return
shown = change.added[: min(per_file, budget - 2)]
lines.append(f"--- {change.path}, только добавленное ---")
lines.extend(f"+ {line}" for line in shown)
budget -= 1 + len(shown)
if len(change.added) > len(shown):
lines.append(f"+ … ещё {len(change.added) - len(shown)}")
budget -= 1
def _replies(n: int) -> str:
if n % 10 == 1 and n % 100 != 11:
return f"{n} реплика"
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
return f"{n} реплики"
return f"{n} реплик"
def _zone_label(tz: str) -> str:
return tz.rsplit("/", 1)[-1].replace("_", " ")
+7 -2
View File
@@ -134,8 +134,13 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
)
async def schedule(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
row = await conversations.schedule(conv, str(args["at"]), str(args["text"]))
return _text(f"scheduled #{row.id} at {row.execute_at.isoformat()}")
try:
job_id, when = await conversations.schedule(
conv, str(args["at"]), str(args["text"])
)
except (RuntimeError, ValueError) as exc:
return _error(str(exc))
return _text(f"scheduled #{job_id} at {when.isoformat(timespec='minutes')}")
@tool(
"inject",
+21 -1
View File
@@ -10,7 +10,7 @@ runs one worker per conversation over them. A row that is still
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Literal, cast
from sqlalchemy import func
from sqlmodel import col, select
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from beaver_gateway.storage.db import Database
from beaver_gateway.storage.models import Conversation
__all__ = ["PRIORITY_RANK", "InjectQueue", "Priority", "inject_header"]
@@ -94,6 +95,25 @@ class InjectQueue:
await session.commit()
return rows
async def move(
self, source: Conversation, target: Conversation, *, priority: Priority
) -> int:
"""Re-home queued items of one priority (rotation carries normal over)."""
async with self._db.session() as session:
result = await session.exec(
select(InjectQueueItem).where(
InjectQueueItem.conversation_id == source.id,
InjectQueueItem.status == "queued",
InjectQueueItem.priority == priority,
)
)
rows = list(result.all())
for row in rows:
row.conversation_id = cast("int", target.id)
session.add(row)
await session.commit()
return len(rows)
async def recent(
self, conversation_id: int, *, limit: int = 50
) -> list[InjectQueueItem]:
+13
View File
@@ -17,6 +17,9 @@ if TYPE_CHECKING:
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.core.rotation import RotationPolicy
from beaver_gateway.core.scheduler import Budget, Job
from beaver_gateway.core.watch import VaultWatch
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.mcp.types import McpServerT
@@ -84,6 +87,16 @@ class Gateway:
frontends: list[Frontend] = field(default_factory=list)
texts: ConversationTexts | None = None
"""Merge prompt and seed bodies for ``core/conversations`` (§8.2-8.3)."""
jobs: list[Job] = field(default_factory=list)
"""Cron / webhook / event jobs for ``core/scheduler`` (§3.6, §4.5)."""
rotation: RotationPolicy | None = None
"""When a master is rotated (§4.5); ``None`` keeps the defaults."""
watch: VaultWatch | None = None
"""Vault watcher feeding the envelope (§3.5, §4.6); ``None`` = no vault block."""
budget: Budget | None = None
"""Subscription window past which non-critical jobs wait (§4.5)."""
tz: str = "UTC"
"""Local zone for the envelope clock and the rotation hour."""
host: str = "0.0.0.0" # noqa: S104
port: int = 8000
"""The one listener; every HTTP frontend is mounted under its ``path``."""
+131
View File
@@ -0,0 +1,131 @@
"""Master rotation (§4.5, §8.1, §8.3).
One logical master thread, many physical sessions: when the policy says
so, a new master is spawned and takes over the window atomically, the old
one writes its handout as its last turn, closes, its finished branches get
marked in their windows, its queued normal injects move over, and the new
one receives "new day". Silence is measured by the user's messages only -
injects never extend a day.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.storage.models import Conversation
__all__ = ["HandoutContext", "Rotation", "RotationPolicy"]
_log = logging.getLogger("beaver_gateway.core.rotation")
@dataclass(frozen=True, slots=True)
class RotationPolicy:
tz: str = "UTC"
hour: int = 4
night_silence: timedelta = timedelta(hours=3)
max_age: timedelta = timedelta(hours=36)
short_silence: timedelta = timedelta(minutes=30)
max_context_tokens: int = 80_000
def reason(
self, master: Conversation, *, now: datetime, context_tokens: int
) -> str | None:
zone = ZoneInfo(self.tz)
created = _aware(master.created_at)
silence = now - _aware(master.last_user_activity_at or master.created_at)
boundary = now.astimezone(zone).replace(
hour=self.hour, minute=0, second=0, microsecond=0
)
if now.astimezone(zone) < boundary:
boundary -= timedelta(days=1)
if created < boundary and silence > self.night_silence:
return "ночь"
if now - created > self.max_age and silence > self.short_silence:
return "возраст"
if context_tokens > self.max_context_tokens and silence > self.short_silence:
return "транскрипт"
return None
def day_of(self, master: Conversation) -> date:
return _aware(master.created_at).astimezone(ZoneInfo(self.tz)).date()
@dataclass(frozen=True, slots=True)
class HandoutContext:
day: date
master: Conversation
reason: str
class Rotation:
def __init__(
self, conversations: Conversations, policy: RotationPolicy | None = None
) -> None:
self._conversations = conversations
self.policy = policy or RotationPolicy()
async def due(self, now: datetime | None = None) -> list[tuple[Conversation, str]]:
now = now or datetime.now(UTC)
out: list[tuple[Conversation, str]] = []
for master in await self._conversations.find(kind="master", status="open"):
tokens = await self._conversations.context_tokens(master)
reason = self.policy.reason(master, now=now, context_tokens=tokens)
if reason is not None:
out.append((master, reason))
return out
async def tick(self, now: datetime | None = None) -> list[Conversation]:
rotated: list[Conversation] = []
for master, reason in await self.due(now):
new = await self.rotate(master, reason)
if new is not None:
rotated.append(new)
return rotated
async def rotate(self, old: Conversation, reason: str) -> Conversation | None:
conversations = self._conversations
if await conversations.busy(old):
_log.info("rotation of %s skipped: busy", old.external_id)
return None
new = await conversations.spawn(
kind="master", agent=old.agent_name, seed="morning", origin="rotation"
)
day = self.policy.day_of(old)
_log.info(
"rotation (%s): %s -> %s, handout for %s",
reason,
old.external_id,
new.external_id,
day,
)
await conversations.handout(
old, HandoutContext(day=day, master=old, reason=reason)
)
await conversations.close(old)
for branch in await conversations.find(parent=old, limit=1000):
if branch.status == "open":
await conversations.reparent(branch, new)
elif not branch.running_turn:
await conversations.mark_closed(branch)
moved = await conversations.queue.move(old, new, priority="normal")
await conversations.new_day(new, moved=moved)
conversations.bus.publish(
"conversation.rotated",
conversation_id=new.external_id,
closed=old.external_id,
reason=reason,
handout_day=day.isoformat(),
moved=moved,
)
return new
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
+482
View File
@@ -0,0 +1,482 @@
"""Jobs and deferred injects on pgqueuer (§3.6, §4.5).
A job is a name, a handler and its triggers: a cron expression, the
webhook ``/hooks/<name>``, gateway bus events. The executor is pgqueuer on
the gateway's own Postgres (a dedicated autocommit connection for
LISTEN/NOTIFY), so cron ticks, webhook deliveries and the ``schedule``
tool's one-off injects all live in one table and survive a restart. A
handler only queues work for a conversation and returns; the turn itself
runs in the conversation's worker. Non-critical jobs step aside while the
subscription window is past its threshold.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from pgqueuer import PgQueuer, Queries
from pgqueuer.models import JobId
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from beaver_gateway.core.conversations import parse_at
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Coroutine, Sequence
from pgqueuer.models import Job as PgJob
from pgqueuer.models import Schedule as PgSchedule
from pgqueuer.ports.driver import Driver
from starlette.requests import Request
from beaver_gateway.core.conversations import Conversations
from beaver_gateway.core.injects import Priority
from beaver_gateway.core.rotation import Rotation
from beaver_gateway.storage.models import Conversation
__all__ = ["INJECT", "Budget", "Job", "JobRun", "Scheduler"]
_log = logging.getLogger("beaver_gateway.core.scheduler")
INJECT = "inject"
RETRY = timedelta(minutes=15)
@dataclass(frozen=True, slots=True)
class Job:
name: str
run: Callable[[JobRun], Awaitable[None]]
cron: str | None = None
webhook: bool = False
events: tuple[str, ...] = ()
critical: bool = True
@property
def entrypoint(self) -> str:
return f"job:{self.name}"
@dataclass(frozen=True, slots=True)
class Budget:
threshold: float = 0.7
tokens: int | None = None
window: timedelta = timedelta(hours=5)
limit_window: str = "five_hour"
@dataclass(slots=True)
class JobRun:
job: Job
trigger: str
payload: dict[str, Any]
scheduler: Scheduler
@property
def conversations(self) -> Conversations:
return self.scheduler.conversations
async def master(self) -> Conversation | None:
masters = await self.conversations.find(kind="master", status="open", limit=1)
return masters[0] if masters else None
async def inject_master(
self, text: str, *, urgency: Priority = "normal", origin: str | None = None
) -> bool:
master = await self.master()
if master is None:
return False
await self.conversations.inject(
master, text, urgency=urgency, origin=origin or self.job.name
)
return True
async def spawn_job(
self, *, agent: str, text: str, title: str | None = None
) -> Conversation:
return await self.conversations.spawn(
kind="job", agent=agent, seed="brief", text=text, title=title, origin="job"
)
async def retry_in(self, delay: timedelta) -> None:
await self.scheduler.trigger(
self.job, self.payload, delay=delay, trigger=self.trigger
)
async def rotate(self) -> list[Conversation]:
rotation = self.scheduler.rotation
return await rotation.tick() if rotation is not None else []
def background(self, coro: Coroutine[Any, Any, Any]) -> None:
self.scheduler.background(coro)
class Scheduler:
def __init__(
self,
*,
conversations: Conversations,
jobs: Sequence[Job] = (),
driver: Driver | None = None,
budget: Budget | None = None,
rotation: Rotation | None = None,
heartbeat: timedelta = timedelta(seconds=30),
) -> None:
self.conversations = conversations
self.rotation = rotation
self.budget = budget or Budget()
self._jobs = {job.name: job for job in jobs}
self._driver = driver
self._queries: Queries | None = None
self._heartbeat = heartbeat
self._tasks: set[asyncio.Task[Any]] = set()
self._runs: dict[str, dict[str, Any]] = {}
@property
def enabled(self) -> bool:
return self._queries is not None
@property
def jobs(self) -> list[Job]:
return list(self._jobs.values())
def job(self, name: str) -> Job | None:
return self._jobs.get(name)
# ---- lifecycle -----------------------------------------------------
async def start(self) -> None:
if self._driver is not None:
queries = Queries(self._driver)
if not await queries.has_table("pgqueuer"):
await queries.install()
pgq = PgQueuer(self._driver, queries=queries)
self._register(pgq)
self._queries = queries
self._spawn(pgq.run(heartbeat_timeout=self._heartbeat))
else:
_log.warning("scheduler: no postgres driver - cron and webhooks are off")
self._spawn(self._events())
async def stop(self) -> None:
for task in list(self._tasks):
task.cancel()
for task in list(self._tasks):
with contextlib.suppress(BaseException):
await task
self._tasks.clear()
self._queries = None
def _register(self, pgq: PgQueuer) -> None:
@pgq.entrypoint(INJECT)
async def deliver(job: PgJob) -> None:
await self._deliver(job)
for job in self._jobs.values():
self._register_job(pgq, job)
def _register_job(self, pgq: PgQueuer, job: Job) -> None:
@pgq.entrypoint(job.entrypoint)
async def queued(pg_job: PgJob) -> None:
data = _decode(pg_job.payload)
await self._dispatch(
job, trigger=str(data.pop("trigger", "queue")), payload=data
)
if job.cron:
@pgq.schedule(job.name, job.cron, clean_old=True)
async def cron(_schedule: PgSchedule) -> None:
await self._dispatch(job, trigger="cron", payload={})
async def _events(self) -> None:
listeners = [job for job in self._jobs.values() if job.events]
if not listeners:
return
async for event in self.conversations.bus.stream():
for job in listeners:
if event.get("type") in job.events:
self._spawn(
self._dispatch(job, trigger="event", payload=dict(event))
)
# ---- dispatch ------------------------------------------------------
async def _dispatch(
self, job: Job, *, trigger: str, payload: dict[str, Any]
) -> None:
bus = self.conversations.bus
if not job.critical and await self.throttled():
bus.publish("job.deferred", job=job.name, trigger=trigger)
if trigger != "cron":
await self.trigger(job, payload, delay=RETRY, trigger=trigger)
return
started = datetime.now(UTC)
bus.publish("job.start", job=job.name, trigger=trigger)
status = "done"
try:
await job.run(JobRun(job, trigger, payload, self))
except Exception: # noqa: BLE001
status = "failed"
_log.exception("job %s (%s) failed", job.name, trigger)
self._runs[job.name] = {
"trigger": trigger,
"started_at": started.isoformat(timespec="seconds"),
"status": status,
}
bus.publish("job.end", job=job.name, trigger=trigger, status=status)
async def trigger(
self,
job: Job,
payload: dict[str, Any] | None = None,
*,
delay: timedelta | None = None,
trigger: str = "manual",
) -> int | None:
"""Queue one run of ``job``; runs inline when there is no executor."""
data = {**(payload or {}), "trigger": trigger}
if self._queries is None:
self._spawn(
self._dispatch(job, trigger=trigger, payload=payload or {}), delay=delay
)
return None
ids = await self._queries.enqueue(
job.entrypoint, _encode(data), execute_after=delay
)
return int(ids[0]) if ids and ids[0] is not None else None
async def hook(self, name: str, payload: dict[str, Any]) -> int | None:
job = self._jobs.get(name)
if job is None or not job.webhook:
msg = f"no webhook job {name!r}"
raise LookupError(msg)
self.conversations.bus.publish("hook", job=name)
if self._queries is None:
return await self.trigger(job, payload, trigger="webhook")
ids = await self._queries.enqueue(
job.entrypoint,
_encode({**payload, "trigger": "webhook"}),
dedupe_key=f"hook:{name}",
on_conflict="skip",
)
return int(ids[0]) if ids and ids[0] is not None else None
# ---- deferred injects ----------------------------------------------
async def schedule(
self,
conv: Conversation,
at: str,
text: str,
*,
urgency: Priority = "normal",
dedupe_key: str | None = None,
) -> tuple[int | None, datetime]:
if self._queries is None:
msg = "scheduler needs postgres; `schedule` is unavailable"
raise RuntimeError(msg)
when = parse_at(at)
delay = max(when - datetime.now(UTC), timedelta(0))
payload = _encode(
{
"conversation": conv.external_id,
"text": text,
"urgency": urgency,
"at": when.isoformat(timespec="seconds"),
}
)
ids = await self._queries.enqueue(
INJECT,
payload,
execute_after=delay,
dedupe_key=dedupe_key,
on_conflict="skip" if dedupe_key else "raise",
)
job_id = int(ids[0]) if ids and ids[0] is not None else None
self.conversations.bus.publish(
"schedule.created",
conversation_id=conv.external_id,
job=job_id,
execute_at=when.isoformat(timespec="seconds"),
)
return job_id, when
async def cancel(self, job_id: int) -> bool:
if self._queries is None:
return False
row = await self._queries.queue_job_by_id(JobId(job_id))
if row is None:
return False
await self._queries.mark_job_as_cancelled([JobId(job_id)])
self.conversations.bus.publish("schedule.cancelled", job=job_id)
return True
async def _deliver(self, job: PgJob) -> None:
data = _decode(job.payload)
conv = await self.conversations.get(str(data.get("conversation", "")))
if conv is not None and conv.status != "open" and conv.kind == "master":
masters = await self.conversations.find(
kind="master", status="open", limit=1
)
conv = masters[0] if masters else None
if conv is None:
_log.warning("scheduled inject #%s: conversation gone", job.id)
return
await self.conversations.inject(
conv,
str(data.get("text", "")),
urgency=cast("Priority", data.get("urgency") or "normal"),
origin="schedule",
)
# ---- budget --------------------------------------------------------
async def utilization(self) -> float | None:
now = datetime.now(UTC)
values: list[float] = []
for row in await self.conversations.rate_limits(limit=100):
if row.window != self.budget.limit_window or row.utilization is None:
continue
fresh = now - _aware(row.ts) < self.budget.window
live = row.resets_at is None or _aware(row.resets_at) > now
if fresh and live:
values.append(float(row.utilization))
break
if self.budget.tokens:
tokens = await self.conversations.usage_tokens(now - self.budget.window)
values.append(tokens / self.budget.tokens)
return max(values) if values else None
async def throttled(self) -> bool:
utilization = await self.utilization()
return utilization is not None and utilization > self.budget.threshold
# ---- introspection -------------------------------------------------
async def snapshot(self) -> dict[str, Any]:
crons: dict[str, PgSchedule] = {}
queue: list[dict[str, Any]] = []
if self._queries is not None:
for row in await self._queries.peek_schedule():
crons[str(row.entrypoint)] = row
queue = [
_job_public(row) for row in await self._queries.browse_queue(limit=200)
]
utilization = await self.utilization()
return {
"enabled": self.enabled,
"utilization": utilization,
"throttled": utilization is not None
and utilization > self.budget.threshold,
"threshold": self.budget.threshold,
"jobs": [self._job_public(job, crons.get(job.name)) for job in self.jobs],
"queue": queue,
}
async def scheduled(self, conv: Conversation | None = None) -> list[dict[str, Any]]:
if self._queries is None:
return []
rows = await self._queries.browse_queue(limit=500, entrypoints=[INJECT])
out = [_job_public(row) for row in rows]
if conv is not None:
out = [
j for j in out if j["payload"].get("conversation") == conv.external_id
]
return out
def _job_public(self, job: Job, cron: PgSchedule | None) -> dict[str, Any]:
return {
"name": job.name,
"cron": job.cron,
"webhook": job.webhook,
"events": list(job.events),
"critical": job.critical,
"next_run": _iso(cron.next_run) if cron is not None else None,
"last_run": _iso(cron.last_run) if cron is not None else None,
"status": str(cron.status) if cron is not None else None,
"run": self._runs.get(job.name),
}
# ---- http ----------------------------------------------------------
def app(self, authorize: Callable[[Request], Awaitable[Any]]) -> Starlette:
async def hook(request: Request) -> JSONResponse:
await authorize(request)
name = request.path_params["name"]
payload = await _payload(request)
try:
job_id = await self.hook(name, payload)
except LookupError as exc:
return JSONResponse({"error": str(exc)}, status_code=404)
return JSONResponse({"job": job_id, "name": name}, status_code=202)
return Starlette(routes=[Route("/{name}", hook, methods=["POST"])])
# ---- internals -----------------------------------------------------
def background(self, coro: Coroutine[Any, Any, Any]) -> None:
self._spawn(coro)
def _spawn(
self, coro: Coroutine[Any, Any, Any], *, delay: timedelta | None = None
) -> None:
async def later() -> None:
if delay is not None:
await asyncio.sleep(delay.total_seconds())
await coro
task = asyncio.create_task(later() if delay is not None else coro)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _payload(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return dict(request.query_params)
try:
data = json.loads(body)
except ValueError:
return {"raw": body.decode("utf-8", errors="replace")}
return data if isinstance(data, dict) else {"raw": data}
def _encode(data: dict[str, Any]) -> bytes:
return json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
def _decode(payload: bytes | None) -> dict[str, Any]:
if not payload:
return {}
try:
data = json.loads(payload)
except ValueError:
return {"raw": payload.decode("utf-8", errors="replace")}
return data if isinstance(data, dict) else {"raw": data}
def _job_public(row: PgJob) -> dict[str, Any]:
return {
"id": int(row.id),
"entrypoint": row.entrypoint,
"status": str(row.status),
"execute_after": _iso(row.execute_after),
"created": _iso(row.created),
"attempts": row.attempts,
"payload": _decode(row.payload),
}
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat(timespec="seconds") if value is not None else None
+205
View File
@@ -0,0 +1,205 @@
"""Vault watcher for the envelope (§3.5, §4.6).
A content snapshot is taken at every envelope; a change is the diff of the
file against that snapshot, "added lines only". ``watchfiles`` delivers
paths with a settle debounce (Sync writes files in pieces), the change
carries the file's mtime rather than the moment it landed. Which files
show a full diff, which show up by name and which are ignored is a set of
glob patterns the config hands in - the gateway itself knows no path.
"""
from __future__ import annotations
import asyncio
import contextlib
import difflib
import fnmatch
import logging
import os
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
from watchfiles import awatch
if TYPE_CHECKING:
from collections.abc import Iterable
__all__ = ["Change", "VaultWatch", "WatchRules"]
_log = logging.getLogger("beaver_gateway.core.watch")
@dataclass(frozen=True, slots=True)
class WatchRules:
"""Globs relative to the vault root; ``{today}`` expands to the local date.
``full`` files show their added lines, ``names`` files only their name
and how many lines they gained, ``ignore`` hides them; when several
patterns match, the most specific one decides.
"""
full: tuple[str, ...] = ()
names: tuple[str, ...] = ()
ignore: tuple[str, ...] = ()
suffixes: tuple[str, ...] = (".md",)
def kind(self, rel: str, *, today: str) -> str | None:
"""The most specific matching pattern wins, whichever list it is in."""
if not rel.endswith(self.suffixes):
return None
best: tuple[int, str | None] = (-1, None)
for kind, patterns in (
("full", self.full),
("names", self.names),
(None, self.ignore),
):
for pattern in patterns:
if _match(rel, pattern, today):
best = max(best, (_specificity(pattern), kind))
return best[1]
@dataclass(slots=True)
class Change:
path: str
mtime: datetime
added: list[str] = field(default_factory=list)
added_count: int = 0
full: bool = False
@dataclass(slots=True)
class _Seen:
lines: list[str] | None
count: int
class VaultWatch:
def __init__(
self, root: Path, rules: WatchRules, *, tz: str = "UTC", debounce: float = 4.0
) -> None:
self.root = root
self.rules = rules
self._tz = ZoneInfo(tz)
self._debounce = debounce
self._seen: dict[str, _Seen] = {}
self._pending: dict[str, Change] = {}
self._stop = asyncio.Event()
@property
def pending(self) -> list[Change]:
return list(self._pending.values())
def today(self, now: datetime | None = None) -> str:
return (now or datetime.now(UTC)).astimezone(self._tz).date().isoformat()
async def run(self) -> None:
await asyncio.to_thread(self.snapshot)
_log.info("watching %s (%d files in snapshot)", self.root, len(self._seen))
async for changes in awatch(
self.root,
debounce=int(self._debounce * 1000),
stop_event=self._stop,
ignore_permission_denied=True,
):
for _kind, raw in changes:
with contextlib.suppress(OSError):
self.note(Path(raw))
def stop(self) -> None:
self._stop.set()
def snapshot(self) -> None:
today = self.today()
for path in self._walk():
rel = self._rel(path)
kind = self.rules.kind(rel, today=today)
if kind is None:
continue
with contextlib.suppress(OSError):
lines = _read_lines(path)
self._seen[rel] = _Seen(
lines=lines if kind == "full" else None, count=len(lines)
)
def note(self, path: Path) -> Change | None:
"""Record a change of ``path`` against the last envelope's snapshot."""
try:
rel = self._rel(path)
except ValueError:
return None
kind = self.rules.kind(rel, today=self.today())
if kind is None:
return None
if not path.is_file():
self._pending.pop(rel, None)
return None
stat = path.stat()
lines = _read_lines(path)
seen = self._seen.get(rel)
change = Change(
path=rel,
mtime=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
full=kind == "full",
)
if seen is not None and seen.lines is not None:
change.added = _added(seen.lines, lines)
change.added_count = len(change.added)
elif seen is not None:
change.added_count = max(len(lines) - seen.count, 0)
else:
change.added = lines if kind == "full" else []
change.added_count = len(lines)
if change.added_count == 0 and seen is not None:
self._pending.pop(rel, None)
return None
self._pending[rel] = change
return change
def take(self) -> list[Change]:
"""Hand out what changed since the last envelope and reset the snapshot."""
changes = list(self._pending.values())
self._pending.clear()
for change in changes:
path = self.root / change.path
with contextlib.suppress(OSError):
lines = _read_lines(path)
self._seen[change.path] = _Seen(
lines=lines if change.full else None, count=len(lines)
)
return changes
def _rel(self, path: Path) -> str:
return path.resolve().relative_to(self.root.resolve()).as_posix()
def _walk(self) -> Iterable[Path]:
for dirpath, dirnames, filenames in os.walk(self.root):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in filenames:
yield Path(dirpath) / name
def _match(rel: str, pattern: str, today: str) -> bool:
return fnmatch.fnmatchcase(
rel, pattern.replace("{today}", today).replace("**", "*")
)
def _specificity(pattern: str) -> int:
return sum(1 for ch in pattern if ch not in "*?[]{}")
def _read_lines(path: Path) -> list[str]:
return path.read_text(encoding="utf-8", errors="replace").splitlines()
def _added(old: list[str], new: list[str]) -> list[str]:
matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
added: list[str] = []
for tag, _i1, _i2, j1, j2 in matcher.get_opcodes():
if tag in ("insert", "replace"):
added.extend(line for line in new[j1:j2] if line.strip())
return added
+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
-2
View File
@@ -23,7 +23,6 @@ from beaver_gateway.storage.models import (
Delivery,
InjectQueueItem,
RateLimit,
Schedule,
TelegramUpdate,
Token,
TranscriptEntry,
@@ -40,7 +39,6 @@ __all__ = [
"InjectQueueItem",
"PostgresSessionStore",
"RateLimit",
"Schedule",
"TelegramUpdate",
"Token",
"TranscriptEntry",
-18
View File
@@ -167,23 +167,6 @@ class InjectQueueItem(SQLModel, table=True):
delivered_at: datetime | None = Field(default=None)
class Schedule(SQLModel, table=True):
"""Deferred inject written by the ``schedule`` tool (§3.6).
M1b only records the promise; the executor (pgqueuer, M3) will move
these into its own job table and this one goes away.
"""
__tablename__ = "schedules"
id: int | None = Field(default=None, primary_key=True)
conversation_id: int = Field(index=True)
execute_at: datetime = Field(index=True)
text: str
created_at: datetime = Field(default_factory=_utcnow)
delivered_at: datetime | None = Field(default=None)
class TelegramUpdate(SQLModel, table=True):
"""Inbox of the Telegram frontend (§3.8).
@@ -362,7 +345,6 @@ __all__ = [
"Delivery",
"InjectQueueItem",
"RateLimit",
"Schedule",
"TelegramUpdate",
"Token",
"TranscriptEntry",