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
+158
View File
@@ -0,0 +1,158 @@
import asyncio
import tempfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from test_conversations import ScriptedClient, World, world
from beaver_gateway.core.envelope import HEADER, Envelope, render
from beaver_gateway.core.watch import Change, VaultWatch, WatchRules
__all__ = ["world"]
RULES = WatchRules(
full=("дни/{today}.md",),
names=("дни/*", "люди/*", "мета/бобер/*"),
ignore=("чаты/*", "мета/*"),
)
def vault() -> tuple[Path, VaultWatch]:
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
for sub in ("дни", "люди", "чаты", "мета/бобер", "мета/чужое"):
(root / sub).mkdir(parents=True)
today = datetime.now(UTC).date().isoformat()
(root / "дни" / f"{today}.md").write_text("# день\n- 09:00 проснулся\n")
(root / "люди" / "Прохор.md").write_text("# Прохор\nстрока\n")
(root / "чаты" / "чат.md").write_text("чат\n")
(root / "мета" / "чужое" / "x.md").write_text("x\n")
watch = VaultWatch(root, RULES, tz="UTC")
watch.snapshot()
return root, watch
def append(path: Path, text: str) -> None:
with path.open("a", encoding="utf-8") as fh:
fh.write(text)
def test_rules_classify_paths() -> None:
today = "2026-08-29"
assert RULES.kind("дни/2026-08-29.md", today=today) == "full"
assert RULES.kind("дни/2026-08-28.md", today=today) == "names"
assert RULES.kind("люди/Прохор.md", today=today) == "names"
assert RULES.kind("мета/бобер/состояние.md", today=today) == "names"
assert RULES.kind("мета/чужое/x.md", today=today) is None
assert RULES.kind("чаты/чат.md", today=today) is None
assert RULES.kind("люди/фото.png", today=today) is None
def test_watch_reports_only_added_lines_with_mtime() -> None:
root, watch = vault()
today = watch.today()
diary = root / "дни" / f"{today}.md"
append(diary, "- 12:40 вышел\n- 13:00 кофе\n")
append(root / "люди" / "Прохор.md", "ещё\n")
append(root / "чаты" / "чат.md", "ignored\n")
for path in (diary, root / "люди" / "Прохор.md", root / "чаты" / "чат.md"):
watch.note(path)
changes = {c.path: c for c in watch.take()}
assert set(changes) == {f"дни/{today}.md", "люди/Прохор.md"}
assert changes[f"дни/{today}.md"].added == ["- 12:40 вышел", "- 13:00 кофе"]
assert changes[f"дни/{today}.md"].full
assert changes["люди/Прохор.md"].added_count == 1
assert changes["люди/Прохор.md"].added == []
assert abs(
changes[f"дни/{today}.md"].mtime
- datetime.fromtimestamp(diary.stat().st_mtime, tz=UTC)
) < timedelta(seconds=1)
assert watch.take() == []
append(diary, "- 14:00 снова\n")
watch.note(diary)
(only,) = watch.take()
assert only.added == ["- 14:00 снова"]
def test_envelope_respects_ceilings_and_names_only_window() -> None:
root, watch = vault()
today = watch.today()
diary = root / "дни" / f"{today}.md"
append(diary, "".join(f"- строка {i}\n" for i in range(200)))
for name in ("Петя", "Маша"):
(root / "люди" / f"{name}.md").write_text("новый\n" * 50)
watch.note(root / "люди" / f"{name}.md")
watch.note(diary)
envelope = Envelope(watch=watch, tz="Europe/Warsaw")
text = envelope.build(streak=7, injects=2)
lines = text.splitlines()
assert lines[0] == HEADER
assert "(Warsaw)" in lines[1]
assert lines[2] == "мастер: 7 реплик подряд без ветки"
assert f"дни/{today}.md (+200)" in lines[3]
assert "люди/Петя.md (+50)" in lines[3]
assert "инжекты со старта: (2) ниже" in lines[4]
assert sum(1 for line in lines if line.startswith("+ ")) == 31
assert "+ … ещё 170" in lines
assert len(lines) <= 120
append(diary, "- ещё одна\n")
watch.note(diary)
second = envelope.build(streak=8, injects=0)
assert f"дни/{today}.md (+1)" in second
assert "+ - ещё одна" not in second
assert "инжекты" not in second
def test_render_hits_total_ceiling() -> None:
now = datetime(2026, 8, 26, 11, 4, tzinfo=UTC)
changes = [
Change(
path=f"дни/{i}.md",
mtime=now,
added=[f"l{j}" for j in range(30)],
added_count=30,
full=True,
)
for i in range(10)
]
text = render(
now=now,
tz="Europe/Warsaw",
streak=1,
changes=changes,
since=now - timedelta(hours=1),
names_only=False,
injects=0,
)
lines = text.splitlines()
assert len(lines) <= 120
assert "… (потолок конверта)" in lines
assert lines[1] == "время: 2026-08-26 13:04 (Warsaw)"
assert lines[2] == "мастер: 1 реплика подряд без ветки"
async def test_master_turn_gets_envelope_after_text_and_before_injects(
world: World,
) -> None:
root, watch = vault()
world.conversations._envelope = Envelope(watch=watch, tz="UTC") # noqa: SLF001
master = await world.conversations.create(kind="master", agent="a", origin="test")
append(root / "люди" / "Прохор.md", "новое\n")
watch.note(root / "люди" / "Прохор.md")
await world.conversations.inject(master, "tick", urgency="normal", origin="крон")
await world.conversations.post(master, "hello")
await world.settle(master, 2)
prompt = ScriptedClient.instances[0].prompts[0]
head, _, rest = prompt.partition("\n\n")
assert head == "hello"
assert rest.startswith(HEADER)
assert "люди/Прохор.md (+1)" in rest
assert "мастер: 0 реплик подряд без ветки" in rest
assert rest.index("[инжекты") > rest.index(HEADER)
assert (await world.conversations.get(master.external_id)).flags["streak"] == 1
branch = await world.conversations.spawn(
kind="branch", parent=master, seed="brief", text="do X"
)
await world.settle(branch, 1)
assert (await world.conversations.get(master.external_id)).flags["streak"] == 0
assert "[конверт" not in ScriptedClient.instances[-1].prompts[0]
await asyncio.sleep(0)