mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 08:47:26 +08:00
feat(kanban): dashboard plugin — Linear/Fusion-style board UI
Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core changes — uses the standard dashboard plugin contract (manifest.json + dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'. What the tab gives you: - One column per kanban status (todo / ready / running / blocked / done; archived behind a toggle), column counts, coloured status dots. - Cards with id, title, priority badge, tenant tag, assignee, comment/link counts, 'created N ago'. - HTML5 drag-drop between columns — status change routes through the same kanban_db code the CLI /kanban verbs use, so the three surfaces (CLI, gateway, dashboard) can never drift. - Inline create per-column (title, assignee, priority). - Side drawer on card click: description, status action row (→ ready / → running / block / unblock / complete / archive), dependency links, comment thread with Enter-to-submit, last 20 events. - Toolbar: search, tenant filter, assignee filter, show-archived, nudge-dispatcher (skip the 60s wait), refresh. - Live updates via WebSocket tailing task_events — the board reflects CLI or gateway actions in real time. REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id, POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links, DELETE /links, POST /dispatch, WS /events. Every handler is a thin wrapper around kanban_db — no new business logic. Visually theme-aware: the plugin CSS reads only --color-*, --radius, --font-mono etc. so it reskins with whichever dashboard theme is active. Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests): - empty board shape - create + appears in ready column with tenant/assignee rollups - tenant filter - detail includes parents/children/events - 404 on unknown task - PATCH status: complete / block / unblock / ready drag-drop / running - PATCH reassign, priority, edit, invalid-status rejection - POST comment (plus empty-body rejection) - POST link + DELETE link + cycle rejection - POST dispatch (dry run) All 76 kanban tests pass under scripts/run_tests.sh. Docs: website/docs/user-guide/features/kanban.md gains a full 'Dashboard (GUI)' section covering install, architecture, REST surface, live-updates mechanism, extending, and scope boundary.
This commit is contained in:
333
tests/plugins/test_kanban_dashboard_plugin.py
Normal file
333
tests/plugins/test_kanban_dashboard_plugin.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""Tests for the Kanban dashboard plugin backend (plugins/kanban/dashboard/plugin_api.py).
|
||||
|
||||
The plugin mounts as /api/plugins/kanban/ inside the dashboard's FastAPI app,
|
||||
but here we attach its router to a bare FastAPI instance so we can test the
|
||||
REST surface without spinning up the whole dashboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_plugin_router():
|
||||
"""Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
||||
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hermes_dashboard_plugin_kanban_test", plugin_file,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kanban_home(tmp_path, monkeypatch):
|
||||
"""Isolated HERMES_HOME with an empty kanban DB."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(kanban_home):
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /board on an empty DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_board_empty(client):
|
||||
r = client.get("/api/plugins/kanban/board")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
# All canonical columns present, each empty.
|
||||
names = [c["name"] for c in data["columns"]]
|
||||
for expected in ("todo", "ready", "running", "blocked", "done"):
|
||||
assert expected in names, f"missing column {expected}: {names}"
|
||||
assert all(len(c["tasks"]) == 0 for c in data["columns"])
|
||||
assert data["tenants"] == []
|
||||
assert data["assignees"] == []
|
||||
assert data["latest_event_id"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tasks then GET /board sees it
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_task_appears_on_board(client):
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={
|
||||
"title": "Research LLM caching",
|
||||
"assignee": "researcher",
|
||||
"priority": 3,
|
||||
"tenant": "acme",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
task = r.json()["task"]
|
||||
assert task["title"] == "Research LLM caching"
|
||||
assert task["assignee"] == "researcher"
|
||||
assert task["status"] == "ready" # no parents -> immediately ready
|
||||
assert task["priority"] == 3
|
||||
assert task["tenant"] == "acme"
|
||||
task_id = task["id"]
|
||||
|
||||
# Board now lists it under 'ready'.
|
||||
r = client.get("/api/plugins/kanban/board")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
ready = next(c for c in data["columns"] if c["name"] == "ready")
|
||||
assert len(ready["tasks"]) == 1
|
||||
assert ready["tasks"][0]["id"] == task_id
|
||||
assert "acme" in data["tenants"]
|
||||
assert "researcher" in data["assignees"]
|
||||
|
||||
|
||||
def test_tenant_filter(client):
|
||||
client.post("/api/plugins/kanban/tasks", json={"title": "A", "tenant": "t1"})
|
||||
client.post("/api/plugins/kanban/tasks", json={"title": "B", "tenant": "t2"})
|
||||
|
||||
r = client.get("/api/plugins/kanban/board?tenant=t1")
|
||||
counts = {c["name"]: len(c["tasks"]) for c in r.json()["columns"]}
|
||||
total = sum(counts.values())
|
||||
assert total == 1
|
||||
|
||||
r = client.get("/api/plugins/kanban/board?tenant=t2")
|
||||
total = sum(len(c["tasks"]) for c in r.json()["columns"])
|
||||
assert total == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /tasks/:id returns body + comments + events + links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_task_detail_includes_links_and_events(client):
|
||||
parent = client.post(
|
||||
"/api/plugins/kanban/tasks", json={"title": "parent"},
|
||||
).json()["task"]
|
||||
child = client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={"title": "child", "parents": [parent["id"]]},
|
||||
).json()["task"]
|
||||
assert child["status"] == "todo" # parent not done yet
|
||||
|
||||
# Detail for the child shows the parent link.
|
||||
r = client.get(f"/api/plugins/kanban/tasks/{child['id']}")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["task"]["id"] == child["id"]
|
||||
assert parent["id"] in data["links"]["parents"]
|
||||
|
||||
# Detail for the parent shows the child.
|
||||
r = client.get(f"/api/plugins/kanban/tasks/{parent['id']}")
|
||||
assert child["id"] in r.json()["links"]["children"]
|
||||
|
||||
# Events exist from creation.
|
||||
assert len(data["events"]) >= 1
|
||||
|
||||
|
||||
def test_task_detail_404_on_unknown(client):
|
||||
r = client.get("/api/plugins/kanban/tasks/does-not-exist")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /tasks/:id — status transitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_status_complete(client):
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}",
|
||||
json={"status": "done", "result": "shipped"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["task"]["status"] == "done"
|
||||
|
||||
# Board reflects the move.
|
||||
done = next(
|
||||
c for c in client.get("/api/plugins/kanban/board").json()["columns"]
|
||||
if c["name"] == "done"
|
||||
)
|
||||
assert any(x["id"] == t["id"] for x in done["tasks"])
|
||||
|
||||
|
||||
def test_patch_block_then_unblock(client):
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}",
|
||||
json={"status": "blocked", "block_reason": "need input"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["task"]["status"] == "blocked"
|
||||
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}",
|
||||
json={"status": "ready"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["task"]["status"] == "ready"
|
||||
|
||||
|
||||
def test_patch_drag_drop_move_todo_to_ready(client):
|
||||
"""Direct status write: the drag-drop path for statuses without a
|
||||
dedicated verb (e.g. manually promoting todo -> ready)."""
|
||||
parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"]
|
||||
child = client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={"title": "c", "parents": [parent["id"]]},
|
||||
).json()["task"]
|
||||
assert child["status"] == "todo"
|
||||
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{child['id']}",
|
||||
json={"status": "ready"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["task"]["status"] == "ready"
|
||||
|
||||
|
||||
def test_patch_reassign(client):
|
||||
t = client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={"title": "x", "assignee": "a"},
|
||||
).json()["task"]
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}",
|
||||
json={"assignee": "b"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["task"]["assignee"] == "b"
|
||||
|
||||
|
||||
def test_patch_priority_and_edit(client):
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}",
|
||||
json={"priority": 5, "title": "renamed"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()["task"]
|
||||
assert data["priority"] == 5
|
||||
assert data["title"] == "renamed"
|
||||
|
||||
|
||||
def test_patch_invalid_status(client):
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}",
|
||||
json={"status": "banana"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comments + Links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_comment(client):
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}/comments",
|
||||
json={"body": "how's progress?", "author": "teknium"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/tasks/{t['id']}")
|
||||
comments = r.json()["comments"]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"] == "how's progress?"
|
||||
assert comments[0]["author"] == "teknium"
|
||||
|
||||
|
||||
def test_add_comment_empty_rejected(client):
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
r = client.post(
|
||||
f"/api/plugins/kanban/tasks/{t['id']}/comments",
|
||||
json={"body": " "},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_add_link_and_delete_link(client):
|
||||
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
||||
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
||||
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/links",
|
||||
json={"parent_id": a["id"], "child_id": b["id"]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/tasks/{b['id']}")
|
||||
assert a["id"] in r.json()["links"]["parents"]
|
||||
|
||||
r = client.delete(
|
||||
"/api/plugins/kanban/links",
|
||||
params={"parent_id": a["id"], "child_id": b["id"]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
def test_add_link_cycle_rejected(client):
|
||||
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
||||
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
||||
client.post(
|
||||
"/api/plugins/kanban/links",
|
||||
json={"parent_id": a["id"], "child_id": b["id"]},
|
||||
)
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/links",
|
||||
json={"parent_id": b["id"], "child_id": a["id"]},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch nudge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dispatch_dry_run(client):
|
||||
client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={"title": "work", "assignee": "researcher"},
|
||||
)
|
||||
r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=4")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# DispatchResult is serialized as a dataclass dict.
|
||||
assert isinstance(body, dict)
|
||||
Reference in New Issue
Block a user