feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones
This commit is contained in:
@@ -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
|
||||
tracker(call(vault, "Skill", state=state, skill="vault:firefly"))
|
||||
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