feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones
This commit is contained in:
+25
-2
@@ -6,8 +6,8 @@
|
|||||||
### Compose ###################################################################
|
### Compose ###################################################################
|
||||||
|
|
||||||
# Какие сервисы поднимать: db (postgres), gateway, obsidian (obsidian-headless),
|
# Какие сервисы поднимать: db (postgres), gateway, obsidian (obsidian-headless),
|
||||||
# t3 (t3code-mcp - руки диспетчера на маке и dell)
|
# t3 (t3code-mcp - руки диспетчера на маке и dell), vibegram (клон репы комнаты)
|
||||||
COMPOSE_PROFILES=db,gateway,obsidian,t3
|
COMPOSE_PROFILES=db,gateway,obsidian,t3,vibegram
|
||||||
|
|
||||||
# Ветка/тег beaver-gateway, из которого собирается образ шлюза
|
# Ветка/тег beaver-gateway, из которого собирается образ шлюза
|
||||||
GATEWAY_REF=main
|
GATEWAY_REF=main
|
||||||
@@ -80,6 +80,29 @@ T3CODE_MCP_REF=main
|
|||||||
KOMODO_URL=
|
KOMODO_URL=
|
||||||
KOMODO_KEY=
|
KOMODO_KEY=
|
||||||
KOMODO_SECRET=
|
KOMODO_SECRET=
|
||||||
|
# Алерты Komodo → /hooks/komodo?token=<токен gateway со scope api> (алертер
|
||||||
|
# в infra/komodo, Custom endpoint; Komodo не умеет заголовки). Токен минтится
|
||||||
|
# в admin → Tokens, здесь его хранить не нужно.
|
||||||
|
|
||||||
|
### Home Assistant (python_tool, с S12) ########################################
|
||||||
|
|
||||||
|
# HA на том же хосте с network_mode: host → host.docker.internal (extra_hosts)
|
||||||
|
HA_URL=http://host.docker.internal:8123
|
||||||
|
# Профиль → Безопасность → долгоживущие токены
|
||||||
|
HA_TOKEN=
|
||||||
|
|
||||||
|
### Вайбграм (комната агентов, с S12) #########################################
|
||||||
|
|
||||||
|
# Хаб и токен агента: один раз `vibegram join <code> --nick <ник> --no-hooks`
|
||||||
|
# из клона репы комнаты на любой машине, токен - в ~/.vibegram/config.json
|
||||||
|
VIBEGRAM_HUB=https://vibegram.example.com
|
||||||
|
VIBEGRAM_TOKEN=
|
||||||
|
VIBEGRAM_NICK=beaver-test
|
||||||
|
# Репа комнаты (GitHub org/repo) - сервис vibegram-repo держит клон в томе
|
||||||
|
# `vibegram`, gateway видит его в /vibegram/<repo> только на чтение
|
||||||
|
VIBEGRAM_REPO=org/room
|
||||||
|
# Токен GitHub с доступом к репе (приватная); пусто - публичная
|
||||||
|
VIBEGRAM_REPO_TOKEN=
|
||||||
|
|
||||||
### Порты хоста и внешний адрес ###############################################
|
### Порты хоста и внешний адрес ###############################################
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ Claude agents run on the Claude Agent SDK. Prompts are granules under
|
|||||||
and the order live in `config.py`; skills are the folders under
|
and the order live in `config.py`; skills are the folders under
|
||||||
`мета/бобер/скиллы/` (each becomes a plugin, keep `name:` in SKILL.md
|
`мета/бобер/скиллы/` (each becomes a plugin, keep `name:` in SKILL.md
|
||||||
latin). The vault must be synced before the gateway can start. The model process runs as `beaver-runner`
|
latin). The vault must be synced before the gateway can start. The model process runs as `beaver-runner`
|
||||||
with a whitelisted environment; the vault is read-only for it except
|
with a whitelisted environment; the vault is mounted read-write (it is the
|
||||||
`мета/бобер` and `💬 чаты`.
|
dispatcher's home), and `policy.py` keeps it out of `.obsidian`, `мета`
|
||||||
|
outside `мета/бобер`, and from deleting anything but its own files.
|
||||||
|
|
||||||
### 4. Mint a token
|
### 4. Mint a token
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ from beaver_gateway.frontends.mcp_server import McpServerFrontend
|
|||||||
from beaver_gateway.frontends.telegram import TelegramFrontend
|
from beaver_gateway.frontends.telegram import TelegramFrontend
|
||||||
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
from beaver_gateway.mcp.types import HttpMcp, McpServer, McpServerT
|
||||||
|
|
||||||
|
from mcps.homeassistant import HomeAssistant
|
||||||
from mcps.komodo import Komodo
|
from mcps.komodo import Komodo
|
||||||
|
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
|
||||||
|
from mcps.vibegram import Vibegram
|
||||||
from policy import (
|
from policy import (
|
||||||
DEEP_DISALLOWED,
|
DEEP_DISALLOWED,
|
||||||
DISPATCHER_DISALLOWED,
|
DISPATCHER_DISALLOWED,
|
||||||
@@ -117,9 +120,18 @@ DEEP_SKILLS = (SKILLS / "общие", SKILLS / "vault")
|
|||||||
DISPATCHER_SKILLS = (SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault")
|
DISPATCHER_SKILLS = (SKILLS / "общие", SKILLS / "диспетчер", SKILLS / "vault")
|
||||||
|
|
||||||
|
|
||||||
# §3.7: зоны vault - запись только в мета/бобер, в 💬 чаты только новые файлы;
|
# §3.7 (решение h, 2026-08-30): vault - дом диспетчера. Создавать, править,
|
||||||
# firefly пишет только после открытого скилла. Маунт ro - первая линия.
|
# переносить можно везде, кроме `.obsidian` и `мета` вне `бобер`; удалять -
|
||||||
ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS_DIR,))
|
# только своё (`мета/бобер`) и `💬 чаты`, существующие чаты не трогать.
|
||||||
|
# Дистиллятор остаётся в строгих зонах. firefly пишет только после скилла.
|
||||||
|
ZONES = Zones(
|
||||||
|
vault=VAULT,
|
||||||
|
write=(BEAVER,),
|
||||||
|
create=(CHATS_DIR,),
|
||||||
|
edit=(VAULT,),
|
||||||
|
protected=(VAULT / ".obsidian", VAULT / "мета"),
|
||||||
|
)
|
||||||
|
STRICT_ZONES = Zones(vault=VAULT, write=(BEAVER,), create=(CHATS_DIR,))
|
||||||
FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*")
|
FIREFLY_WRITES = ("mcp__firefly__store_*", "mcp__firefly__update_*")
|
||||||
VAULT_POLICY = (
|
VAULT_POLICY = (
|
||||||
skill_tracker(),
|
skill_tracker(),
|
||||||
@@ -170,6 +182,39 @@ komodo_mcps = (
|
|||||||
)
|
)
|
||||||
komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else ()
|
komodo_exposed = (ExposedMcp(name="komodo"),) if KOMODO else ()
|
||||||
|
|
||||||
|
# §4.4: Home Assistant - python_tool, токен в gateway. HA живёт на хосте
|
||||||
|
# (network_mode: host), из контейнера - host.docker.internal.
|
||||||
|
HA = (
|
||||||
|
HomeAssistant(url=os.environ["HA_URL"], token=os.environ["HA_TOKEN"])
|
||||||
|
if os.environ.get("HA_URL") and os.environ.get("HA_TOKEN")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
ha_mcps = [McpServer.python_tool(name="ha", tools=[HA.ha])] if HA else []
|
||||||
|
ha_exposed = (ExposedMcp(name="ha"),) if HA else ()
|
||||||
|
|
||||||
|
# §8.5, §12.D1: вайбграм - комната агентов; диспетчер сидит в ней сам, но
|
||||||
|
# через перечислимые действия, токен агента в gateway. Клон репы комнаты -
|
||||||
|
# `vibegram` volume, /vibegram/<repo> ro, тянет сервис vibegram-repo.
|
||||||
|
VIBEGRAM_REPO = (
|
||||||
|
Path("/vibegram") / os.environ.get("VIBEGRAM_REPO", "x/room").split("/")[-1]
|
||||||
|
)
|
||||||
|
VIBEGRAM = (
|
||||||
|
Vibegram(
|
||||||
|
hub=os.environ.get("VIBEGRAM_HUB", "https://vibegram.example.com"),
|
||||||
|
token=os.environ["VIBEGRAM_TOKEN"],
|
||||||
|
nick=os.environ.get("VIBEGRAM_NICK", "beaver-test"),
|
||||||
|
repo=VIBEGRAM_REPO if VIBEGRAM_REPO.exists() else None,
|
||||||
|
)
|
||||||
|
if os.environ.get("VIBEGRAM_TOKEN")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
vibegram_mcps = (
|
||||||
|
[McpServer.python_tool(name="vibegram", tools=[VIBEGRAM.vibegram])]
|
||||||
|
if VIBEGRAM
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
vibegram_exposed = (ExposedMcp(name="vibegram"),) if VIBEGRAM else ()
|
||||||
|
|
||||||
# §5: t3code-mcp - сосед по compose (профиль t3), машины и allowlist проектов
|
# §5: t3code-mcp - сосед по compose (профиль t3), машины и allowlist проектов
|
||||||
# в его t3code.toml; токены t3 живут в его env, gateway их не видит.
|
# в его t3code.toml; токены t3 живут в его env, gateway их не видит.
|
||||||
T3CODE_MCP = os.environ.get("T3CODE_MCP", "http://t3code-mcp:8000/mcp")
|
T3CODE_MCP = os.environ.get("T3CODE_MCP", "http://t3code-mcp:8000/mcp")
|
||||||
@@ -200,6 +245,8 @@ mcps: list[McpServerT] = [
|
|||||||
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
|
McpServer.http(name="telegram", url=os.environ["BEAVERGRAM_MCP"]),
|
||||||
*calendar_mcps,
|
*calendar_mcps,
|
||||||
*komodo_mcps,
|
*komodo_mcps,
|
||||||
|
*ha_mcps,
|
||||||
|
*vibegram_mcps,
|
||||||
*t3code_mcps,
|
*t3code_mcps,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -210,6 +257,7 @@ CLAUDE_MCPS = (
|
|||||||
ExposedMcp(name="telegram"),
|
ExposedMcp(name="telegram"),
|
||||||
*calendar_exposed,
|
*calendar_exposed,
|
||||||
*komodo_exposed,
|
*komodo_exposed,
|
||||||
|
*ha_exposed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -228,8 +276,9 @@ def dispatcher(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|||||||
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
|
gateway_tools=("read_conversation", "spawn", "say", "schedule"),
|
||||||
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
|
options=ClaudeOptions(effort=effort, disallowed_tools=DISPATCHER_DISALLOWED),
|
||||||
policy=VAULT_POLICY,
|
policy=VAULT_POLICY,
|
||||||
# §4.4: t3code - рука диспетчера (мастер, ветки), глубоким не даётся.
|
# §4.4: t3code и вайбграм - руки диспетчера (мастер, ветки), глубоким
|
||||||
expose_mcps=(*CLAUDE_MCPS, *t3code_exposed),
|
# не даются: наружу и в код ходит только он.
|
||||||
|
expose_mcps=(*CLAUDE_MCPS, *t3code_exposed, *vibegram_exposed),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +309,7 @@ def distiller(name: str, model: str, effort: str | None = None) -> ClaudeAgent:
|
|||||||
tools=("Read", "Write"),
|
tools=("Read", "Write"),
|
||||||
disallowed_tools=DISTILLER_DISALLOWED,
|
disallowed_tools=DISTILLER_DISALLOWED,
|
||||||
),
|
),
|
||||||
policy=(vault_zones(ZONES),),
|
policy=(vault_zones(STRICT_ZONES),),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -506,28 +555,44 @@ async def rotate(run: JobRun) -> None:
|
|||||||
|
|
||||||
|
|
||||||
_vibegram_last_wake: datetime | None = None
|
_vibegram_last_wake: datetime | None = None
|
||||||
|
_vibegram_backlog: list[str] = []
|
||||||
|
|
||||||
|
VIBEGRAM_BRIEF = (
|
||||||
def vibegram_check() -> list[str]:
|
"Новое в вайбграме (комната {room}, ты там {nick}; остальные - чужие агенты):\n"
|
||||||
"""Заглушка до M7: детерминированная проверка вайбграма, пока ничего нового."""
|
"{items}\n"
|
||||||
return []
|
"Будить мастера - inject(master, резюме до 3 строк: кто, что, чего ждёт). "
|
||||||
|
"Мастер прочитает подробности через vibegram(read) и ответит через "
|
||||||
|
"vibegram(send), если решит."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def vibegram(run: JobRun) -> None:
|
async def vibegram(run: JobRun) -> None:
|
||||||
|
"""§8.5: проверка → triage → в мастер ≤ 1/ч, если не ждут ответа."""
|
||||||
global _vibegram_last_wake # noqa: PLW0603 - счётчик «≤ 1/ч», живёт до рестарта
|
global _vibegram_last_wake # noqa: PLW0603 - счётчик «≤ 1/ч», живёт до рестарта
|
||||||
new = vibegram_check()
|
if VIBEGRAM is None:
|
||||||
if not new:
|
return
|
||||||
|
events = await VIBEGRAM.pending()
|
||||||
|
_vibegram_backlog.extend(e.line() for e in events)
|
||||||
|
if not _vibegram_backlog:
|
||||||
return
|
return
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
if _vibegram_last_wake is not None and now - _vibegram_last_wake < timedelta(
|
addressed = VIBEGRAM.mentioned(events)
|
||||||
hours=1
|
if (
|
||||||
|
not addressed
|
||||||
|
and _vibegram_last_wake is not None
|
||||||
|
and now - _vibegram_last_wake < timedelta(hours=1)
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
_vibegram_last_wake = now
|
_vibegram_last_wake = now
|
||||||
|
items, _vibegram_backlog[:] = list(_vibegram_backlog), []
|
||||||
await run.spawn_job(
|
await run.spawn_job(
|
||||||
agent="beaver-triage",
|
agent="beaver-triage",
|
||||||
title="вайбграм",
|
title="вайбграм",
|
||||||
text="Новое в вайбграме:\n" + "\n".join(f"- {item}" for item in new),
|
text=VIBEGRAM_BRIEF.format(
|
||||||
|
room=VIBEGRAM_REPO.name,
|
||||||
|
nick=VIBEGRAM.nick,
|
||||||
|
items="\n".join(f"- {item}" for item in items[-40:]),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -635,10 +700,58 @@ async def t3code_event(run: JobRun) -> None:
|
|||||||
await run.inject_master(t3code_text(run.payload), urgency="urgent", origin="t3code")
|
await run.inject_master(t3code_text(run.payload), urgency="urgent", origin="t3code")
|
||||||
|
|
||||||
|
|
||||||
|
ALERTS = AlertMemory()
|
||||||
|
KOMODO_HINT = (
|
||||||
|
"Это флот Бобра; ему такие алерты приходят и так (healthbot), тебя будят, "
|
||||||
|
"когда через 5 минут само не встало. Посмотри komodo(status/logs), почини, "
|
||||||
|
"если очевидно (restart стека), иначе - коротко скажи Бобру через say, что "
|
||||||
|
"видишь. Не деплой и не чини «заодно»."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def komodo_alert(run: JobRun) -> None:
|
async def komodo_alert(run: JobRun) -> None:
|
||||||
|
"""§4.5 `/hooks/komodo`: флапы выдерживаются HOLD и перепроверяются."""
|
||||||
payload = run.payload
|
payload = run.payload
|
||||||
summary = payload.get("raw") or {k: v for k, v in payload.items() if k != "trigger"}
|
if payload.get("held"):
|
||||||
await run.inject_master(f"Komodo: {summary}", urgency="urgent", origin="komodo")
|
alert = parse(payload["alert"])
|
||||||
|
bad = await still_bad(KOMODO.read, alert) if KOMODO else True
|
||||||
|
verdict = ALERTS.after_hold(alert, still_bad=bad)
|
||||||
|
if verdict.action == "drop":
|
||||||
|
_log.info("komodo alert %s recovered during hold", alert.key)
|
||||||
|
return
|
||||||
|
ALERTS.mark_told(alert)
|
||||||
|
await run.inject_master(
|
||||||
|
f"Komodo: {describe(alert, held=HOLD)}\n{KOMODO_HINT}",
|
||||||
|
urgency=verdict.urgency,
|
||||||
|
origin="komodo",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
alert = parse(payload)
|
||||||
|
verdict = ALERTS.verdict(alert)
|
||||||
|
match verdict.action:
|
||||||
|
case "drop":
|
||||||
|
_log.info("komodo alert %s dropped (%s)", alert.key, alert.kind)
|
||||||
|
case "hold":
|
||||||
|
await run.scheduler.trigger(
|
||||||
|
run.job,
|
||||||
|
{
|
||||||
|
"held": True,
|
||||||
|
"alert": {k: v for k, v in payload.items() if k != "trigger"},
|
||||||
|
},
|
||||||
|
delay=HOLD,
|
||||||
|
trigger="hold",
|
||||||
|
)
|
||||||
|
case "resolved":
|
||||||
|
await run.inject_master(
|
||||||
|
f"Komodo: отбой - {describe(alert)}", urgency="normal", origin="komodo"
|
||||||
|
)
|
||||||
|
case "inject":
|
||||||
|
ALERTS.mark_told(alert)
|
||||||
|
await run.inject_master(
|
||||||
|
f"Komodo: {describe(alert)}\n{KOMODO_HINT}",
|
||||||
|
urgency=verdict.urgency,
|
||||||
|
origin="komodo",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
jobs = [
|
jobs = [
|
||||||
@@ -647,7 +760,8 @@ jobs = [
|
|||||||
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
||||||
Job("память", memory, cron="30 4 * * 0", critical=False),
|
Job("память", memory, cron="30 4 * * 0", critical=False),
|
||||||
Job("deploy", deploy, webhook=True),
|
Job("deploy", deploy, webhook=True),
|
||||||
Job("komodo", komodo_alert, webhook=True),
|
# алерты идут пачками (тревога и отбой подряд) - схлопывать нельзя
|
||||||
|
Job("komodo", komodo_alert, webhook=True, dedupe=False),
|
||||||
# события идут подряд (вопрос и завершение) - схлопывать нельзя
|
# события идут подряд (вопрос и завершение) - схлопывать нельзя
|
||||||
Job("t3code", t3code_event, webhook=True, dedupe=False),
|
Job("t3code", t3code_event, webhook=True, dedupe=False),
|
||||||
]
|
]
|
||||||
|
|||||||
+44
-17
@@ -55,19 +55,15 @@ services:
|
|||||||
- ./policy.py:/config/policy.py:ro
|
- ./policy.py:/config/policy.py:ro
|
||||||
- ./mcps:/config/mcps:ro
|
- ./mcps:/config/mcps:ro
|
||||||
- ./config.json:/config/config.json:ro
|
- ./config.json:/config/config.json:ro
|
||||||
# §3.7: vault только на чтение, rw - подмонтирования зон агента.
|
# §3.7: vault - дом диспетчера, rw целиком; границы (`.obsidian`,
|
||||||
- vault:/vault:ro
|
# `мета` вне `бобер`, удаление чужого) держит policy.py (2026-08-30).
|
||||||
- type: volume
|
- vault:/vault
|
||||||
source: vault
|
# клон репы комнаты вайбграма (сервис vibegram-repo), только читать
|
||||||
target: /vault/мета/бобер
|
- vibegram:/vibegram:ro
|
||||||
volume:
|
|
||||||
subpath: мета/бобер
|
|
||||||
- type: volume
|
|
||||||
source: vault
|
|
||||||
target: /vault/💬 чаты
|
|
||||||
volume:
|
|
||||||
subpath: 💬 чаты
|
|
||||||
- claude-home:/home/beaver-runner/.claude
|
- claude-home:/home/beaver-runner/.claude
|
||||||
|
extra_hosts:
|
||||||
|
# Home Assistant и прочее с network_mode: host
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:62990/healthz', timeout=3)"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:62990/healthz', timeout=3)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -83,11 +79,10 @@ services:
|
|||||||
mkdir -p /home/$$runner/.claude
|
mkdir -p /home/$$runner/.claude
|
||||||
[ -e /home/$$runner/.claude/.claude.json ] || echo '{}' > /home/$$runner/.claude/.claude.json
|
[ -e /home/$$runner/.claude/.claude.json ] || echo '{}' > /home/$$runner/.claude/.claude.json
|
||||||
chown -R $$runner /home/$$runner
|
chown -R $$runner /home/$$runner
|
||||||
for d in "/vault/мета/бобер" "/vault/💬 чаты"; do
|
# ACL, не chown: Obsidian Sync переписывает файлы под root, а default-ACL
|
||||||
setfacl -R -m "u:$$runner:rwX" -m "d:u:$$runner:rwX" "$$d" \
|
# на папках даёт runner'у права и на то, что появится позже.
|
||||||
|| chown -R $$runner "$$d" \
|
setfacl -R -m "u:$$runner:rwX" -m "d:u:$$runner:rwX" /vault \
|
||||||
|| echo "warning: cannot grant $$runner write access to $$d"
|
|| echo "warning: cannot grant $$runner write access to /vault"
|
||||||
done
|
|
||||||
exec python -m beaver_gateway
|
exec python -m beaver_gateway
|
||||||
|
|
||||||
t3code-mcp:
|
t3code-mcp:
|
||||||
@@ -105,9 +100,41 @@ services:
|
|||||||
- ./t3code.toml:/config/t3code.toml:ro
|
- ./t3code.toml:/config/t3code.toml:ro
|
||||||
- t3code-state:/data
|
- t3code-state:/data
|
||||||
|
|
||||||
|
vibegram-repo:
|
||||||
|
container_name: beaver-vibegram-repo
|
||||||
|
image: alpine/git:latest
|
||||||
|
profiles: [vibegram]
|
||||||
|
<<: *restart
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- vibegram:/vibegram
|
||||||
|
# Клон репы комнаты и pull раз в 10 минут. Токен - только заголовком
|
||||||
|
# через GIT_CONFIG_*, в .git/config он не попадает: том читает модель.
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
repo="$${VIBEGRAM_REPO:?VIBEGRAM_REPO=org/repo}"
|
||||||
|
dir="/vibegram/$${repo##*/}"
|
||||||
|
if [ -n "$$VIBEGRAM_REPO_TOKEN" ]; then
|
||||||
|
export GIT_CONFIG_COUNT=1
|
||||||
|
export GIT_CONFIG_KEY_0="http.https://github.com/.extraheader"
|
||||||
|
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $$(printf 'x-access-token:%s' "$$VIBEGRAM_REPO_TOKEN" | base64 | tr -d '\n')"
|
||||||
|
fi
|
||||||
|
while true; do
|
||||||
|
if [ -d "$$dir/.git" ]; then
|
||||||
|
git -C "$$dir" pull -q --ff-only || echo "vibegram-repo: pull failed"
|
||||||
|
else
|
||||||
|
git clone -q "https://github.com/$$repo.git" "$$dir" || echo "vibegram-repo: clone failed"
|
||||||
|
fi
|
||||||
|
chmod -R a+rX "$$dir" 2>/dev/null
|
||||||
|
sleep "$${VIBEGRAM_PULL_SECONDS:-600}"
|
||||||
|
done
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
t3code-state:
|
t3code-state:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
vault:
|
vault:
|
||||||
|
vibegram:
|
||||||
obsidian-config:
|
obsidian-config:
|
||||||
claude-home:
|
claude-home:
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""§4.4: Home Assistant как python_tool с перечислимыми действиями.
|
||||||
|
|
||||||
|
Токен HA живёт в gateway и в контекст модели не попадает. Действия:
|
||||||
|
найти сущности, посмотреть состояние, вызвать сервис, перечислить сервисы
|
||||||
|
домена. Ничего про конфигурацию, пользователей и интеграции - только
|
||||||
|
REST `/api/states` и `/api/services`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
__all__ = ["Action", "HomeAssistant"]
|
||||||
|
|
||||||
|
Action = Literal["search", "state", "call", "services"]
|
||||||
|
|
||||||
|
SEARCH_LIMIT = 60
|
||||||
|
ATTR_LIMIT = 25
|
||||||
|
# Не влезает в ответ и не нужно модели: гигантские списки и бинарники.
|
||||||
|
NOISY_ATTRS = frozenset({"entity_picture", "icon", "supported_color_modes", "options"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HomeAssistant:
|
||||||
|
url: str
|
||||||
|
token: str
|
||||||
|
timeout: float = 20.0
|
||||||
|
max_chars: int = 8_000
|
||||||
|
|
||||||
|
async def ha(
|
||||||
|
self,
|
||||||
|
action: Action,
|
||||||
|
*,
|
||||||
|
query: str | None = None,
|
||||||
|
entity: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
service: str | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Home Assistant - умный дом: свет, кнопки ИК-пульта, сцены, скрипты, датчики.
|
||||||
|
|
||||||
|
action:
|
||||||
|
- search: сущности по подстроке в id или имени (query; пусто - все),
|
||||||
|
сгруппированы по домену. Начни отсюда: точные entity_id нужны везде.
|
||||||
|
- state: состояние и атрибуты одной сущности (entity).
|
||||||
|
- call: вызвать сервис (domain, service, entity опционально, data -
|
||||||
|
доп. поля: brightness, color_temp, …). Примеры: light/turn_on,
|
||||||
|
light/turn_off, script/turn_on, scene/turn_on, button/press.
|
||||||
|
- services: какие сервисы есть у домена (domain) и их поля.
|
||||||
|
|
||||||
|
Делай то, о чём попросили: «выключи свет» - это одна сущность или
|
||||||
|
группа, не весь дом.
|
||||||
|
"""
|
||||||
|
match action:
|
||||||
|
case "search":
|
||||||
|
return await self._search(query or "")
|
||||||
|
case "state":
|
||||||
|
return await self._state(_need(entity, "entity"))
|
||||||
|
case "call":
|
||||||
|
return await self._call(
|
||||||
|
_need(domain, "domain"), _need(service, "service"), entity, data
|
||||||
|
)
|
||||||
|
case "services":
|
||||||
|
return await self._services(_need(domain, "domain"))
|
||||||
|
|
||||||
|
async def _search(self, query: str) -> str:
|
||||||
|
states = await self._get("/api/states")
|
||||||
|
q = query.lower()
|
||||||
|
hits = [
|
||||||
|
s
|
||||||
|
for s in states
|
||||||
|
if q in s["entity_id"].lower()
|
||||||
|
or q in str(s.get("attributes", {}).get("friendly_name", "")).lower()
|
||||||
|
]
|
||||||
|
if not hits:
|
||||||
|
return "ничего не нашлось"
|
||||||
|
by_domain: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for s in sorted(hits, key=lambda s: s["entity_id"]):
|
||||||
|
by_domain.setdefault(s["entity_id"].split(".", 1)[0], []).append(s)
|
||||||
|
lines = []
|
||||||
|
shown = 0
|
||||||
|
for dom, items in by_domain.items():
|
||||||
|
lines.append(f"{dom}:")
|
||||||
|
for s in items:
|
||||||
|
if shown >= SEARCH_LIMIT:
|
||||||
|
lines.append(f" … ещё {len(hits) - shown}, уточни query")
|
||||||
|
return "\n".join(lines)
|
||||||
|
name = s.get("attributes", {}).get("friendly_name") or ""
|
||||||
|
lines.append(f" {s['entity_id']:40} {s['state']:12} {name}")
|
||||||
|
shown += 1
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def _state(self, entity: str) -> str:
|
||||||
|
s = await self._get(f"/api/states/{entity}")
|
||||||
|
attrs = {
|
||||||
|
k: v for k, v in (s.get("attributes") or {}).items() if k not in NOISY_ATTRS
|
||||||
|
}
|
||||||
|
lines = [f"{s['entity_id']}: {s['state']} (с {s.get('last_changed', '?')})"]
|
||||||
|
for k, v in list(attrs.items())[:ATTR_LIMIT]:
|
||||||
|
lines.append(f" {k}: {json.dumps(v, ensure_ascii=False)[:200]}")
|
||||||
|
return self._clip("\n".join(lines))
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
self, domain: str, service: str, entity: str | None, data: dict[str, Any] | None
|
||||||
|
) -> str:
|
||||||
|
body: dict[str, Any] = dict(data or {})
|
||||||
|
if entity:
|
||||||
|
body["entity_id"] = entity
|
||||||
|
changed = await self._post(f"/api/services/{domain}/{service}", body)
|
||||||
|
if not changed:
|
||||||
|
return f"{domain}.{service}: выполнено, состояния не менялись"
|
||||||
|
lines = [f"{domain}.{service}: выполнено, изменилось:"]
|
||||||
|
lines.extend(f" {s['entity_id']}: {s['state']}" for s in changed[:20])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
async def _services(self, domain: str) -> str:
|
||||||
|
for block in await self._get("/api/services"):
|
||||||
|
if block.get("domain") == domain:
|
||||||
|
lines = [f"{domain}:"]
|
||||||
|
for name, spec in sorted((block.get("services") or {}).items()):
|
||||||
|
fields = ", ".join((spec.get("fields") or {}).keys())
|
||||||
|
lines.append(
|
||||||
|
f" {name}: {spec.get('description') or ''}".rstrip()
|
||||||
|
+ (f" [{fields}]" if fields else "")
|
||||||
|
)
|
||||||
|
return self._clip("\n".join(lines))
|
||||||
|
return f"домена {domain} нет"
|
||||||
|
|
||||||
|
async def _get(self, path: str) -> Any:
|
||||||
|
return await self._request("GET", path, None)
|
||||||
|
|
||||||
|
async def _post(self, path: str, body: dict[str, Any]) -> Any:
|
||||||
|
return await self._request("POST", path, body)
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self, method: str, path: str, body: dict[str, Any] | None
|
||||||
|
) -> Any:
|
||||||
|
headers = {"Authorization": f"Bearer {self.token}"}
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession() as http,
|
||||||
|
http.request(
|
||||||
|
method,
|
||||||
|
f"{self.url.rstrip('/')}{path}",
|
||||||
|
json=body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
||||||
|
) as response,
|
||||||
|
):
|
||||||
|
text = await response.text()
|
||||||
|
if response.status >= 400:
|
||||||
|
msg = f"Home Assistant {method} {path}: {response.status} {text[:300]}"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return json.loads(text) if text else None
|
||||||
|
|
||||||
|
def _clip(self, text: str) -> str:
|
||||||
|
if len(text) <= self.max_chars:
|
||||||
|
return text
|
||||||
|
return text[: self.max_chars] + "\n…[обрезано]"
|
||||||
|
|
||||||
|
|
||||||
|
def _need(value: str | None, name: str) -> str:
|
||||||
|
if not value:
|
||||||
|
msg = f"для этого действия нужен параметр {name}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""§4.5: алерты Komodo для диспетчера - что доехать до мастера, когда и как.
|
||||||
|
|
||||||
|
Вебхук `/hooks/komodo` получает всё, что шлёт Komodo (типы алертов у
|
||||||
|
алертера не перечислены намеренно). Сюда мастеру нужна малая часть, и не
|
||||||
|
сразу: `ServerUnreachable` и смены состояния стеков - флап, который через
|
||||||
|
минуту сам отбивается («отбой» от Komodo приходит следом), а срочный
|
||||||
|
инжект прерывает тёрн и стоит токенов. Поэтому такие алерты выдерживаются
|
||||||
|
(`HOLD`), и по истечении выдержки состояние перепроверяется по API: если
|
||||||
|
всё уже running - алерт молча выбрасывается; если нет - мастер получает
|
||||||
|
срочный инжект. Информационные типы (обновления образов, расписания,
|
||||||
|
дрейф синка) не доезжают вообще - их читает Бобёр в healthbot.
|
||||||
|
|
||||||
|
Komodo создаёт `StackStateChange`/`ContainerStateChange` сразу с
|
||||||
|
`resolved=true` (`resolved_ts == ts`): это факт «состояние сменилось», а
|
||||||
|
не тревога, которую снимают. У них смотрим на `to`, не на `resolved`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
__all__ = ["HOLD", "Alert", "AlertMemory", "Verdict", "describe", "parse", "still_bad"]
|
||||||
|
|
||||||
|
HOLD = timedelta(minutes=5)
|
||||||
|
|
||||||
|
# Не для диспетчера: Бобёр видит их в healthbot, действия по ним - его.
|
||||||
|
INFORMATIONAL = frozenset(
|
||||||
|
{
|
||||||
|
"StackImageUpdateAvailable",
|
||||||
|
"DeploymentImageUpdateAvailable",
|
||||||
|
"StackAutoUpdated",
|
||||||
|
"DeploymentAutoUpdated",
|
||||||
|
"ResourceSyncPendingUpdates",
|
||||||
|
"ScheduleRun",
|
||||||
|
"Test",
|
||||||
|
"Custom",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Флапает: ждать HOLD и перепроверить, прежде чем будить мастера.
|
||||||
|
FLAPPY = frozenset(
|
||||||
|
{"ServerUnreachable", "StackStateChange", "ContainerStateChange", "ServerCpu"}
|
||||||
|
)
|
||||||
|
STATE_CHANGE = frozenset({"StackStateChange", "ContainerStateChange"})
|
||||||
|
HEALTHY_STATES = frozenset({"running", "healthy"})
|
||||||
|
FAILURE = frozenset(
|
||||||
|
{"ProcedureFailed", "ActionFailed", "BuildFailed", "RepoBuildFailed"}
|
||||||
|
)
|
||||||
|
|
||||||
|
Action = Literal["drop", "hold", "inject", "resolved"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Alert:
|
||||||
|
kind: str
|
||||||
|
level: str
|
||||||
|
resolved: bool
|
||||||
|
name: str
|
||||||
|
server: str | None
|
||||||
|
data: dict[str, Any]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"{self.kind}:{self.server or '-'}:{self.name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad(self) -> bool:
|
||||||
|
"""Тревога, а не снятие.
|
||||||
|
|
||||||
|
У смен состояния - по `to`, у прочих - по `resolved`.
|
||||||
|
"""
|
||||||
|
if self.kind in STATE_CHANGE:
|
||||||
|
return str(self.data.get("to", "")).lower() not in HEALTHY_STATES
|
||||||
|
return not self.resolved
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Verdict:
|
||||||
|
action: Action
|
||||||
|
urgency: Literal["urgent", "normal"] = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AlertMemory:
|
||||||
|
"""Что выдерживается и о чём мастеру уже сказали.
|
||||||
|
|
||||||
|
Повторы во время выдержки не дублируются, а «отбой» доезжает только
|
||||||
|
после тревоги, которую мастер видел.
|
||||||
|
"""
|
||||||
|
|
||||||
|
holding: set[str] = field(default_factory=set)
|
||||||
|
told: dict[str, datetime] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def verdict(self, alert: Alert) -> Verdict:
|
||||||
|
if alert.kind in INFORMATIONAL:
|
||||||
|
return Verdict("drop")
|
||||||
|
if not alert.bad:
|
||||||
|
if alert.key in self.holding:
|
||||||
|
# Снялось, пока ждали: ни тревоги, ни отбоя.
|
||||||
|
self.holding.discard(alert.key)
|
||||||
|
return Verdict("drop")
|
||||||
|
if self.told.pop(alert.key, None) is not None:
|
||||||
|
return Verdict("resolved")
|
||||||
|
return Verdict("drop")
|
||||||
|
if alert.kind in FLAPPY:
|
||||||
|
if alert.key in self.holding:
|
||||||
|
return Verdict("drop")
|
||||||
|
self.holding.add(alert.key)
|
||||||
|
return Verdict("hold")
|
||||||
|
return Verdict("inject", "normal")
|
||||||
|
|
||||||
|
def after_hold(self, alert: Alert, *, still_bad: bool) -> Verdict:
|
||||||
|
self.holding.discard(alert.key)
|
||||||
|
if not still_bad:
|
||||||
|
return Verdict("drop")
|
||||||
|
return Verdict("inject", "urgent")
|
||||||
|
|
||||||
|
def mark_told(self, alert: Alert) -> None:
|
||||||
|
self.told[alert.key] = datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(payload: dict[str, Any]) -> Alert:
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
inner = data.get("data") if isinstance(data, dict) else None
|
||||||
|
inner = inner if isinstance(inner, dict) else {}
|
||||||
|
return Alert(
|
||||||
|
kind=str(data.get("type") or "Unknown")
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else "Unknown",
|
||||||
|
level=str(payload.get("level") or "").upper(),
|
||||||
|
resolved=bool(payload.get("resolved")),
|
||||||
|
name=str(inner.get("name") or inner.get("id") or "?"),
|
||||||
|
server=inner.get("server_name"),
|
||||||
|
data=inner,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gb(value: Any) -> str:
|
||||||
|
try:
|
||||||
|
return f"{float(value):.1f} ГБ"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(value: Any) -> str:
|
||||||
|
try:
|
||||||
|
return f"{float(value):.0f}%"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(value: Any) -> str:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return str(value.get("error") or value)
|
||||||
|
return str(value or "без деталей")
|
||||||
|
|
||||||
|
|
||||||
|
def describe(alert: Alert, *, held: timedelta | None = None) -> str:
|
||||||
|
"""Одна-две строки для инжекта: что, где, с каких пор."""
|
||||||
|
d = alert.data
|
||||||
|
name, server = alert.name, alert.server
|
||||||
|
where = f" на {server}" if server else ""
|
||||||
|
since = f" уже {int(held.total_seconds() // 60)} мин" if held else ""
|
||||||
|
match alert.kind:
|
||||||
|
case "ServerUnreachable":
|
||||||
|
return f"сервер {name} не отвечает{since}: {_err(d.get('err'))}"
|
||||||
|
case "StackStateChange":
|
||||||
|
return f"стек {name}{where}: {d.get('from')} → {d.get('to')}{since}"
|
||||||
|
case "ContainerStateChange":
|
||||||
|
return f"контейнер {name}{where}: {d.get('from')} → {d.get('to')}{since}"
|
||||||
|
case "ServerCpu":
|
||||||
|
return f"CPU на {name}: {_pct(d.get('percentage'))}{since}"
|
||||||
|
case "ServerMem":
|
||||||
|
return (
|
||||||
|
f"память на {name}: {_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}"
|
||||||
|
)
|
||||||
|
case "ServerDisk":
|
||||||
|
return (
|
||||||
|
f"диск на {name} ({d.get('path', '/')}): "
|
||||||
|
f"{_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}"
|
||||||
|
)
|
||||||
|
case "ServerVersionMismatch":
|
||||||
|
return (
|
||||||
|
f"версии разъехались на {name}: periphery {d.get('version')}, "
|
||||||
|
f"core {d.get('core_version')} - чинится только по ssh, это к Бобру"
|
||||||
|
)
|
||||||
|
case k if k in FAILURE:
|
||||||
|
what = {"ProcedureFailed": "процедура", "ActionFailed": "действие"}.get(
|
||||||
|
k, "сборка"
|
||||||
|
)
|
||||||
|
return f"{what} {name} упала - подробности в komodo(updates)"
|
||||||
|
case _:
|
||||||
|
rest = ", ".join(
|
||||||
|
f"{k}={v}" for k, v in d.items() if k not in {"id", "server_id"}
|
||||||
|
)
|
||||||
|
return f"{alert.kind} {name}: {rest}"[:400]
|
||||||
|
|
||||||
|
|
||||||
|
async def still_bad(read: Any, alert: Alert) -> bool:
|
||||||
|
"""Перепроверка по API после выдержки; при ошибке считаем, что тревога в силе."""
|
||||||
|
try:
|
||||||
|
match alert.kind:
|
||||||
|
case "ServerUnreachable" | "ServerCpu":
|
||||||
|
servers = await read("ListServers", {})
|
||||||
|
for s in servers:
|
||||||
|
if s["name"] == alert.name:
|
||||||
|
return s["info"]["state"] != "Ok"
|
||||||
|
return True
|
||||||
|
case "StackStateChange":
|
||||||
|
stacks = await read("ListStacks", {})
|
||||||
|
for s in stacks:
|
||||||
|
if s["name"] == alert.name:
|
||||||
|
return s["info"]["state"] not in HEALTHY_STATES
|
||||||
|
return True
|
||||||
|
case "ContainerStateChange":
|
||||||
|
if not alert.server:
|
||||||
|
return True
|
||||||
|
containers = await read(
|
||||||
|
"ListDockerContainers", {"server": alert.server}
|
||||||
|
)
|
||||||
|
for c in containers:
|
||||||
|
if c["name"] == alert.name:
|
||||||
|
return str(c.get("state", "")).lower() not in HEALTHY_STATES
|
||||||
|
return True
|
||||||
|
case _:
|
||||||
|
return True
|
||||||
|
except Exception: # noqa: BLE001 - недоступный Komodo сам по себе тревога
|
||||||
|
return True
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""§4.4, §8.5, §12.D1: вайбграм - комната агентов на хабе, как python_tool.
|
||||||
|
|
||||||
|
Хаб - https://vibegram.example.com, протокол - REST с bearer-токеном агента
|
||||||
|
(выдан при `vibegram join`, лежит в env gateway и в контекст модели не
|
||||||
|
попадает). Диспетчер сидит в комнате сам (D1: внутренний контур с соседями),
|
||||||
|
но через перечислимые действия: прочитать новое, написать, кто в комнате,
|
||||||
|
что занято, план, claim/release, карточка. Один и тот же клиент кормит
|
||||||
|
крон-проверку (`pending`) и тулзу (`vibegram`).
|
||||||
|
|
||||||
|
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
|
||||||
|
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
||||||
|
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
__all__ = ["Action", "Event", "Vibegram"]
|
||||||
|
|
||||||
|
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
|
||||||
|
|
||||||
|
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
|
||||||
|
REMEMBER = 40
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Event:
|
||||||
|
id: int
|
||||||
|
nick: str
|
||||||
|
kind: str
|
||||||
|
text: str
|
||||||
|
at: str
|
||||||
|
|
||||||
|
def line(self) -> str:
|
||||||
|
return f"[{self.at}] {self.text}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Vibegram:
|
||||||
|
hub: str
|
||||||
|
token: str
|
||||||
|
nick: str
|
||||||
|
repo: Path | None = None
|
||||||
|
timeout: float = 15.0
|
||||||
|
max_chars: int = 8_000
|
||||||
|
_cursor: int = 0
|
||||||
|
_recent: deque[Event] = field(default_factory=lambda: deque(maxlen=REMEMBER))
|
||||||
|
|
||||||
|
async def vibegram(
|
||||||
|
self,
|
||||||
|
action: Action,
|
||||||
|
*,
|
||||||
|
text: str | None = None,
|
||||||
|
paths: list[str] | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
about: str | None = None,
|
||||||
|
skills: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
||||||
|
|
||||||
|
action:
|
||||||
|
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
||||||
|
нового нет - последние уже виденные события.
|
||||||
|
- send: написать в комнату (text). Это доска объявлений, не чат:
|
||||||
|
пиши, когда добавляешь информацию; адресовать - @ник.
|
||||||
|
- who: кто в комнате, что умеет, что держит.
|
||||||
|
- work: свободные пункты плана, занятые файлы, кто что делает.
|
||||||
|
- plan: общий план комнаты.
|
||||||
|
- claim / release: занять / отпустить файлы репы (paths относительно
|
||||||
|
корня репы, note - что делаешь). Перед правкой - claim, после - release.
|
||||||
|
- card: рассказать о себе (about, skills).
|
||||||
|
|
||||||
|
Наружу личного нет: имена из `👤 люди/`, содержимое `мета/бобер/` и
|
||||||
|
`📅 дни/` в комнату не уходят - только рабочее.
|
||||||
|
"""
|
||||||
|
match action:
|
||||||
|
case "read":
|
||||||
|
return await self._read()
|
||||||
|
case "send":
|
||||||
|
return await self._send(_need(text, "text"))
|
||||||
|
case "who":
|
||||||
|
return _cards((await self._get("/api/cards"))["cards"], self.nick)
|
||||||
|
case "work":
|
||||||
|
return _work(await self._get("/api/work"), self.nick)
|
||||||
|
case "plan":
|
||||||
|
return _plan(await self._get("/api/plan"))
|
||||||
|
case "claim":
|
||||||
|
result = await self._post(
|
||||||
|
"/api/claims", {"resources": _need_list(paths), "note": note}
|
||||||
|
)
|
||||||
|
if result.get("ok"):
|
||||||
|
held = ", ".join(c["resource"] for c in result.get("claims", []))
|
||||||
|
return f"занято: {held}"
|
||||||
|
return "\n".join(
|
||||||
|
f"{c['resource']} держит {c['heldBy']}"
|
||||||
|
+ (f" ({c['note']})" if c.get("note") else "")
|
||||||
|
+ " - напиши ему через send, не обходи"
|
||||||
|
for c in result.get("conflicts", [])
|
||||||
|
)
|
||||||
|
case "release":
|
||||||
|
body = {"resources": paths} if paths else {}
|
||||||
|
result = await self._post("/api/claims/release", body)
|
||||||
|
released = result.get("released") or []
|
||||||
|
return (
|
||||||
|
"отпущено: " + ", ".join(released)
|
||||||
|
if released
|
||||||
|
else "нечего отпускать"
|
||||||
|
)
|
||||||
|
case "card":
|
||||||
|
patch: dict[str, Any] = {}
|
||||||
|
if about:
|
||||||
|
patch["description"] = about
|
||||||
|
if skills:
|
||||||
|
patch["skills"] = skills
|
||||||
|
result = await self._post("/api/card", patch)
|
||||||
|
return _cards([result["card"]], self.nick)
|
||||||
|
|
||||||
|
async def pending(self, limit: int = 50) -> list[Event]:
|
||||||
|
"""Новые события с курсора хаба (для крона); запоминает их."""
|
||||||
|
data = await self._get(f"/api/pending?limit={limit}")
|
||||||
|
events = [e for e in map(_event, data.get("events", [])) if e is not None]
|
||||||
|
fresh = [e for e in events if e.id > self._cursor]
|
||||||
|
for e in fresh:
|
||||||
|
self._recent.append(e)
|
||||||
|
self._cursor = max(self._cursor, e.id)
|
||||||
|
ack = data.get("planAckNeeded")
|
||||||
|
if ack is not None:
|
||||||
|
fresh.append(
|
||||||
|
Event(
|
||||||
|
id=self._cursor,
|
||||||
|
nick="system",
|
||||||
|
kind="plan",
|
||||||
|
text=f"план изменился (ревизия {ack}), ты его не подтвердил",
|
||||||
|
at=_now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
def mentioned(self, events: list[Event]) -> bool:
|
||||||
|
needle = f"@{self.nick}".lower()
|
||||||
|
return any(needle in e.text.lower() for e in events)
|
||||||
|
|
||||||
|
async def _read(self) -> str:
|
||||||
|
fresh = await self.pending()
|
||||||
|
if fresh:
|
||||||
|
return self._clip("\n".join(e.line() for e in fresh))
|
||||||
|
if self._recent:
|
||||||
|
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
|
||||||
|
return f"нового нет; последнее, что было:\n{tail}"
|
||||||
|
return "нового нет"
|
||||||
|
|
||||||
|
async def _send(self, text: str) -> str:
|
||||||
|
try:
|
||||||
|
await self._post("/api/messages", {"body": text})
|
||||||
|
except HubError as exc:
|
||||||
|
if exc.code == "rate_limited":
|
||||||
|
return f"хаб притормозил: {exc}"
|
||||||
|
raise
|
||||||
|
return "отправлено"
|
||||||
|
|
||||||
|
async def _get(self, path: str) -> Any:
|
||||||
|
return await self._call("GET", path, None)
|
||||||
|
|
||||||
|
async def _post(self, path: str, body: dict[str, Any]) -> Any:
|
||||||
|
return await self._call("POST", path, body)
|
||||||
|
|
||||||
|
async def _call(self, method: str, path: str, body: dict[str, Any] | None) -> Any:
|
||||||
|
headers = {"authorization": f"Bearer {self.token}", "user-agent": USER_AGENT}
|
||||||
|
async with (
|
||||||
|
aiohttp.ClientSession() as http,
|
||||||
|
http.request(
|
||||||
|
method,
|
||||||
|
f"{self.hub.rstrip('/')}{path}",
|
||||||
|
json=body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
||||||
|
) as response,
|
||||||
|
):
|
||||||
|
raw = await response.text()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
data = {"error": "bad_json", "message": raw[:200]}
|
||||||
|
if response.status >= 400:
|
||||||
|
raise HubError(
|
||||||
|
response.status,
|
||||||
|
str(data.get("error") or "error"),
|
||||||
|
str(data.get("message") or data.get("error") or raw[:200]),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _clip(self, text: str) -> str:
|
||||||
|
if len(text) <= self.max_chars:
|
||||||
|
return text
|
||||||
|
return text[: self.max_chars] + "\n…[обрезано]"
|
||||||
|
|
||||||
|
|
||||||
|
class HubError(RuntimeError):
|
||||||
|
def __init__(self, status: int, code: str, message: str) -> None:
|
||||||
|
super().__init__(f"vibegram {status} {code}: {message}")
|
||||||
|
self.status = status
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(UTC).strftime("%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _when(iso: Any) -> str:
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(iso)).strftime("%m-%d %H:%M")
|
||||||
|
except ValueError:
|
||||||
|
return _now()
|
||||||
|
|
||||||
|
|
||||||
|
def _event(raw: dict[str, Any]) -> Event | None:
|
||||||
|
who = raw.get("nick") or "system"
|
||||||
|
kind = str(raw.get("kind") or "")
|
||||||
|
p = raw.get("payload") or {}
|
||||||
|
match kind:
|
||||||
|
case "message":
|
||||||
|
text = f"{who}: {p.get('body', '')}"
|
||||||
|
case "claim":
|
||||||
|
text = f"{who} занял: {', '.join(p.get('resources', []))}"
|
||||||
|
case "release":
|
||||||
|
text = f"{who} отпустил: {', '.join(p.get('resources', []))}"
|
||||||
|
case "claim_denied":
|
||||||
|
text = f"{who} пытался занять то, что держишь ты - возможно, ждёт тебя"
|
||||||
|
case "violation":
|
||||||
|
text = f"{who} полез в файл, который держишь ты"
|
||||||
|
case "plan_change":
|
||||||
|
text = f"{who}: план - {p.get('summary', '')}"
|
||||||
|
case "agent_join":
|
||||||
|
text = f"{who} вошёл"
|
||||||
|
case "agent_leave":
|
||||||
|
text = f"{who} вышел"
|
||||||
|
case _:
|
||||||
|
return None
|
||||||
|
return Event(
|
||||||
|
id=int(raw.get("id") or 0),
|
||||||
|
nick=str(who),
|
||||||
|
kind=kind,
|
||||||
|
text=text,
|
||||||
|
at=_when(raw.get("createdAt")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cards(cards: list[dict[str, Any]], me: str) -> str:
|
||||||
|
if not cards:
|
||||||
|
return "в комнате никого"
|
||||||
|
lines = []
|
||||||
|
for c in cards:
|
||||||
|
mark = "●" if c.get("status") == "online" else "○"
|
||||||
|
head = f"{mark} {c['nick']}" + (" (это ты)" if c["nick"] == me else "")
|
||||||
|
about = " · ".join(
|
||||||
|
x for x in (c.get("description"), ", ".join(c.get("skills") or [])) if x
|
||||||
|
)
|
||||||
|
lines.append(f"{head} - {about}" if about else head)
|
||||||
|
focus = c.get("focus") or {}
|
||||||
|
if focus.get("planItem"):
|
||||||
|
lines.append(f" делает: {focus['planItem'].get('text')}")
|
||||||
|
if focus.get("holding"):
|
||||||
|
lines.append(f" держит: {', '.join(focus['holding'])}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _work(snapshot: dict[str, Any], me: str) -> str:
|
||||||
|
parts = []
|
||||||
|
free = snapshot.get("free") or []
|
||||||
|
taken = snapshot.get("taken") or []
|
||||||
|
busy = snapshot.get("busy") or []
|
||||||
|
if free:
|
||||||
|
parts.append(
|
||||||
|
"свободные пункты плана:\n"
|
||||||
|
+ "\n".join(f" {i + 1}. {it['text']}" for i, it in enumerate(free))
|
||||||
|
)
|
||||||
|
elif not snapshot.get("planRevision"):
|
||||||
|
parts.append("плана в комнате пока нет")
|
||||||
|
else:
|
||||||
|
parts.append("свободных пунктов плана нет")
|
||||||
|
if taken:
|
||||||
|
parts.append(
|
||||||
|
"занято:\n"
|
||||||
|
+ "\n".join(f" - {it['text']} ({it.get('ownerNick')})" for it in taken)
|
||||||
|
)
|
||||||
|
if busy:
|
||||||
|
parts.append(
|
||||||
|
"файлы:\n"
|
||||||
|
+ "\n".join(
|
||||||
|
f" - {b['resource']} у {b['nick']}"
|
||||||
|
+ (f" ({b['note']})" if b.get("note") else "")
|
||||||
|
for b in busy
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cards = [c for c in snapshot.get("cards") or [] if c["nick"] != me]
|
||||||
|
if cards:
|
||||||
|
parts.append("кто что делает:\n" + _cards(cards, me))
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(plan: dict[str, Any]) -> str:
|
||||||
|
items = plan.get("items") or []
|
||||||
|
if not items:
|
||||||
|
return "плана нет"
|
||||||
|
lines = [
|
||||||
|
f"{i + 1}. [{it.get('status')}] {it['text']}"
|
||||||
|
+ (f" - {it['ownerNick']}" if it.get("ownerNick") else "")
|
||||||
|
for i, it in enumerate(items)
|
||||||
|
]
|
||||||
|
notes = (plan.get("notes") or {}).get("body")
|
||||||
|
if notes:
|
||||||
|
lines.append(f"заметки: {notes}")
|
||||||
|
return f"план v{plan.get('revision')}:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _need(value: str | None, name: str) -> str:
|
||||||
|
if not value:
|
||||||
|
msg = f"для этого действия нужен параметр {name}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _need_list(value: list[str] | None) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
msg = "для этого действия нужен параметр paths"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
@@ -67,6 +67,7 @@ MUTATING = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
COPYING = frozenset({"cp", "install", "rsync"})
|
COPYING = frozenset({"cp", "install", "rsync"})
|
||||||
|
DELETING = frozenset({"rm", "rmdir", "unlink", "shred", "dd", "truncate"})
|
||||||
INPLACE = frozenset({"sed", "perl"})
|
INPLACE = frozenset({"sed", "perl"})
|
||||||
REDIRECTS = frozenset({">", ">>", ">|", "&>", "&>>", ">&"})
|
REDIRECTS = frozenset({">", ">>", ">|", "&>", "&>>", ">&"})
|
||||||
SEPARATORS = frozenset({";", "&&", "||", "|", "&", "(", ")"})
|
SEPARATORS = frozenset({";", "&&", "||", "|", "&", "(", ")"})
|
||||||
@@ -74,18 +75,36 @@ SEPARATORS = frozenset({";", "&&", "||", "|", "&", "(", ")"})
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Zones:
|
class Zones:
|
||||||
"""Зоны vault: ``write`` - полная запись, ``create`` - только новые файлы."""
|
"""Зоны vault.
|
||||||
|
|
||||||
|
``write`` - всё, включая удаление; ``create`` - только новые файлы;
|
||||||
|
``edit`` - создавать, править, переносить можно, удалять - нет;
|
||||||
|
``protected`` - не трогать. Приоритет: write > protected > create > edit;
|
||||||
|
путь в vault вне всех зон - отказ. Vault целиком в ``edit`` - это
|
||||||
|
«дом» диспетчера (решение h, 2026-08-30): заметки, задачи, люди - его;
|
||||||
|
удаление чужого и `.obsidian`/`мета` остаются за Бобром.
|
||||||
|
"""
|
||||||
|
|
||||||
vault: Path
|
vault: Path
|
||||||
write: tuple[Path, ...]
|
write: tuple[Path, ...]
|
||||||
create: tuple[Path, ...]
|
create: tuple[Path, ...] = ()
|
||||||
|
edit: tuple[Path, ...] = ()
|
||||||
|
protected: tuple[Path, ...] = ()
|
||||||
|
|
||||||
def verdict(self, path: Path, *, creating: bool) -> Deny | None:
|
def verdict(
|
||||||
|
self, path: Path, *, creating: bool, deleting: bool = False
|
||||||
|
) -> Deny | None:
|
||||||
if not _under(path, self.vault):
|
if not _under(path, self.vault):
|
||||||
return None
|
return None
|
||||||
if any(_under(path, zone) for zone in self.write):
|
if any(_under(path, zone) for zone in self.write):
|
||||||
return None
|
return None
|
||||||
rel = path.relative_to(self.vault)
|
rel = path.relative_to(self.vault)
|
||||||
|
for zone in self.protected:
|
||||||
|
if _under(path, zone):
|
||||||
|
return Deny(
|
||||||
|
reason=f"{rel}: «{zone.relative_to(self.vault)}» не трогаем - "
|
||||||
|
"это зона Бобра"
|
||||||
|
)
|
||||||
for zone in self.create:
|
for zone in self.create:
|
||||||
if _under(path, zone):
|
if _under(path, zone):
|
||||||
if creating and not path.exists():
|
if creating and not path.exists():
|
||||||
@@ -94,6 +113,17 @@ class Zones:
|
|||||||
reason=f"{rel}: в «{zone.relative_to(self.vault)}» можно только "
|
reason=f"{rel}: в «{zone.relative_to(self.vault)}» можно только "
|
||||||
"создавать новые файлы, существующие не трогаем"
|
"создавать новые файлы, существующие не трогаем"
|
||||||
)
|
)
|
||||||
|
for zone in self.edit:
|
||||||
|
if _under(path, zone):
|
||||||
|
if deleting:
|
||||||
|
zones = ", ".join(
|
||||||
|
f"«{z.relative_to(self.vault)}»" for z in self.write
|
||||||
|
)
|
||||||
|
return Deny(
|
||||||
|
reason=f"{rel}: удалять можно только в {zones}; чужие "
|
||||||
|
"заметки не удаляем - скажи Бобру, он сам"
|
||||||
|
)
|
||||||
|
return None
|
||||||
zones = ", ".join(f"«{z.relative_to(self.vault)}»" for z in self.write)
|
zones = ", ".join(f"«{z.relative_to(self.vault)}»" for z in self.write)
|
||||||
return Deny(reason=f"{rel}: vault только на чтение; писать можно в {zones}")
|
return Deny(reason=f"{rel}: vault только на чтение; писать можно в {zones}")
|
||||||
|
|
||||||
@@ -125,8 +155,10 @@ def bash_zones(zones: Zones) -> PolicyRule:
|
|||||||
command = call.input.get("command")
|
command = call.input.get("command")
|
||||||
if not isinstance(command, str):
|
if not isinstance(command, str):
|
||||||
return None
|
return None
|
||||||
for target, creating in _bash_targets(command):
|
for target, creating, deleting in _bash_targets(command):
|
||||||
deny = zones.verdict(call.resolve(target), creating=creating)
|
deny = zones.verdict(
|
||||||
|
call.resolve(target), creating=creating, deleting=deleting
|
||||||
|
)
|
||||||
if deny is not None:
|
if deny is not None:
|
||||||
return Deny(reason=f"Bash: {deny.reason}")
|
return Deny(reason=f"Bash: {deny.reason}")
|
||||||
return None
|
return None
|
||||||
@@ -134,7 +166,8 @@ def bash_zones(zones: Zones) -> PolicyRule:
|
|||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
def _bash_targets(command: str) -> Iterator[tuple[str, bool]]:
|
def _bash_targets(command: str) -> Iterator[tuple[str, bool, bool]]:
|
||||||
|
"""``(путь, создаёт, удаляет)`` для каждого пути, который команда трогает."""
|
||||||
for segment in _segments(command):
|
for segment in _segments(command):
|
||||||
plain: list[str] = []
|
plain: list[str] = []
|
||||||
i = 0
|
i = 0
|
||||||
@@ -142,7 +175,7 @@ def _bash_targets(command: str) -> Iterator[tuple[str, bool]]:
|
|||||||
word = segment[i]
|
word = segment[i]
|
||||||
if word in REDIRECTS:
|
if word in REDIRECTS:
|
||||||
if i + 1 < len(segment) and _pathlike(segment[i + 1]):
|
if i + 1 < len(segment) and _pathlike(segment[i + 1]):
|
||||||
yield segment[i + 1], True
|
yield segment[i + 1], True, False
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
plain.append(word)
|
plain.append(word)
|
||||||
@@ -156,7 +189,7 @@ def _bash_targets(command: str) -> Iterator[tuple[str, bool]]:
|
|||||||
if word in COPYING:
|
if word in COPYING:
|
||||||
args = args[-1:]
|
args = args[-1:]
|
||||||
for arg in args:
|
for arg in args:
|
||||||
yield arg, False
|
yield arg, False, word in DELETING
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from mcps.komodo_alerts import HOLD, AlertMemory, describe, parse, still_bad
|
||||||
|
|
||||||
|
|
||||||
|
def alert(kind, *, resolved=False, level="CRITICAL", **data):
|
||||||
|
data.setdefault("name", "ms-agents-proxy-aeza")
|
||||||
|
return {"level": level, "resolved": resolved, "data": {"type": kind, "data": data}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_change_is_one_shot_and_read_by_target_state():
|
||||||
|
up = parse(
|
||||||
|
alert(
|
||||||
|
"StackStateChange", resolved=True, **{"from": "running", "to": "unhealthy"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
down = parse(
|
||||||
|
alert(
|
||||||
|
"StackStateChange", resolved=True, **{"from": "unhealthy", "to": "running"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert up.bad and not down.bad
|
||||||
|
assert up.key == down.key
|
||||||
|
|
||||||
|
|
||||||
|
def test_flappy_alert_is_held_then_dropped_or_injected():
|
||||||
|
mem = AlertMemory()
|
||||||
|
first = parse(
|
||||||
|
alert("ServerUnreachable", err={"error": "Timed out waiting for Ping"})
|
||||||
|
)
|
||||||
|
assert mem.verdict(first).action == "hold"
|
||||||
|
# повтор той же тревоги, пока держим - тишина
|
||||||
|
assert mem.verdict(first).action == "drop"
|
||||||
|
# отбой во время выдержки - тоже тишина, и память чистая
|
||||||
|
assert (
|
||||||
|
mem.verdict(parse(alert("ServerUnreachable", resolved=True))).action == "drop"
|
||||||
|
)
|
||||||
|
assert not mem.holding and not mem.told
|
||||||
|
# выдержка истекла, всё ещё плохо - срочно, и потом отбой доезжает
|
||||||
|
mem.verdict(first)
|
||||||
|
late = mem.after_hold(first, still_bad=True)
|
||||||
|
assert (late.action, late.urgency) == ("inject", "urgent")
|
||||||
|
mem.mark_told(first)
|
||||||
|
assert (
|
||||||
|
mem.verdict(parse(alert("ServerUnreachable", resolved=True))).action
|
||||||
|
== "resolved"
|
||||||
|
)
|
||||||
|
assert not mem.told
|
||||||
|
|
||||||
|
|
||||||
|
def test_informational_never_reaches_master_and_failures_go_normal():
|
||||||
|
mem = AlertMemory()
|
||||||
|
assert mem.verdict(parse(alert("StackImageUpdateAvailable"))).action == "drop"
|
||||||
|
assert mem.verdict(parse(alert("ScheduleRun"))).action == "drop"
|
||||||
|
failed = mem.verdict(parse(alert("ProcedureFailed", name="Fleet Backup")))
|
||||||
|
assert (failed.action, failed.urgency) == ("inject", "normal")
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_mentions_hold():
|
||||||
|
a = parse(alert("ServerUnreachable", err={"error": "Timed out waiting for Ping"}))
|
||||||
|
text = describe(a, held=HOLD)
|
||||||
|
assert "ms-agents-proxy-aeza" in text and "5 мин" in text and "Timed out" in text
|
||||||
|
assert describe(
|
||||||
|
parse(alert("ServerDisk", name="dell", path="/", used_gb=90, total_gb=100))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_still_bad_reads_current_state():
|
||||||
|
async def read(kind, params):
|
||||||
|
assert kind == "ListServers"
|
||||||
|
return [{"name": "ms-agents-proxy-aeza", "info": {"state": "Ok"}}]
|
||||||
|
|
||||||
|
a = parse(alert("ServerUnreachable"))
|
||||||
|
assert await still_bad(read, a) is False
|
||||||
|
|
||||||
|
async def boom(kind, params):
|
||||||
|
raise RuntimeError("komodo down")
|
||||||
|
|
||||||
|
assert await still_bad(boom, a) is True
|
||||||
|
assert HOLD == timedelta(minutes=5)
|
||||||
@@ -115,3 +115,65 @@ def test_firefly_requires_open_skill(vault):
|
|||||||
assert gate(call(vault, "mcp__firefly__list_account", state=state)) is None
|
assert gate(call(vault, "mcp__firefly__list_account", state=state)) is None
|
||||||
tracker(call(vault, "Skill", state=state, skill="vault:firefly"))
|
tracker(call(vault, "Skill", state=state, skill="vault:firefly"))
|
||||||
assert gate(store) is None
|
assert gate(store) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def home(vault: Path) -> Zones:
|
||||||
|
"""Зоны диспетчера с 2026-08-30: vault - дом, удаление - только своё."""
|
||||||
|
(vault / ".obsidian").mkdir()
|
||||||
|
(vault / "мета/шаблоны").mkdir()
|
||||||
|
return Zones(
|
||||||
|
vault=vault,
|
||||||
|
write=(vault / "мета/бобер",),
|
||||||
|
create=(vault / "💬 чаты",),
|
||||||
|
edit=(vault,),
|
||||||
|
protected=(vault / ".obsidian", vault / "мета"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_zones_allow_notes_but_protect_obsidian_and_meta(vault, home):
|
||||||
|
rule = vault_zones(home)
|
||||||
|
assert rule(call(vault, "Write", file_path=str(vault / "👤 люди/новый.md"))) is None
|
||||||
|
assert (
|
||||||
|
rule(call(vault, "Edit", file_path=str(vault / "📅 дни/2026-08-29.md"))) is None
|
||||||
|
)
|
||||||
|
assert rule(call(vault, "Write", file_path=str(vault / "мета/бобер/x.md"))) is None
|
||||||
|
assert (
|
||||||
|
rule(call(vault, "Edit", file_path=str(vault / "💬 чаты/старый.md")))
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
deny = rule(call(vault, "Write", file_path=str(vault / ".obsidian/app.json")))
|
||||||
|
assert isinstance(deny, Deny) and "Бобра" in deny.reason
|
||||||
|
assert (
|
||||||
|
rule(call(vault, "Edit", file_path=str(vault / "мета/шаблоны/x.md")))
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"command",
|
||||||
|
[
|
||||||
|
"mv '👤 люди/x.md' '👤 люди/архив/x.md'",
|
||||||
|
"echo hi >> '📅 дни/2026-08-29.md'",
|
||||||
|
"sed -i 's/a/b/' '👤 люди/x.md'",
|
||||||
|
"rm мета/бобер/дни/old.md",
|
||||||
|
"mkdir '💻 проекты/новый'",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_home_bash_edits_allowed(vault, home, command):
|
||||||
|
assert bash_zones(home)(call(vault, "Bash", command=command)) is None, command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"command",
|
||||||
|
[
|
||||||
|
"rm '📅 дни/2026-08-29.md'",
|
||||||
|
"rm -rf 👤\\ люди",
|
||||||
|
"truncate -s 0 '👤 люди/x.md'",
|
||||||
|
"echo x > .obsidian/app.json",
|
||||||
|
"mv '👤 люди/x.md' мета/шаблоны/x.md",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_home_bash_deletes_and_protected_denied(vault, home, command):
|
||||||
|
deny = bash_zones(home)(call(vault, "Bash", command=command))
|
||||||
|
assert isinstance(deny, Deny), command
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import pytest
|
||||||
|
from beaver_gateway.mcp.types import McpServer
|
||||||
|
from beaver_gateway.mcp.wrap import build_python_tool_server
|
||||||
|
from fastmcp import Client
|
||||||
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
|
from mcps.vibegram import Vibegram, _cards, _event, _work
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_render_and_unknown_kinds_are_skipped():
|
||||||
|
msg = _event(
|
||||||
|
{
|
||||||
|
"id": 61,
|
||||||
|
"nick": "claude-neighbour",
|
||||||
|
"kind": "message",
|
||||||
|
"payload": {"body": "@beaver-test привет"},
|
||||||
|
"createdAt": "2026-08-30T10:00:00.000Z",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
msg is not None
|
||||||
|
and msg.line() == "[08-30 10:00] claude-neighbour: @beaver-test привет"
|
||||||
|
)
|
||||||
|
assert _event({"id": 1, "kind": "tree", "payload": {}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mentioned_and_cards():
|
||||||
|
v = Vibegram(hub="http://127.0.0.1:9", token="t", nick="beaver-test")
|
||||||
|
ev = _event(
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"nick": "x",
|
||||||
|
"kind": "message",
|
||||||
|
"payload": {"body": "эй @Beaver-Test"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert ev is not None and v.mentioned([ev])
|
||||||
|
text = _cards(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"nick": "beaver-test",
|
||||||
|
"status": "online",
|
||||||
|
"skills": [],
|
||||||
|
"focus": {"holding": ["a.md"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"beaver-test",
|
||||||
|
)
|
||||||
|
assert "(это ты)" in text and "держит: a.md" in text
|
||||||
|
assert "плана в комнате пока нет" in _work(
|
||||||
|
{"free": [], "taken": [], "busy": [], "cards": [], "planRevision": 0}, "me"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_schema_lists_actions_and_needs_text_for_send():
|
||||||
|
tool = Vibegram(hub="http://127.0.0.1:9", token="t", nick="beaver-test")
|
||||||
|
server = build_python_tool_server(
|
||||||
|
McpServer.python_tool(name="vibegram", tools=[tool.vibegram])
|
||||||
|
)
|
||||||
|
async with Client(server) as client:
|
||||||
|
(spec,) = await client.list_tools()
|
||||||
|
assert {"read", "send", "who", "claim", "release"} <= set(
|
||||||
|
spec.inputSchema["properties"]["action"]["enum"]
|
||||||
|
)
|
||||||
|
with pytest.raises(ToolError, match="text"):
|
||||||
|
await client.call_tool("vibegram", {"action": "send"})
|
||||||
Reference in New Issue
Block a user