mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 02:41:48 +00:00
feat(kanban): multi-project boards — one install, many kanbans (#19653)
Adds first-class board support to kanban so users can separate unrelated
streams of work (projects, repos, domains) into isolated queues. Single-
project users stay on the 'default' board and see no UI change.
Isolation model
---------------
- Each board is a directory at `~/.hermes/kanban/boards/<slug>/` with
its own `kanban.db`, `workspaces/`, and `logs/`. The 'default' board
keeps its legacy path (`~/.hermes/kanban.db`) for back-compat — fresh
installs and pre-boards users get zero migration.
- Workers spawned by the dispatcher have `HERMES_KANBAN_BOARD` pinned in
their env alongside the existing `HERMES_KANBAN_DB` /
`HERMES_KANBAN_WORKSPACES_ROOT` pins, so workers physically cannot see
other boards' tasks.
- The gateway's single dispatcher loop now sweeps every board per tick;
per-tick cost is a few extra filesystem stats.
- CAS concurrency guarantees are preserved per-board (each board is its
own SQLite DB, same WAL+IMMEDIATE machinery as before).
CLI
---
hermes kanban boards list|create|switch|show|rename|rm
hermes kanban --board <slug> <any-subcommand>
Board resolution order: `--board` flag → `HERMES_KANBAN_BOARD` env →
`~/.hermes/kanban/current` file → `default`. Slug validation is strict:
lowercase alphanumerics + hyphens + underscores, 1-64 chars, starts with
alphanumeric. Uppercase is auto-downcased; slashes / dots / `..` /
control chars are rejected so boards can't name their way out of the
boards/ directory.
Passive discoverability: when more than one board exists, `hermes kanban
list` prints a one-line header ("Board: foo (2 other boards …)") so
users who stumble across multi-project never have to hunt for the
feature. Invisible for single-board installs.
Dashboard
---------
- New `BoardSwitcher` component at the top of the Kanban tab: dropdown
with all boards + task counts, `+ New board` button, `Archive`
button (non-default only). Hidden entirely when only `default` exists
and is empty — single-project users never see it.
- New `NewBoardDialog` modal: slug / display name / description / icon
+ "switch to this board after creating" checkbox.
- Selected board persists to `localStorage` so browser users don't
shift the CLI's active board out from under a terminal they left open.
- New `?board=<slug>` query param on every existing endpoint plus a
new `/boards` CRUD surface (`GET /boards`, `POST /boards`,
`PATCH /boards/<slug>`, `DELETE /boards/<slug>`,
`POST /boards/<slug>/switch`).
- Events WebSocket is pinned to a board at connection time; switching
opens a fresh WS against the new board.
Also fixes a pre-existing bug in the plugin's tenant / assignee
filters: the SDK's `Select` uses `onValueChange(value)`, not
native `onChange(event)`, so those filters silently didn't work.
New `selectChangeHandler` helper wires both signatures.
Tests
-----
49 new tests in `tests/hermes_cli/test_kanban_boards.py` covering:
slug validation (valid / invalid / auto-downcase), path resolution
(default = legacy path, named = `boards/<slug>/`, env var override),
current-board resolution chain (env > file > default), board CRUD +
archive / hard-delete, per-board connection isolation (tasks don't
leak), worker spawn env injection (`HERMES_KANBAN_BOARD`,
`HERMES_KANBAN_DB`, `HERMES_KANBAN_WORKSPACES_ROOT` all point at the
right board), and end-to-end CLI surface.
Regression surface: all 264 pre-existing kanban tests continue to pass.
Live-tested via the dashboard: created 3 boards (default,
hermes-agent, atm10-server), created tasks on each via both CLI
(`--board <slug> create`) and dashboard (inline create on the Ready
column), confirmed zero cross-board leakage, confirmed `BoardSwitcher`
+ `NewBoardDialog` work end-to-end in the browser.
This commit is contained in:
parent
135b4c8b35
commit
5ec6baa400
8 changed files with 2191 additions and 212 deletions
|
|
@ -72,19 +72,45 @@ def _check_ws_token(provided: Optional[str]) -> bool:
|
|||
return hmac.compare_digest(str(provided), str(expected))
|
||||
|
||||
|
||||
def _conn():
|
||||
def _resolve_board(board: Optional[str]) -> Optional[str]:
|
||||
"""Validate and normalise a board slug from a query param.
|
||||
|
||||
Raises :class:`HTTPException` 400 on malformed slugs so the browser
|
||||
sees a clean error instead of a 500. Returns the normalised slug,
|
||||
or ``None`` when the caller omitted the param (which then falls
|
||||
through to the active board inside ``kb.connect()``).
|
||||
"""
|
||||
if board is None or board == "":
|
||||
return None
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(board)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if normed and normed != kanban_db.DEFAULT_BOARD and not kanban_db.board_exists(normed):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"board {normed!r} does not exist",
|
||||
)
|
||||
return normed
|
||||
|
||||
|
||||
def _conn(board: Optional[str] = None):
|
||||
"""Open a kanban_db connection, creating the schema on first use.
|
||||
|
||||
Every handler that mutates the DB goes through this so the plugin
|
||||
self-heals on a fresh install (no user-visible "no such table"
|
||||
error if somebody hits POST /tasks before GET /board).
|
||||
``init_db`` is idempotent.
|
||||
|
||||
``board`` is the query-param slug (already normalised by
|
||||
:func:`_resolve_board`). When ``None`` the active board is used
|
||||
via the resolution chain (env var → ``current`` file → ``default``).
|
||||
"""
|
||||
try:
|
||||
kanban_db.init_db()
|
||||
kanban_db.init_db(board=board)
|
||||
except Exception as exc:
|
||||
log.warning("kanban init_db failed: %s", exc)
|
||||
return kanban_db.connect()
|
||||
return kanban_db.connect(board=board)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -177,13 +203,19 @@ def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
|
|||
def get_board(
|
||||
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
|
||||
include_archived: bool = Query(False),
|
||||
board: Optional[str] = Query(None, description="Kanban board slug (omit for current)"),
|
||||
):
|
||||
"""Return the full board grouped by status column.
|
||||
|
||||
``_conn()`` auto-initializes ``kanban.db`` on first call so a fresh
|
||||
install doesn't surface a "failed to load" error on the plugin tab.
|
||||
|
||||
``board`` selects which board to read from. Omitting it falls
|
||||
through to the active board (``HERMES_KANBAN_BOARD`` env → on-disk
|
||||
``current`` pointer → ``default``).
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
tasks = kanban_db.list_tasks(
|
||||
conn, tenant=tenant, include_archived=include_archived
|
||||
|
|
@ -274,8 +306,9 @@ def get_board(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def get_task(task_id: str):
|
||||
conn = _conn()
|
||||
def get_task(task_id: str, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
|
|
@ -311,8 +344,9 @@ class CreateTaskBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/tasks")
|
||||
def create_task(payload: CreateTaskBody):
|
||||
conn = _conn()
|
||||
def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task_id = kanban_db.create_task(
|
||||
conn,
|
||||
|
|
@ -373,8 +407,9 @@ class UpdateTaskBody(BaseModel):
|
|||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
def update_task(task_id: str, payload: UpdateTaskBody):
|
||||
conn = _conn()
|
||||
def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
|
|
@ -527,10 +562,11 @@ class CommentBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/tasks/{task_id}/comments")
|
||||
def add_comment(task_id: str, payload: CommentBody):
|
||||
def add_comment(task_id: str, payload: CommentBody, board: Optional[str] = Query(None)):
|
||||
if not payload.body.strip():
|
||||
raise HTTPException(status_code=400, detail="body is required")
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
|
|
@ -552,8 +588,9 @@ class LinkBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/links")
|
||||
def add_link(payload: LinkBody):
|
||||
conn = _conn()
|
||||
def add_link(payload: LinkBody, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
kanban_db.link_tasks(conn, payload.parent_id, payload.child_id)
|
||||
return {"ok": True}
|
||||
|
|
@ -564,8 +601,13 @@ def add_link(payload: LinkBody):
|
|||
|
||||
|
||||
@router.delete("/links")
|
||||
def delete_link(parent_id: str = Query(...), child_id: str = Query(...)):
|
||||
conn = _conn()
|
||||
def delete_link(
|
||||
parent_id: str = Query(...),
|
||||
child_id: str = Query(...),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
ok = kanban_db.unlink_tasks(conn, parent_id, child_id)
|
||||
return {"ok": bool(ok)}
|
||||
|
|
@ -586,7 +628,7 @@ class BulkTaskBody(BaseModel):
|
|||
|
||||
|
||||
@router.post("/tasks/bulk")
|
||||
def bulk_update(payload: BulkTaskBody):
|
||||
def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
|
||||
"""Apply the same patch to every id in ``payload.ids``.
|
||||
|
||||
This is an *independent* iteration — per-task failures don't abort
|
||||
|
|
@ -596,7 +638,8 @@ def bulk_update(payload: BulkTaskBody):
|
|||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="ids is required")
|
||||
results: list[dict] = []
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
for tid in ids:
|
||||
entry: dict[str, Any] = {"id": tid, "ok": True}
|
||||
|
|
@ -690,14 +733,15 @@ def get_config():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats():
|
||||
def get_stats(board: Optional[str] = Query(None)):
|
||||
"""Per-status + per-assignee counts + oldest-ready age.
|
||||
|
||||
Designed for the dashboard HUD and for router profiles that need to
|
||||
answer "is this specialist overloaded?" without scanning the whole
|
||||
board themselves.
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
return kanban_db.board_stats(conn)
|
||||
finally:
|
||||
|
|
@ -705,7 +749,7 @@ def get_stats():
|
|||
|
||||
|
||||
@router.get("/assignees")
|
||||
def get_assignees():
|
||||
def get_assignees(board: Optional[str] = Query(None)):
|
||||
"""Known profiles + per-profile task counts.
|
||||
|
||||
Returns the union of ``~/.hermes/profiles/*`` on disk and every
|
||||
|
|
@ -713,7 +757,8 @@ def get_assignees():
|
|||
this to populate its assignee dropdown so a freshly-created profile
|
||||
appears in the picker before it's been given any task.
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
return {"assignees": kanban_db.known_assignees(conn)}
|
||||
finally:
|
||||
|
|
@ -725,7 +770,11 @@ def get_assignees():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/tasks/{task_id}/log")
|
||||
def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_000)):
|
||||
def get_task_log(
|
||||
task_id: str,
|
||||
tail: Optional[int] = Query(None, ge=1, le=2_000_000),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""Return the worker's stdout/stderr log.
|
||||
|
||||
``tail`` caps the response size (bytes) so the dashboard drawer
|
||||
|
|
@ -734,15 +783,16 @@ def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_
|
|||
``_rotate_worker_log`` — a single ``.log.1`` is kept, no further
|
||||
generations, so disk usage per task is bounded at ~4 MiB.
|
||||
"""
|
||||
conn = _conn()
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
content = kanban_db.read_worker_log(task_id, tail_bytes=tail)
|
||||
log_path = kanban_db.worker_log_path(task_id)
|
||||
content = kanban_db.read_worker_log(task_id, tail_bytes=tail, board=board)
|
||||
log_path = kanban_db.worker_log_path(task_id, board=board)
|
||||
size = log_path.stat().st_size if log_path.exists() else 0
|
||||
return {
|
||||
"task_id": task_id,
|
||||
|
|
@ -760,11 +810,16 @@ def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/dispatch")
|
||||
def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
|
||||
conn = _conn()
|
||||
def dispatch(
|
||||
dry_run: bool = Query(False),
|
||||
max_n: int = Query(8, alias="max"),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
result = kanban_db.dispatch_once(
|
||||
conn, dry_run=dry_run, max_spawn=max_n,
|
||||
conn, dry_run=dry_run, max_spawn=max_n, board=board,
|
||||
)
|
||||
# DispatchResult is a dataclass.
|
||||
try:
|
||||
|
|
@ -775,6 +830,124 @@ def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boards CRUD (multi-project support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateBoardBody(BaseModel):
|
||||
slug: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
switch: bool = False
|
||||
|
||||
|
||||
class RenameBoardBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
def _board_counts(slug: str) -> dict[str, int]:
|
||||
"""Return ``{status: count}`` for a board. Safe on an empty DB."""
|
||||
try:
|
||||
path = kanban_db.kanban_db_path(board=slug)
|
||||
if not path.exists():
|
||||
return {}
|
||||
conn = kanban_db.connect(board=slug)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
|
||||
).fetchall()
|
||||
return {r["status"]: int(r["n"]) for r in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/boards")
|
||||
def list_boards(include_archived: bool = Query(False)):
|
||||
"""Return every board on disk with task counts and the active slug."""
|
||||
boards = kanban_db.list_boards(include_archived=include_archived)
|
||||
current = kanban_db.get_current_board()
|
||||
for b in boards:
|
||||
b["is_current"] = (b["slug"] == current)
|
||||
b["counts"] = _board_counts(b["slug"])
|
||||
b["total"] = sum(b["counts"].values())
|
||||
return {"boards": boards, "current": current}
|
||||
|
||||
|
||||
@router.post("/boards")
|
||||
def create_board_endpoint(payload: CreateBoardBody):
|
||||
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
|
||||
try:
|
||||
meta = kanban_db.create_board(
|
||||
payload.slug,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if payload.switch:
|
||||
try:
|
||||
kanban_db.set_current_board(meta["slug"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"board": meta, "current": kanban_db.get_current_board()}
|
||||
|
||||
|
||||
@router.patch("/boards/{slug}")
|
||||
def rename_board(slug: str, payload: RenameBoardBody):
|
||||
"""Update a board's display metadata (slug is immutable — create a new one to rename the directory)."""
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(slug)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not normed or not kanban_db.board_exists(normed):
|
||||
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
|
||||
meta = kanban_db.write_board_metadata(
|
||||
normed,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
)
|
||||
return {"board": meta}
|
||||
|
||||
|
||||
@router.delete("/boards/{slug}")
|
||||
def delete_board(slug: str, delete: bool = Query(False, description="Hard-delete instead of archive")):
|
||||
"""Archive (default) or hard-delete a board."""
|
||||
try:
|
||||
res = kanban_db.remove_board(slug, archive=not delete)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"result": res, "current": kanban_db.get_current_board()}
|
||||
|
||||
|
||||
@router.post("/boards/{slug}/switch")
|
||||
def switch_board(slug: str):
|
||||
"""Persist ``slug`` as the active board for subsequent CLI / slash calls.
|
||||
|
||||
Dashboard users pick boards via a client-side ``localStorage`` — this
|
||||
endpoint is for ``/kanban boards switch`` parity so gateway slash
|
||||
commands and the CLI share the same current-board pointer.
|
||||
"""
|
||||
try:
|
||||
normed = kanban_db._normalize_board_slug(slug)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if not normed or not kanban_db.board_exists(normed):
|
||||
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
|
||||
kanban_db.set_current_board(normed)
|
||||
return {"current": normed}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket: /events?since=<event_id>
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -802,8 +975,18 @@ async def stream_events(ws: WebSocket):
|
|||
except ValueError:
|
||||
cursor = 0
|
||||
|
||||
# Board selection — pinned at the WS handshake; re-subscribe to
|
||||
# switch boards. Changing boards mid-stream would require
|
||||
# reconciling two cursors, so the UI just opens a new WS on
|
||||
# board change.
|
||||
ws_board_raw = ws.query_params.get("board")
|
||||
try:
|
||||
ws_board = kanban_db._normalize_board_slug(ws_board_raw) if ws_board_raw else None
|
||||
except ValueError:
|
||||
ws_board = None
|
||||
|
||||
def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]:
|
||||
conn = kanban_db.connect()
|
||||
conn = kanban_db.connect(board=ws_board)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, task_id, run_id, kind, payload, created_at "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue