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

This commit is contained in:
hh
2026-08-29 02:29:05 +02:00
parent 0479d7cdda
commit 662bdcdfda
27 changed files with 2123 additions and 120 deletions
+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("_", " ")