feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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("_", " ")
|
||||
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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``."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user