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:
Teknium 2026-05-04 04:42:38 -07:00 committed by GitHub
parent 135b4c8b35
commit 5ec6baa400
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2191 additions and 212 deletions

View file

@ -3277,6 +3277,11 @@ class GatewayRunner:
Runs in the gateway event loop; all SQLite work is pushed to a
thread via ``asyncio.to_thread`` so the loop never blocks on the
WAL lock. Failures in one tick don't stop subsequent ticks.
**Multi-board:** iterates every board discovered on disk per
tick. Subscriptions live inside each board's own DB and cannot
cross boards, so delivery semantics are unchanged this is
purely a fan-out of the single-DB poll.
"""
from gateway.config import Platform as _Platform
try:
@ -3309,40 +3314,54 @@ class GatewayRunner:
while self._running:
try:
def _collect():
conn = _kb.connect()
deliveries: list[dict] = []
# Enumerate every board on disk. Cheap: a few
# directory stat calls per tick. Missing/empty
# boards are silently skipped.
try:
_kb.init_db() # idempotent; handles first-run
boards = _kb.list_boards(include_archived=False)
except Exception:
pass
try:
subs = _kb.list_notify_subs(conn)
deliveries: list[dict] = []
for sub in subs:
cursor, events = _kb.unseen_events_for_sub(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
kinds=TERMINAL_KINDS,
)
if not events:
continue
task = _kb.get_task(conn, sub["task_id"])
deliveries.append({
"sub": sub,
"cursor": cursor,
"events": events,
"task": task,
})
return deliveries
finally:
conn.close()
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
for board_meta in boards:
slug = board_meta.get("slug") or _kb.DEFAULT_BOARD
try:
conn = _kb.connect(board=slug)
except Exception:
continue
try:
try:
_kb.init_db(board=slug) # idempotent; handles first-run
except Exception:
pass
subs = _kb.list_notify_subs(conn)
for sub in subs:
cursor, events = _kb.unseen_events_for_sub(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
kinds=TERMINAL_KINDS,
)
if not events:
continue
task = _kb.get_task(conn, sub["task_id"])
deliveries.append({
"sub": sub,
"cursor": cursor,
"events": events,
"task": task,
"board": slug,
})
finally:
conn.close()
return deliveries
deliveries = await asyncio.to_thread(_collect)
for d in deliveries:
sub = d["sub"]
task = d["task"]
board_slug = d.get("board")
platform_str = (sub["platform"] or "").lower()
try:
plat = _Platform(platform_str)
@ -3350,7 +3369,7 @@ class GatewayRunner:
# Unknown platform string; skip and advance cursor so
# we don't replay forever.
await asyncio.to_thread(
self._kanban_advance, sub, d["cursor"],
self._kanban_advance, sub, d["cursor"], board_slug,
)
continue
adapter = self.adapters.get(plat)
@ -3440,14 +3459,14 @@ class GatewayRunner:
"%s on %s after %d consecutive send failures",
sub["task_id"], platform_str, fails,
)
await asyncio.to_thread(self._kanban_unsub, sub)
await asyncio.to_thread(self._kanban_unsub, sub, board_slug)
sub_fail_counts.pop(sub_key, None)
# Don't advance cursor on send failure — retry next tick.
break
else:
# All events delivered; advance cursor + maybe unsub.
await asyncio.to_thread(
self._kanban_advance, sub, d["cursor"],
self._kanban_advance, sub, d["cursor"], board_slug,
)
# Unsubscribe when the LAST delivered event is a
# terminal kind (the task hit a "no further updates"
@ -3459,7 +3478,7 @@ class GatewayRunner:
event_terminal = last_kind in TERMINAL_EVENT_KINDS
if task_terminal or event_terminal:
await asyncio.to_thread(
self._kanban_unsub, sub,
self._kanban_unsub, sub, board_slug,
)
except Exception as exc:
logger.warning("kanban notifier tick failed: %s", exc)
@ -3469,10 +3488,16 @@ class GatewayRunner:
return
await asyncio.sleep(1)
def _kanban_advance(self, sub: dict, cursor: int) -> None:
"""Sync helper: advance a subscription's cursor. Runs in to_thread."""
def _kanban_advance(
self, sub: dict, cursor: int, board: Optional[str] = None,
) -> None:
"""Sync helper: advance a subscription's cursor. Runs in to_thread.
``board`` scopes the DB connection to the board that owns this
subscription. Unsub cursors in one board can't touch another's.
"""
from hermes_cli import kanban_db as _kb
conn = _kb.connect()
conn = _kb.connect(board=board)
try:
_kb.advance_notify_cursor(
conn,
@ -3485,9 +3510,9 @@ class GatewayRunner:
finally:
conn.close()
def _kanban_unsub(self, sub: dict) -> None:
def _kanban_unsub(self, sub: dict, board: Optional[str] = None) -> None:
from hermes_cli import kanban_db as _kb
conn = _kb.connect()
conn = _kb.connect(board=board)
try:
_kb.remove_notify_sub(
conn,
@ -3565,20 +3590,25 @@ class GatewayRunner:
bad_ticks = 0
last_warn_at = 0
def _tick_once() -> "Optional[object]":
"""Run one dispatch_once; return result or None on error.
def _tick_once_for_board(slug: str) -> "Optional[object]":
"""Run one dispatch_once for a specific board.
Runs in a worker thread via `asyncio.to_thread`."""
Runs in a worker thread via `asyncio.to_thread`. `board=slug`
is passed through `dispatch_once` so `resolve_workspace` and
`_default_spawn` see the right paths. The per-board DB is
opened explicitly so concurrent boards never share a
connection handle or accidentally claim across each other.
"""
conn = None
try:
conn = _kb.connect()
conn = _kb.connect(board=slug)
try:
_kb.init_db() # idempotent, handles first-run
_kb.init_db(board=slug) # idempotent, handles first-run
except Exception:
pass
return _kb.dispatch_once(conn)
return _kb.dispatch_once(conn, board=slug)
except Exception:
logger.exception("kanban dispatcher: tick failed")
logger.exception("kanban dispatcher: tick failed on board %s", slug)
return None
finally:
if conn is not None:
@ -3587,49 +3617,77 @@ class GatewayRunner:
except Exception:
pass
def _ready_nonempty() -> bool:
"""Cheap probe: is there at least one ready+assigned+unclaimed task?"""
conn = None
def _tick_once() -> "list[tuple[str, Optional[object]]]":
"""Run one dispatch_once per board. Returns (slug, result) pairs.
Enumerating boards on every tick keeps the dispatcher honest
when users create a new board mid-run: no restart required,
the next tick picks it up automatically.
"""
try:
conn = _kb.connect()
row = conn.execute(
"SELECT 1 FROM tasks "
"WHERE status = 'ready' AND assignee IS NOT NULL "
" AND claim_lock IS NULL LIMIT 1"
).fetchone()
return row is not None
boards = _kb.list_boards(include_archived=False)
except Exception:
return False
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
out: list[tuple[str, "Optional[object]"]] = []
for b in boards:
slug = b.get("slug") or _kb.DEFAULT_BOARD
out.append((slug, _tick_once_for_board(slug)))
return out
def _ready_nonempty() -> bool:
"""Cheap probe: is there a ready+assigned+unclaimed task on ANY board?"""
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
for b in boards:
slug = b.get("slug") or _kb.DEFAULT_BOARD
conn = None
try:
conn = _kb.connect(board=slug)
row = conn.execute(
"SELECT 1 FROM tasks "
"WHERE status = 'ready' AND assignee IS NOT NULL "
" AND claim_lock IS NULL LIMIT 1"
).fetchone()
if row is not None:
return True
except Exception:
continue
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
return False
logger.info(
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
)
while self._running:
try:
res = await asyncio.to_thread(_tick_once)
if res is not None and getattr(res, "spawned", None):
# Quiet by default — only log when something actually
# happened, so an idle gateway stays silent.
logger.info(
"kanban dispatcher: tick spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
len(res.spawned),
res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
# Health telemetry
results = await asyncio.to_thread(_tick_once)
any_spawned = False
for slug, res in (results or []):
if res is not None and getattr(res, "spawned", None):
any_spawned = True
# Quiet by default — only log when something actually
# happened, so an idle gateway stays silent.
logger.info(
"kanban dispatcher [%s]: spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
slug,
len(res.spawned),
res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
# Health telemetry (aggregate across boards)
ready_pending = await asyncio.to_thread(_ready_nonempty)
spawned_any = bool(res and getattr(res, "spawned", None))
if ready_pending and not spawned_any:
if ready_pending and not any_spawned:
bad_ticks += 1
else:
bad_ticks = 0

View file

@ -169,11 +169,93 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
"or docs/hermes-kanban-v1-spec.pdf for the full design."
),
)
# --- global --board flag ---
# Applies to every subcommand below. When set, scopes all reads and
# writes to that board's DB. When omitted, resolves via the
# HERMES_KANBAN_BOARD env var, then the persisted current-board
# file, then "default". See kanban_db.get_current_board().
kanban_parser.add_argument(
"--board",
default=None,
metavar="<slug>",
help=(
"Board slug to operate on. Defaults to the current board "
"(set via `hermes kanban boards switch <slug>` or the "
"HERMES_KANBAN_BOARD env var). Use `hermes kanban boards list` "
"to see all boards."
),
)
sub = kanban_parser.add_subparsers(dest="kanban_action")
# --- init ---
sub.add_parser("init", help="Create kanban.db if missing (idempotent)")
# --- boards (new in v2: multi-project support) ---
p_boards = sub.add_parser(
"boards",
help="Manage kanban boards (one board per project / workstream)",
description=(
"Boards let you separate unrelated streams of work "
"(projects, repos, domains) into isolated queues. Each "
"board has its own DB, workspaces directory, and dispatcher "
"loop — tasks on one board cannot collide with tasks on "
"another. The first board is 'default' and always exists."
),
)
boards_sub = p_boards.add_subparsers(dest="boards_action")
b_list = boards_sub.add_parser(
"list", aliases=["ls"],
help="List all boards with task counts",
)
b_list.add_argument("--json", action="store_true")
b_list.add_argument("--all", action="store_true",
help="Include archived boards too")
b_create = boards_sub.add_parser(
"create", aliases=["new"],
help="Create a new board",
)
b_create.add_argument("slug",
help="Board slug (kebab-case, e.g. atm10-server)")
b_create.add_argument("--name", default=None,
help="Human-readable display name (defaults to Title Case of slug)")
b_create.add_argument("--description", default=None,
help="Optional description")
b_create.add_argument("--icon", default=None,
help="Optional emoji or single-character icon for the dashboard")
b_create.add_argument("--color", default=None,
help="Optional hex color (e.g. '#8b5cf6') for the dashboard")
b_create.add_argument("--switch", action="store_true",
help="Switch to the new board after creating it")
b_rm = boards_sub.add_parser(
"rm", aliases=["remove", "delete"],
help="Archive (default) or delete a board",
)
b_rm.add_argument("slug")
b_rm.add_argument("--delete", action="store_true",
help="Hard-delete the board directory instead of archiving it. "
"Default is to move it to boards/_archived/ so it's recoverable.")
b_switch = boards_sub.add_parser(
"switch", aliases=["use"],
help="Set the active board for subsequent CLI calls",
)
b_switch.add_argument("slug")
boards_sub.add_parser(
"show", aliases=["current"],
help="Print the currently-active board slug",
)
b_rename = boards_sub.add_parser(
"rename",
help="Change a board's human-readable display name (slug is immutable)",
)
b_rename.add_argument("slug")
b_rename.add_argument("name", help="New display name")
# --- create ---
p_create = sub.add_parser("create", help="Create a new task")
p_create.add_argument("title", help="Task title")
@ -442,6 +524,38 @@ def kanban_command(args: argparse.Namespace) -> int:
)
return 0
# `--board <slug>` applies to every subcommand below by way of an
# env-var pin for the duration of this call. Using HERMES_KANBAN_BOARD
# (rather than threading `board=` through 50+ kb.connect() sites)
# keeps the patch small and inherits the exact same resolution the
# dispatcher uses for workers — consistency is a feature here.
board_override = getattr(args, "board", None)
if board_override:
try:
normed = kb._normalize_board_slug(board_override)
except ValueError as exc:
print(f"kanban: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban: --board requires a slug", file=sys.stderr)
return 2
# Boards other than 'default' must already exist — typoed slugs
# would otherwise silently create an empty board.
if normed != kb.DEFAULT_BOARD and not kb.board_exists(normed):
print(
f"kanban: board {normed!r} does not exist. "
f"Create it with `hermes kanban boards create {normed}`.",
file=sys.stderr,
)
return 1
os.environ["HERMES_KANBAN_BOARD"] = normed
# Boards management doesn't touch the DB at all — dispatch early so
# fresh installs that haven't initialized any DB can still use
# `hermes kanban boards create …`.
if action == "boards":
return _dispatch_boards(args)
# Auto-initialize the DB before dispatching any subcommand. init_db
# is idempotent, so running it every invocation is cheap (one
# SELECT against sqlite_master when tables already exist) and
@ -513,6 +627,185 @@ def _profile_author() -> str:
return "user"
# ---------------------------------------------------------------------------
# Boards management (hermes kanban boards …)
# ---------------------------------------------------------------------------
def _dispatch_boards(args: argparse.Namespace) -> int:
"""Handle ``hermes kanban boards <action>``.
Boards management is deliberately separate from the task-level
commands: it operates on the filesystem (board directories,
``current`` pointer, ``board.json``), not on the per-board SQLite
DB, so a fresh HERMES_HOME that has never called ``kanban init``
can still run ``boards create`` / ``boards list``.
"""
sub = getattr(args, "boards_action", None) or "list"
if sub in ("list", "ls"):
return _cmd_boards_list(args)
if sub in ("create", "new"):
return _cmd_boards_create(args)
if sub in ("rm", "remove", "delete"):
return _cmd_boards_rm(args)
if sub in ("switch", "use"):
return _cmd_boards_switch(args)
if sub in ("show", "current"):
return _cmd_boards_show(args)
if sub == "rename":
return _cmd_boards_rename(args)
print(f"kanban boards: unknown action {sub!r}", file=sys.stderr)
return 2
def _board_task_counts(slug: str) -> dict[str, int]:
"""Return ``{status: count}`` for a board. Safe to call on an empty DB."""
try:
path = kb.kanban_db_path(board=slug)
if not path.exists():
return {}
with kb.connect(board=slug) as conn:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
).fetchall()
return {r["status"]: int(r["n"]) for r in rows}
except Exception:
return {}
def _cmd_boards_list(args: argparse.Namespace) -> int:
include_archived = bool(getattr(args, "all", False))
boards = kb.list_boards(include_archived=include_archived)
# Enrich each entry with task counts + whether it's the current board.
current = kb.get_current_board()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_task_counts(b["slug"])
b["total"] = sum(b["counts"].values())
if getattr(args, "json", False):
print(json.dumps(boards, indent=2, ensure_ascii=False))
return 0
# Human table: marker (•) for current, slug, display name, counts.
if not boards:
print("(no boards — create one with `hermes kanban boards create <slug>`)")
return 0
print(f"{'':2s} {'SLUG':24s} {'NAME':28s} COUNTS")
for b in boards:
marker = "" if b["is_current"] else " "
counts = b["counts"] or {}
counts_str = (
", ".join(f"{k}={v}" for k, v in sorted(counts.items()))
or "(empty)"
)
name = b.get("name") or ""
if b.get("archived"):
name += " [archived]"
print(f"{marker:2s} {b['slug']:24s} {name:28s} {counts_str}")
print()
print(f"Current board: {current}")
if len(boards) > 1:
print("Switch boards with `hermes kanban boards switch <slug>`.")
return 0
def _cmd_boards_create(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards create: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban boards create: slug is required", file=sys.stderr)
return 2
already = kb.board_exists(normed) and normed != kb.DEFAULT_BOARD
meta = kb.create_board(
normed,
name=args.name,
description=args.description,
icon=args.icon,
color=args.color,
)
verb = "already exists" if already else "created"
print(f"Board {meta['slug']!r} {verb}.")
print(f" Display name: {meta.get('name', '')}")
print(f" DB path: {meta['db_path']}")
if getattr(args, "switch", False):
kb.set_current_board(meta["slug"])
print(f" Switched to {meta['slug']!r}.")
else:
print(f" Use `hermes kanban boards switch {meta['slug']}` to make it current.")
return 0
def _cmd_boards_rm(args: argparse.Namespace) -> int:
try:
res = kb.remove_board(args.slug, archive=not getattr(args, "delete", False))
except ValueError as exc:
print(f"kanban boards rm: {exc}", file=sys.stderr)
return 1
if res["action"] == "archived":
print(f"Board {res['slug']!r} archived → {res['new_path']}")
print("Recover by moving the directory back to "
"<root>/kanban/boards/<slug>/.")
else:
print(f"Board {res['slug']!r} deleted.")
return 0
def _cmd_boards_switch(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards switch: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban boards switch: slug is required", file=sys.stderr)
return 2
if not kb.board_exists(normed):
print(
f"kanban boards switch: board {normed!r} does not exist. "
f"Create it with `hermes kanban boards create {normed}`.",
file=sys.stderr,
)
return 1
kb.set_current_board(normed)
print(f"Active board is now {normed!r}.")
return 0
def _cmd_boards_show(args: argparse.Namespace) -> int:
current = kb.get_current_board()
meta = kb.read_board_metadata(current)
counts = _board_task_counts(current)
total = sum(counts.values())
print(f"Current board: {current}")
print(f" Display name: {meta.get('name', '')}")
if meta.get("description"):
print(f" Description: {meta['description']}")
print(f" DB path: {meta['db_path']}")
print(f" Tasks: {total} total"
+ (f" ({', '.join(f'{k}={v}' for k, v in sorted(counts.items()))})"
if counts else ""))
return 0
def _cmd_boards_rename(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards rename: {exc}", file=sys.stderr)
return 2
if not normed or not kb.board_exists(normed):
print(f"kanban boards rename: board {args.slug!r} does not exist",
file=sys.stderr)
return 1
meta = kb.write_board_metadata(normed, name=args.name)
print(f"Board {normed!r} renamed to {meta['name']!r}.")
return 0
# ---------------------------------------------------------------------------
def _parse_duration(val) -> Optional[int]:
"""Parse ``30s`` / ``5m`` / ``2h`` / ``1d`` or a raw integer → seconds.
@ -662,6 +955,21 @@ def _cmd_list(args: argparse.Namespace) -> int:
if getattr(args, "json", False):
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
return 0
# Passive discoverability: when the user has multiple boards, surface
# which one they're looking at in the list header. Single-board users
# never see this — the feature stays invisible until you opt in.
try:
all_boards = kb.list_boards(include_archived=False)
except Exception:
all_boards = []
if len(all_boards) > 1:
current = kb.get_current_board()
other_count = len(all_boards) - 1
print(
f"Board: {current} "
f"({other_count} other board{'s' if other_count != 1 else ''}"
f"`hermes kanban boards list`)\n"
)
if not tasks:
print("(no matching tasks)")
return 0

View file

@ -1,28 +1,56 @@
"""SQLite-backed Kanban board for multi-profile collaboration.
"""SQLite-backed Kanban board for multi-profile, multi-project collaboration.
The board lives at ``<root>/kanban.db`` where ``<root>`` is the **shared
Hermes root** (the parent of any active profile). Profiles intentionally
collapse onto a single board: it IS the cross-profile coordination
primitive. A worker spawned with ``hermes -p <profile>`` joins the same
board as the dispatcher that claimed the task. The same applies to
``<root>/kanban/workspaces/`` and ``<root>/kanban/logs/``.
In a fresh install the board lives at ``<root>/kanban.db`` where
``<root>`` is the **shared Hermes root** (the parent of any active
profile). Profiles intentionally collapse onto a shared board: it IS
the cross-profile coordination primitive. A worker spawned with
``hermes -p <profile>`` joins the same board as the dispatcher that
claimed the task. The same applies to ``<root>/kanban/workspaces/`` and
``<root>/kanban/logs/``.
**Multiple boards (projects):** users can create additional boards to
separate unrelated streams of work (e.g. one per project / repo / domain).
Each board is a directory under ``<root>/kanban/boards/<slug>/`` with
its own ``kanban.db``, ``workspaces/``, and ``logs/``. All boards share
the profile's Hermes home but are otherwise isolated: a worker spawned
for a task on board ``atm10-server`` sees only that board's tasks,
cannot enumerate other boards, and its dispatcher ticks don't touch
other boards' DBs.
The first (and for single-project users, only) board is ``default``.
For back-compat its on-disk DB is ``<root>/kanban.db`` (not
``boards/default/kanban.db``), so installs that predate the boards
feature keep working with zero migration. See :func:`kanban_db_path`.
Board resolution order (highest precedence first, all optional):
* ``board=`` argument passed directly to :func:`connect` / :func:`init_db`
(explicit used by the CLI ``--board`` flag and the dashboard
``?board=...`` query param).
* ``HERMES_KANBAN_BOARD`` env var (used by the dispatcher to pin workers
to the board their task lives on workers cannot see other boards).
* ``HERMES_KANBAN_DB`` env var (pins the DB file path directly legacy
override still honoured; highest precedence when the file path itself
is what the caller wants to force).
* ``<root>/kanban/current`` a one-line text file holding the slug of
the "currently selected" board. Written by ``hermes kanban boards
switch <slug>``. When absent, the active board is ``default``.
In standard installs ``<root>`` is ``~/.hermes``. In Docker / custom
deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g.
``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Three env-var overrides
are available (highest precedence first, all optional):
``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Legacy env-var
overrides still work:
* ``HERMES_KANBAN_DB`` pin the database file path directly.
* ``HERMES_KANBAN_WORKSPACES_ROOT`` pin the workspaces root directly.
* ``HERMES_KANBAN_HOME`` pin the umbrella root that anchors all three
kanban paths (db + workspaces + logs). Useful for tests and unusual
deployments where a single override is enough.
* ``HERMES_KANBAN_HOME`` pin the umbrella root that anchors kanban
paths. Useful for tests and unusual deployments.
The dispatcher injects ``HERMES_KANBAN_DB`` and
``HERMES_KANBAN_WORKSPACES_ROOT`` into the worker subprocess env as a
defense-in-depth measure: even if the worker's ``get_default_hermes_root()``
resolution somehow disagrees with the dispatcher's (unusual symlink or
Docker layout), the two processes still converge on the same files.
The dispatcher injects ``HERMES_KANBAN_DB``,
``HERMES_KANBAN_WORKSPACES_ROOT``, and ``HERMES_KANBAN_BOARD`` into
worker subprocess env so workers converge on the exact DB the
dispatcher used to claim their task even under unusual symlink or
Docker layouts.
Schema is intentionally small: tasks, task_links, task_comments,
task_events. The ``workspace_kind`` field decouples coordination from git
@ -35,6 +63,9 @@ transactions + compare-and-swap (CAS) updates on ``tasks.status`` and
``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at
most one claimer can win any given task. Losers observe zero affected
rows and move on -- no retry loops, no distributed-lock machinery.
The CAS coordination is **per-board** each board is a separate DB,
so multi-board installs get the same atomicity guarantees without any
new locking.
"""
from __future__ import annotations
@ -42,6 +73,7 @@ from __future__ import annotations
import contextlib
import json
import os
import re
import secrets
import sqlite3
import sys
@ -81,6 +113,31 @@ _CTX_MAX_COMMENT_BYTES = 2 * 1024 # 2 KB per comment
# Paths
# ---------------------------------------------------------------------------
DEFAULT_BOARD = "default"
# Slug validator: lowercase alphanumerics, digits, hyphens; 164 chars.
# Strict enough to stop traversal (`..`) and embedded path separators, loose
# enough that kebab-case names like ``atm10-server`` or ``hermes-agent``
# pass without fuss. Board names with display formatting (spaces, emoji)
# live in ``board.json``; the slug is just the directory name.
_BOARD_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
def _normalize_board_slug(slug: Optional[str]) -> Optional[str]:
"""Lowercase + strip a slug; validate; return ``None`` for empty."""
if slug is None:
return None
s = str(slug).strip().lower()
if not s:
return None
if not _BOARD_SLUG_RE.match(s):
raise ValueError(
f"invalid board slug {slug!r}: must be 1-64 chars, lowercase "
f"alphanumerics / hyphens / underscores, not starting with '-' or '_'"
)
return s
def kanban_home() -> Path:
"""Return the shared Hermes root that anchors the kanban board.
@ -104,34 +161,390 @@ def kanban_home() -> Path:
return get_default_hermes_root()
def kanban_db_path() -> Path:
"""Return the path to the shared ``kanban.db``.
def boards_root() -> Path:
"""Return ``<root>/kanban/boards`` — the parent of non-default board dirs.
Anchored at :func:`kanban_home`, not the active profile's
``HERMES_HOME``, so profile workers and the dispatcher converge on
the same board. ``HERMES_KANBAN_DB`` pins the path directly (highest
precedence) the dispatcher injects this into worker subprocess env
as defense-in-depth.
``default`` is intentionally NOT under this directory its DB lives at
``<root>/kanban.db`` for back-compat with pre-boards installs. This
function returns the directory where *additional* named boards live,
used by :func:`list_boards` to enumerate them.
"""
return kanban_home() / "kanban" / "boards"
def current_board_path() -> Path:
"""Return the path to ``<root>/kanban/current``.
One-line text file written by ``hermes kanban boards switch <slug>``
to persist the user's board selection across CLI invocations. Absent
by default (meaning: active board is ``default``).
"""
return kanban_home() / "kanban" / "current"
def get_current_board() -> str:
"""Return the active board slug, honouring the resolution chain.
Order (highest precedence first):
1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
spawn, or manually for ad-hoc overrides).
2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
switch``).
3. ``DEFAULT_BOARD`` (``"default"``).
A malformed slug at any step falls through to the next layer with a
best-effort warning the dispatcher must never crash because a user
hand-edited a file.
"""
env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
if env:
try:
normed = _normalize_board_slug(env)
if normed:
return normed
except ValueError:
pass
try:
f = current_board_path()
if f.exists():
val = f.read_text(encoding="utf-8").strip()
if val:
try:
normed = _normalize_board_slug(val)
if normed:
return normed
except ValueError:
pass
except OSError:
pass
return DEFAULT_BOARD
def set_current_board(slug: str) -> Path:
"""Persist ``slug`` as the active board. Returns the file written.
Writes ``<root>/kanban/current``. The caller should validate the slug
exists first (via :func:`board_exists`) this function does not
so that ``hermes kanban boards switch <typo>`` returns an error
instead of silently pointing at nothing.
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
path = current_board_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(normed + "\n", encoding="utf-8")
return path
def clear_current_board() -> None:
"""Remove ``<root>/kanban/current`` so the active board reverts to ``default``."""
try:
current_board_path().unlink()
except FileNotFoundError:
pass
def board_dir(board: Optional[str] = None) -> Path:
"""Return the on-disk directory for ``board``.
``default`` is ``<root>/kanban/boards/default/`` **for metadata only**
(board.json + workspaces/ + logs/). Its DB file stays at
``<root>/kanban.db`` for back-compat see :func:`kanban_db_path`.
All other boards live at ``<root>/kanban/boards/<slug>/`` with
everything inside that directory including the ``kanban.db``.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
return boards_root() / slug
def board_exists(board: Optional[str] = None) -> bool:
"""Return True if the board has a DB or a metadata dir on disk.
``default`` is considered to always exist its DB is created
on first :func:`connect` and there's no way for it to be missing
in a configuration where the kanban feature is usable at all.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
if slug == DEFAULT_BOARD:
return True
d = board_dir(slug)
return d.is_dir() or (d / "kanban.db").exists()
def kanban_db_path(board: Optional[str] = None) -> Path:
"""Return the path to the ``kanban.db`` for ``board``.
Resolution (highest precedence first):
1. ``HERMES_KANBAN_DB`` env var pins the path directly. Honoured for
back-compat and for the dispatcherworker handoff (defense in
depth: dispatcher injects this into worker env so workers are
immune to any path-resolution disagreement).
2. When ``board`` arg is None, the active board from
:func:`get_current_board` is used.
3. Board ``default`` ``<root>/kanban.db`` (back-compat path).
Other boards ``<root>/kanban/boards/<slug>/kanban.db``.
"""
override = os.environ.get("HERMES_KANBAN_DB", "").strip()
if override:
return Path(override).expanduser()
return kanban_home() / "kanban.db"
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban.db"
return board_dir(slug) / "kanban.db"
def workspaces_root() -> Path:
def workspaces_root(board: Optional[str] = None) -> Path:
"""Return the directory under which ``scratch`` workspaces are created.
Anchored at :func:`kanban_home` so workspace paths are stable across
profile workers spawned by the dispatcher.
Anchored per-board so workspaces don't leak between projects.
``HERMES_KANBAN_WORKSPACES_ROOT`` pins the path directly (highest
precedence) the dispatcher injects this into worker subprocess env
as defense-in-depth.
precedence) the dispatcher injects this into worker env.
``default`` keeps the legacy path ``<root>/kanban/workspaces/`` so
that existing scratch workspaces from before the boards feature are
preserved. Other boards use ``<root>/kanban/boards/<slug>/workspaces/``.
"""
override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip()
if override:
return Path(override).expanduser()
return kanban_home() / "kanban" / "workspaces"
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "workspaces"
return board_dir(slug) / "workspaces"
def worker_logs_dir(board: Optional[str] = None) -> Path:
"""Return the directory under which per-task worker logs are written.
``default`` keeps the legacy path ``<root>/kanban/logs/``. Other
boards use ``<root>/kanban/boards/<slug>/logs/``. Logs follow the
board makes ``hermes kanban log`` unambiguous even when multiple
boards have tasks with the same id.
"""
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "logs"
return board_dir(slug) / "logs"
def board_metadata_path(board: Optional[str] = None) -> Path:
"""Return the path to ``board.json`` for ``board``.
Stores display metadata (display name, description, icon, color,
created_at). The on-disk slug is the canonical identity; this file
is purely for presentation in the CLI / dashboard.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
return board_dir(slug) / "board.json"
def _default_board_display_name(slug: str) -> str:
"""Turn a slug into a reasonable default display name.
``atm10-server`` ``Atm10 Server``. Users can override via
``board.json`` but the default should look presentable in the
dashboard without any follow-up editing.
"""
return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part) or slug
def read_board_metadata(board: Optional[str] = None) -> dict:
"""Return ``board.json`` contents (or synthesized defaults).
Never raises a missing / malformed ``board.json`` falls back to a
synthesised entry so the dashboard always has something to render.
Includes the canonical ``slug`` and ``db_path`` so the caller
doesn't need to reconstruct them.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
meta: dict[str, Any] = {
"slug": slug,
"name": _default_board_display_name(slug),
"description": "",
"icon": "",
"color": "",
"created_at": None,
"archived": False,
}
try:
p = board_metadata_path(slug)
if p.exists():
raw = json.loads(p.read_text(encoding="utf-8"))
if isinstance(raw, dict):
# Never let the metadata file claim a different slug than
# its directory — trust the filesystem.
raw["slug"] = slug
meta.update(raw)
except (OSError, json.JSONDecodeError):
pass
meta["db_path"] = str(kanban_db_path(slug))
return meta
def write_board_metadata(
board: Optional[str],
*,
name: Optional[str] = None,
description: Optional[str] = None,
icon: Optional[str] = None,
color: Optional[str] = None,
archived: Optional[bool] = None,
) -> dict:
"""Create / update ``board.json`` for ``board``.
Preserves any existing fields not mentioned in the call. Sets
``created_at`` on first write. Returns the resulting metadata dict.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
meta = read_board_metadata(slug)
# Preserve existing DB-derived fields — they get re-computed each
# read but shouldn't be written into board.json.
meta.pop("db_path", None)
if name is not None:
meta["name"] = str(name).strip() or _default_board_display_name(slug)
if description is not None:
meta["description"] = str(description)
if icon is not None:
meta["icon"] = str(icon)
if color is not None:
meta["color"] = str(color)
if archived is not None:
meta["archived"] = bool(archived)
if not meta.get("created_at"):
meta["created_at"] = int(time.time())
path = board_metadata_path(slug)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
meta["db_path"] = str(kanban_db_path(slug))
return meta
def create_board(
slug: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
icon: Optional[str] = None,
color: Optional[str] = None,
) -> dict:
"""Create a new board directory + DB + metadata. Idempotent.
Returns the resulting metadata. Raises :class:`ValueError` for a
malformed slug; returns the existing metadata (not an error) if the
board already exists matching ``mkdir -p`` semantics.
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
meta = write_board_metadata(
normed,
name=name,
description=description,
icon=icon,
color=color,
)
# Touch the DB so list_boards() sees it immediately.
init_db(board=normed)
return meta
def list_boards(*, include_archived: bool = True) -> list[dict]:
"""Enumerate all boards that exist on disk.
Always includes ``default`` (even when the ``boards/default/``
metadata dir doesn't exist, because its DB is at the legacy path).
Other boards are discovered by scanning ``boards/`` for subdirectories
that either contain a ``kanban.db`` or a ``board.json``.
Returns a list of metadata dicts, sorted with ``default`` first and
the rest alphabetically.
"""
entries: list[dict] = []
seen: set[str] = set()
# Default board is always first.
entries.append(read_board_metadata(DEFAULT_BOARD))
seen.add(DEFAULT_BOARD)
root = boards_root()
if root.is_dir():
for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if not child.is_dir():
continue
slug = child.name
# Keep slug normalisation soft for discovery — but skip dirs
# that don't parse as valid slugs so we don't surface junk.
try:
normed = _normalize_board_slug(slug)
except ValueError:
continue
if not normed or normed in seen:
continue
has_db = (child / "kanban.db").exists()
has_meta = (child / "board.json").exists()
if not (has_db or has_meta):
continue
meta = read_board_metadata(normed)
if meta.get("archived") and not include_archived:
continue
entries.append(meta)
seen.add(normed)
return entries
def remove_board(slug: str, *, archive: bool = True) -> dict:
"""Remove or archive a board.
``archive=True`` (default) moves the board's directory to
``<root>/kanban/boards/_archived/<slug>-<timestamp>/`` so the data
is recoverable. ``archive=False`` deletes the directory outright.
The ``default`` board cannot be removed raises :class:`ValueError`.
Returns a summary dict describing what happened (``{"slug", "action",
"new_path"}``).
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
if normed == DEFAULT_BOARD:
raise ValueError("the 'default' board cannot be removed")
d = board_dir(normed)
if not d.exists():
raise ValueError(f"board {normed!r} does not exist")
# If the user removed the currently-active board, revert to default.
if get_current_board() == normed:
clear_current_board()
if archive:
archive_root = boards_root() / "_archived"
archive_root.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
target = archive_root / f"{normed}-{ts}"
# Avoid collision on rapid double-archives.
suffix = 1
while target.exists():
target = archive_root / f"{normed}-{ts}-{suffix}"
suffix += 1
d.rename(target)
return {"slug": normed, "action": "archived", "new_path": str(target)}
else:
import shutil
shutil.rmtree(d)
return {"slug": normed, "action": "deleted", "new_path": ""}
# ---------------------------------------------------------------------------
@ -429,7 +842,11 @@ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_
_INITIALIZED_PATHS: set[str] = set()
def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
def connect(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
) -> sqlite3.Connection:
"""Open (and initialize if needed) the kanban DB.
WAL mode is enabled on every connection; it's a no-op after the first
@ -439,8 +856,19 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
fresh installs and test harnesses that construct `connect()`
directly don't have to remember a separate init step. Subsequent
connections skip the schema check via a module-level path cache.
Path resolution:
* ``db_path`` explicit used as-is (legacy callers, tests).
* ``board`` explicit resolves to that board's DB.
* Neither :func:`kanban_db_path` resolves via
``HERMES_KANBAN_DB`` env ``HERMES_KANBAN_BOARD`` env
``<root>/kanban/current`` ``default``.
"""
path = db_path or kanban_db_path()
if db_path is not None:
path = db_path
else:
path = kanban_db_path(board=board)
path.parent.mkdir(parents=True, exist_ok=True)
resolved = str(path.resolve())
needs_init = resolved not in _INITIALIZED_PATHS
@ -459,7 +887,11 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
return conn
def init_db(db_path: Optional[Path] = None) -> Path:
def init_db(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
) -> Path:
"""Create the schema if it doesn't exist; return the path used.
Kept as a public entry point so CLI ``hermes kanban init`` and the
@ -470,7 +902,10 @@ def init_db(db_path: Optional[Path] = None) -> Path:
external tools that upgrade an old DB file can call this to
force re-migration.
"""
path = db_path or kanban_db_path()
if db_path is not None:
path = db_path
else:
path = kanban_db_path(board=board)
path.parent.mkdir(parents=True, exist_ok=True)
resolved = str(path.resolve())
# Clear the cache entry so the underlying connect() re-runs the
@ -1574,13 +2009,13 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
# Workspace resolution
# ---------------------------------------------------------------------------
def resolve_workspace(task: Task) -> Path:
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
"""Resolve (and create if needed) the workspace for a task.
- ``scratch``: a fresh dir under ``<kanban-root>/kanban/workspaces/<id>/``,
where ``<kanban-root>`` is the shared Hermes root (see
:func:`kanban_home`). The path is the same for the dispatcher and
every profile worker, so handoff is path-stable.
- ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
where ``<board-root>`` is the active board's root. The path is the
same for the dispatcher and every profile worker, so handoff is
path-stable.
- ``dir:<path>``: the path stored in ``workspace_path``. Created
if missing. MUST be absolute relative paths are rejected to
prevent confused-deputy traversal where ``../../../tmp/attacker``
@ -1607,7 +2042,7 @@ def resolve_workspace(task: Task) -> Path:
f"{task.workspace_path!r}; workspace paths must be absolute"
)
else:
p = workspaces_root() / task.id
p = workspaces_root(board=board) / task.id
p.mkdir(parents=True, exist_ok=True)
return p
if kind == "dir":
@ -2021,6 +2456,7 @@ def dispatch_once(
dry_run: bool = False,
max_spawn: Optional[int] = None,
failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
board: Optional[str] = None,
) -> DispatchResult:
"""Run one dispatcher tick.
@ -2029,15 +2465,17 @@ def dispatch_once(
2. Reclaim crashed running tasks (host-local PID no longer alive).
3. Promote todo -> ready where all parents are done.
4. For each ready task with an assignee, atomically claim and call
``spawn_fn(task, workspace_path) -> Optional[int]``. The return
value (if any) is recorded as ``worker_pid`` so subsequent ticks
can detect crashes before the TTL expires.
``spawn_fn(task, workspace_path, board) -> Optional[int]``. The
return value (if any) is recorded as ``worker_pid`` so subsequent
ticks can detect crashes before the TTL expires.
Spawn failures are counted per-task. After ``failure_limit`` consecutive
failures the task is auto-blocked with the last error as its reason
prevents the dispatcher from thrashing forever on an unfixable task.
``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
``board`` pins workspace/log/db resolution for this tick to a specific
board. When omitted, the current-board resolution chain is used.
"""
result = DispatchResult()
result.reclaimed = release_stale_claims(conn)
@ -2064,7 +2502,7 @@ def dispatch_once(
if claimed is None:
continue
try:
workspace = resolve_workspace(claimed)
workspace = resolve_workspace(claimed, board=board)
except Exception as exc:
auto = _record_spawn_failure(
conn, claimed.id, f"workspace: {exc}",
@ -2077,7 +2515,18 @@ def dispatch_once(
set_workspace_path(conn, claimed.id, str(workspace))
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
try:
pid = _spawn(claimed, str(workspace))
# Back-compat: older spawn_fn signatures accept only
# (task, workspace). Test stubs in the suite rely on that.
# Introspect the callable and pass `board` only when supported.
import inspect
try:
sig = inspect.signature(_spawn)
if "board" in sig.parameters:
pid = _spawn(claimed, str(workspace), board=board)
else:
pid = _spawn(claimed, str(workspace))
except (TypeError, ValueError):
pid = _spawn(claimed, str(workspace))
if pid:
_set_worker_pid(conn, claimed.id, int(pid))
_clear_spawn_failures(conn, claimed.id)
@ -2116,13 +2565,23 @@ def _rotate_worker_log(log_path: Path, max_bytes: int) -> None:
pass
def _default_spawn(task: Task, workspace: str) -> Optional[int]:
def _default_spawn(
task: Task,
workspace: str,
*,
board: Optional[str] = None,
) -> Optional[int]:
"""Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.
Returns the spawned child's PID so the dispatcher can detect crashes
before the claim TTL expires. The child's completion is still observed
via the ``complete`` / ``block`` transitions the worker writes itself;
the PID check is a safety net for crashes, OOM kills, and Ctrl+C.
``board`` pins the child's kanban context to that board: the child's
``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root env
vars all resolve to the same board the dispatcher claimed the task
from. Workers cannot accidentally see other boards.
"""
import subprocess
if not task.assignee:
@ -2140,8 +2599,13 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]:
# dispatcher's. Belt-and-braces with the `get_default_hermes_root()`
# resolution in `kanban_home()` — symmetric resolution is the norm,
# but unusual symlink / Docker layouts are caught here too.
env["HERMES_KANBAN_DB"] = str(kanban_db_path())
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root())
env["HERMES_KANBAN_DB"] = str(kanban_db_path(board=board))
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root(board=board))
# Board slug — the final defense-in-depth pin. If the worker ever
# resolves kanban paths without the DB / workspaces env vars, the
# board slug still forces it to the right directory.
resolved_board = _normalize_board_slug(board) or get_current_board()
env["HERMES_KANBAN_BOARD"] = resolved_board
# HERMES_PROFILE is the author the kanban_comment tool defaults to.
# `hermes -p <assignee>` activates the profile, but the env var is
# what the tool reads — set it explicitly here so comments are
@ -2176,10 +2640,11 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]:
"chat",
"-q", prompt,
])
# Redirect output to a per-task log under <kanban-root>/kanban/logs/.
# Anchored at the shared kanban root, not the worker's profile home,
# so `hermes kanban tail` reads the same file the worker writes to.
log_dir = kanban_home() / "kanban" / "logs"
# Redirect output to a per-task log under <board-root>/logs/.
# Anchored at the board root (not the shared kanban root), so
# `hermes kanban log` on a specific board reads its own file and
# logs don't collide across boards that happen to share task ids.
log_dir = worker_logs_dir(board=board)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{task.id}.log"
_rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES)
@ -2660,11 +3125,14 @@ def gc_events(
def gc_worker_logs(
*, older_than_seconds: int = 30 * 24 * 3600,
board: Optional[str] = None,
) -> int:
"""Delete worker log files older than ``older_than_seconds``. Returns
the number of files removed. Kept separate from ``gc_events`` because
log files live on disk, not in SQLite."""
log_dir = kanban_home() / "kanban" / "logs"
log files live on disk, not in SQLite. Scoped to ``board`` (defaults
to the active board) per-board isolation means deleting logs from
board A cannot touch board B's logs."""
log_dir = worker_logs_dir(board=board)
if not log_dir.exists():
return 0
cutoff = time.time() - older_than_seconds
@ -2683,19 +3151,25 @@ def gc_worker_logs(
# Worker log accessor
# ---------------------------------------------------------------------------
def worker_log_path(task_id: str) -> Path:
def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path:
"""Return the path to a worker's log file. The file may not exist
(task never spawned, or log already GC'd)."""
return kanban_home() / "kanban" / "logs" / f"{task_id}.log"
(task never spawned, or log already GC'd).
When ``board`` is None, resolves via the active board (env var
current-board file default). The dispatcher always passes the
board explicitly to avoid any resolution ambiguity when multiple
boards exist."""
return worker_logs_dir(board=board) / f"{task_id}.log"
def read_worker_log(
task_id: str, *, tail_bytes: Optional[int] = None,
board: Optional[str] = None,
) -> Optional[str]:
"""Read the worker log for ``task_id``. Returns None if the file
doesn't exist. If ``tail_bytes`` is set, only the last N bytes are
returned (useful for the dashboard drawer which shouldn't page megabytes)."""
path = worker_log_path(task_id)
path = worker_log_path(task_id, board=board)
if not path.exists():
return None
try:

View file

@ -63,6 +63,53 @@
const API = "/api/plugins/kanban";
const MIME_TASK = "text/x-hermes-task";
// localStorage key for the user's selected board. Independent of the
// CLI's on-disk ``<root>/kanban/current`` pointer so browser users
// can inspect any board without shifting the CLI's active board out
// from under a terminal they left open.
const LS_BOARD_KEY = "hermes.kanban.selectedBoard";
function readSelectedBoard() {
try {
const v = window.localStorage.getItem(LS_BOARD_KEY);
return (v || "").trim() || null;
} catch (_e) { return null; }
}
function writeSelectedBoard(slug) {
try {
if (slug && slug !== "default") window.localStorage.setItem(LS_BOARD_KEY, slug);
else window.localStorage.removeItem(LS_BOARD_KEY);
} catch (_e) { /* ignore quota / private mode */ }
}
function withBoard(url, board) {
// Append ?board=<slug> when a non-default board is active. Omitted
// for default so the URL stays clean and the backend falls through
// to its own resolution chain (env var → ``current`` file →
// default) which is already correct.
if (!board || board === "default") return url;
const sep = url.indexOf("?") >= 0 ? "&" : "?";
return `${url}${sep}board=${encodeURIComponent(board)}`;
}
// The SDK's Select component fires ``onValueChange(value)`` directly
// (it's a shadcn-style popup, not a native <select>). Older plugin
// code calls ``onChange({target: {value}})`` which silently never
// fires. This helper wires both signatures so a setter works with
// either API — use it as:
//
// h(Select, {..., ...selectChangeHandler(setState), ...})
function selectChangeHandler(setter) {
return {
onValueChange: function (v) { setter(v == null ? "" : v); },
onChange: function (e) {
const v = e && e.target ? e.target.value : e;
setter(v == null ? "" : v);
},
};
}
// -------------------------------------------------------------------------
// Minimal safe markdown renderer.
//
@ -245,7 +292,19 @@
// -------------------------------------------------------------------------
function KanbanPage() {
const [board, setBoard] = useState(null);
const [board, setBoard] = useState(() => readSelectedBoard() || "default");
const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}]
const [showNewBoard, setShowNewBoard] = useState(false);
const [kanbanBoard, setKanbanBoard] = useState(null); // the grid data
// Alias so the rest of the function can keep using `board` semantically
// for the grid data (card columns + tenants + assignees) without
// colliding with the selected-board slug above. History: the old
// component had `const [board, setBoard]` for the grid data. We
// renamed the grid data to `kanbanBoard` so the more useful name
// (`board`) belongs to the selected slug.
const boardData = kanbanBoard;
const setBoardData = setKanbanBoard;
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@ -292,9 +351,9 @@
if (tenantFilter) qs.set("tenant", tenantFilter);
if (includeArchived) qs.set("include_archived", "true");
const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`;
return SDK.fetchJSON(url)
return SDK.fetchJSON(withBoard(url, board))
.then(function (data) {
setBoard(data);
setBoardData(data);
cursorRef.current = data.latest_event_id || 0;
setError(null);
})
@ -302,7 +361,26 @@
setError(String(err && err.message ? err.message : err));
})
.finally(function () { setLoading(false); });
}, [tenantFilter, includeArchived]);
}, [tenantFilter, includeArchived, board]);
// --- load list of boards for the switcher ------------------------------
const loadBoardList = useCallback(function () {
return SDK.fetchJSON(`${API}/boards`)
.then(function (data) {
const boards = (data && data.boards) || [];
setBoardList(boards);
// If the stored slug isn't in the list any longer (board was
// deleted in the CLI while dashboard was open), fall back to
// default so the UI doesn't hang on a 404.
if (board !== "default" && !boards.find(function (b) { return b.slug === board; })) {
setBoard("default");
writeSelectedBoard("default");
}
})
.catch(function () { /* non-fatal */ });
}, [board]);
useEffect(function () { loadBoardList(); }, [loadBoardList]);
const scheduleReload = useCallback(function () {
if (reloadTimerRef.current) return;
@ -324,16 +402,21 @@
// --- WebSocket ---------------------------------------------------------
useEffect(function () {
if (!board) return undefined;
if (!boardData) return undefined;
wsClosedRef.current = false;
function openWs() {
if (wsClosedRef.current) return;
const token = window.__HERMES_SESSION_TOKEN__ || "";
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({
const qsParams = {
since: String(cursorRef.current || 0),
token: token,
});
};
// Pin the WS stream to the currently-selected board so events
// from other boards don't bleed in. Only set for non-default so
// single-board installs keep the cleaner URL.
if (board && board !== "default") qsParams.board = board;
const qs = new URLSearchParams(qsParams);
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
let ws;
try { ws = new WebSocket(url); } catch (_e) { return; }
@ -372,11 +455,11 @@
wsClosedRef.current = true;
try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ }
};
}, [!!board, scheduleReload]);
}, [!!boardData, board, scheduleReload]);
// --- filtering ----------------------------------------------------------
const filteredBoard = useMemo(function () {
if (!board) return null;
if (!boardData) return null;
const q = search.trim().toLowerCase();
const filterTask = function (t) {
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
@ -386,18 +469,18 @@
}
return true;
};
return Object.assign({}, board, {
columns: board.columns.map(function (col) {
return Object.assign({}, boardData, {
columns: boardData.columns.map(function (col) {
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
}),
});
}, [board, assigneeFilter, search]);
}, [boardData, assigneeFilter, search]);
// --- actions ------------------------------------------------------------
const moveTask = useCallback(function (taskId, newStatus) {
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
if (confirmMsg && !window.confirm(confirmMsg)) return;
setBoard(function (b) {
setBoardData(function (b) {
if (!b) return b;
let moved = null;
const columns = b.columns.map(function (col) {
@ -413,7 +496,7 @@
}
return Object.assign({}, b, { columns });
});
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, {
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
@ -421,10 +504,10 @@
setError(`Move failed: ${err.message || err}`);
loadBoard();
});
}, [loadBoard]);
}, [loadBoard, board]);
const createTask = useCallback(function (body) {
return SDK.fetchJSON(`${API}/tasks`, {
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@ -437,9 +520,10 @@
setError("Task created, but: " + res.warning);
}
loadBoard();
loadBoardList(); // refresh counts in the switcher
return res;
});
}, [loadBoard]);
}, [loadBoard, loadBoardList, board]);
const toggleSelected = useCallback(function (id, additive) {
setSelectedIds(function (prev) {
@ -455,7 +539,7 @@
if (selectedIds.size === 0) return;
if (confirmMsg && !window.confirm(confirmMsg)) return;
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
SDK.fetchJSON(`${API}/tasks/bulk`, {
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@ -470,14 +554,50 @@
loadBoard();
})
.catch(function (e) { setError(String(e.message || e)); });
}, [selectedIds, loadBoard, clearSelected]);
}, [selectedIds, loadBoard, clearSelected, board]);
// --- board switching ----------------------------------------------------
const switchBoard = useCallback(function (nextSlug) {
if (!nextSlug || nextSlug === board) return;
// Optimistic UI: clear the current grid + show loading, reset the
// event cursor so the WS reopens aligned to the new board's
// latest_event_id on the next loadBoard.
setBoardData(null);
cursorRef.current = 0;
setLoading(true);
setBoard(nextSlug);
writeSelectedBoard(nextSlug);
}, [board]);
const createNewBoard = useCallback(function (payload) {
return SDK.fetchJSON(`${API}/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then(function (res) {
loadBoardList();
const slug = res && res.board && res.board.slug;
if (slug && payload.switch) switchBoard(slug);
return res;
});
}, [loadBoardList, switchBoard]);
const deleteBoard = useCallback(function (slug) {
if (!slug || slug === "default") return Promise.resolve();
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
method: "DELETE",
}).then(function () {
loadBoardList();
if (board === slug) switchBoard("default");
});
}, [board, loadBoardList, switchBoard]);
// --- render -------------------------------------------------------------
if (loading && !board) {
if (loading && !boardData) {
return h("div", { className: "p-8 text-sm text-muted-foreground" },
"Loading Kanban board…");
}
if (error && !board) {
if (error && !boardData) {
return h(Card, null,
h(CardContent, { className: "p-6" },
h("div", { className: "text-sm text-destructive" },
@ -493,15 +613,28 @@
return h(ErrorBoundary, null,
h("div", { className: "hermes-kanban flex flex-col gap-4" },
h(BoardToolbar, {
h(BoardSwitcher, {
board: board,
boardList: boardList,
onSwitch: switchBoard,
onNewClick: function () { setShowNewBoard(true); },
onDeleteBoard: deleteBoard,
}),
showNewBoard ? h(NewBoardDialog, {
onCancel: function () { setShowNewBoard(false); },
onCreate: function (payload) {
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
},
}) : null,
h(BoardToolbar, {
board: boardData,
tenantFilter, setTenantFilter,
assigneeFilter, setAssigneeFilter,
includeArchived, setIncludeArchived,
laneByProfile, setLaneByProfile,
search, setSearch,
onNudgeDispatch: function () {
SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" })
SDK.fetchJSON(withBoard(`${API}/dispatch?max=8`, board), { method: "POST" })
.then(loadBoard)
.catch(function (e) { setError(String(e.message || e)); });
},
@ -509,7 +642,7 @@
}),
selectedIds.size > 0 ? h(BulkActionBar, {
count: selectedIds.size,
assignees: (board && board.assignees) || [],
assignees: (boardData && boardData.assignees) || [],
onApply: applyBulk,
onClear: clearSelected,
}) : null,
@ -522,20 +655,215 @@
onMove: moveTask,
onOpen: setSelectedTaskId,
onCreate: createTask,
allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
}),
selectedTaskId ? h(TaskDrawer, {
taskId: selectedTaskId,
boardSlug: board,
onClose: function () { setSelectedTaskId(null); },
onRefresh: loadBoard,
renderMarkdown: renderMd,
allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
eventTick: taskEventTick[selectedTaskId] || 0,
}) : null,
),
);
}
// -------------------------------------------------------------------------
// Board switcher (multi-project)
// -------------------------------------------------------------------------
function BoardSwitcher(props) {
const list = props.boardList || [];
const current = list.find(function (b) { return b.slug === props.board; });
const currentName = current && current.name ? current.name : props.board;
const currentTotal = current ? current.total : 0;
const hasMultipleBoards = list.length > 1;
// Hide entirely when only the default board exists AND it's empty —
// single-project users never see boards UI unless they ask for it.
// We show the [+ New board] affordance as soon as any board has a
// task (so the user can discover multi-project before they need it)
// OR when any non-default board exists.
const totalAcrossAllBoards = list.reduce(function (n, b) { return n + (b.total || 0); }, 0);
const shouldShow = hasMultipleBoards || totalAcrossAllBoards > 0;
if (!shouldShow) {
return h("div", {
className: "hermes-kanban-boardswitcher-compact",
title: "Boards let you separate unrelated streams of work",
},
h(Button, {
onClick: props.onNewClick,
size: "sm",
className: "h-7 text-xs",
}, "+ New board"),
);
}
return h("div", { className: "hermes-kanban-boardswitcher" },
h("div", { className: "hermes-kanban-boardswitcher-inner" },
h("div", { className: "flex flex-col gap-0.5" },
h("div", { className: "text-[11px] uppercase tracking-wider text-muted-foreground" },
"Board"),
h("div", { className: "flex items-center gap-2" },
h(Select, Object.assign({
value: props.board,
className: "h-8 min-w-[220px]",
"aria-label": "Switch kanban board",
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
list.map(function (b) {
const label = b.total > 0
? `${b.name || b.slug} · ${b.total}`
: (b.name || b.slug);
return h(SelectOption, { key: b.slug, value: b.slug }, label);
}),
),
h("span", { className: "text-xs text-muted-foreground" },
`${currentTotal || 0} task${currentTotal === 1 ? "" : "s"}`),
),
),
h("div", { className: "flex-1" }),
h(Button, {
onClick: props.onNewClick,
size: "sm",
className: "h-8",
}, "+ New board"),
props.board !== "default"
? h(Button, {
onClick: function () {
const msg =
`Archive board '${currentName}'? ` +
`It will be moved to boards/_archived/ so you can recover it later. ` +
`Tasks on this board will no longer appear anywhere in the UI.`;
if (window.confirm(msg)) props.onDeleteBoard(props.board);
},
size: "sm",
className: "h-8",
title: "Archive this board",
}, "Archive")
: null,
),
);
}
function NewBoardDialog(props) {
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [switchTo, setSwitchTo] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState(null);
// Auto-derive a name from the slug if the user hasn't typed one.
const autoName = useMemo(function () {
if (!slug) return "";
return slug.replace(/[-_]+/g, " ")
.split(" ")
.filter(Boolean)
.map(function (w) { return w[0].toUpperCase() + w.slice(1); })
.join(" ");
}, [slug]);
function onSubmit(ev) {
if (ev) ev.preventDefault();
if (!slug.trim()) { setErr("slug is required"); return; }
setSubmitting(true);
setErr(null);
props.onCreate({
slug: slug.trim(),
name: name.trim() || autoName || undefined,
description: description.trim() || undefined,
icon: icon.trim() || undefined,
switch: switchTo,
}).catch(function (e) {
setErr(String(e && e.message ? e.message : e));
setSubmitting(false);
});
}
return h("div", {
className: "hermes-kanban-dialog-backdrop",
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
},
h("form", {
className: "hermes-kanban-dialog",
onSubmit: onSubmit,
},
h("div", { className: "hermes-kanban-dialog-title" }, "New board"),
h("div", { className: "text-xs text-muted-foreground mb-2" },
"Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks."),
h("div", { className: "flex flex-col gap-3" },
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Slug ",
h("span", { className: "text-muted-foreground" },
"— lowercase, hyphens, e.g. atm10-server")),
h(Input, {
value: slug,
onChange: function (e) { setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9\-_]/g, "-")); },
placeholder: "atm10-server",
autoFocus: true,
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Display name ",
h("span", { className: "text-muted-foreground" }, "(optional)")),
h(Input, {
value: name,
onChange: function (e) { setName(e.target.value); },
placeholder: autoName || "Display name",
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Description ",
h("span", { className: "text-muted-foreground" }, "(optional)")),
h(Input, {
value: description,
onChange: function (e) { setDescription(e.target.value); },
placeholder: "What goes on this board?",
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Icon ",
h("span", { className: "text-muted-foreground" }, "(single character or emoji)")),
h(Input, {
value: icon,
onChange: function (e) { setIcon(e.target.value.slice(0, 4)); },
placeholder: "📦",
className: "h-8 w-24",
}),
),
h("label", { className: "flex items-center gap-2 text-xs" },
h("input", {
type: "checkbox",
checked: switchTo,
onChange: function (e) { setSwitchTo(e.target.checked); },
}),
"Switch to this board after creating it",
),
),
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
h("div", { className: "hermes-kanban-dialog-actions" },
h(Button, {
type: "button",
onClick: props.onCancel,
size: "sm",
disabled: submitting,
}, "Cancel"),
h(Button, {
type: "submit",
size: "sm",
disabled: submitting || !slug.trim(),
}, submitting ? "Creating…" : "Create board"),
),
),
);
}
// -------------------------------------------------------------------------
// Toolbar
// -------------------------------------------------------------------------
@ -555,11 +883,10 @@
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
h(Select, {
h(Select, Object.assign({
value: props.tenantFilter,
onChange: function (e) { props.setTenantFilter(e.target.value); },
className: "h-8",
},
}, selectChangeHandler(props.setTenantFilter)),
h(SelectOption, { value: "" }, "All tenants"),
tenants.map(function (t) {
return h(SelectOption, { key: t, value: t }, t);
@ -568,11 +895,10 @@
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
h(Select, {
h(Select, Object.assign({
value: props.assigneeFilter,
onChange: function (e) { props.setAssigneeFilter(e.target.value); },
className: "h-8",
},
}, selectChangeHandler(props.setAssigneeFilter)),
h(SelectOption, { value: "" }, "All profiles"),
assignees.map(function (a) {
return h(SelectOption, { key: a, value: a }, a);
@ -1049,13 +1375,14 @@
const [err, setErr] = useState(null);
const [newComment, setNewComment] = useState("");
const [editing, setEditing] = useState(false);
const boardSlug = props.boardSlug;
const load = useCallback(function () {
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`)
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug))
.then(function (d) { setData(d); setErr(null); })
.catch(function (e) { setErr(String(e.message || e)); })
.finally(function () { setLoading(false); });
}, [props.taskId]);
}, [props.taskId, boardSlug]);
// Reload when the WS stream reports new events for this task id
// (completion, block, crash, etc. — anything that'd make the drawer
@ -1070,7 +1397,7 @@
const handleComment = function () {
const body = newComment.trim();
if (!body) return;
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, {
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, boardSlug), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
@ -1085,7 +1412,7 @@
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
return Promise.resolve();
}
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, {
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
@ -1093,7 +1420,7 @@
};
const addLink = function (parentId) {
return SDK.fetchJSON(`${API}/links`, {
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }),
@ -1102,12 +1429,12 @@
};
const removeLink = function (parentId) {
const qs = new URLSearchParams({ parent_id: parentId, child_id: props.taskId });
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
return SDK.fetchJSON(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setErr(String(e.message || e)); });
};
const addChild = function (childId) {
return SDK.fetchJSON(`${API}/links`, {
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_id: props.taskId, child_id: childId }),
@ -1116,7 +1443,7 @@
};
const removeChild = function (childId) {
const qs = new URLSearchParams({ parent_id: props.taskId, child_id: childId });
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
return SDK.fetchJSON(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setErr(String(e.message || e)); });
};
@ -1141,6 +1468,7 @@
data, editing, setEditing,
renderMarkdown: props.renderMarkdown,
allTasks: props.allTasks,
boardSlug: boardSlug,
onPatch: doPatch,
onAddParent: addLink,
onRemoveParent: removeLink,
@ -1253,7 +1581,7 @@
);
}),
),
h(WorkerLogSection, { taskId: t.id }),
h(WorkerLogSection, { taskId: t.id, boardSlug: props.boardSlug }),
h(RunHistorySection, { runs: props.data.runs || [] }),
);
}
@ -1324,10 +1652,10 @@
const [state, setState] = useState({ loading: false, data: null, err: null });
const load = useCallback(function () {
setState({ loading: true, data: null, err: null });
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`)
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`, props.boardSlug))
.then(function (d) { setState({ loading: false, data: d, err: null }); })
.catch(function (e) { setState({ loading: false, data: null, err: String(e.message || e) }); });
}, [props.taskId]);
}, [props.taskId, props.boardSlug]);
// Auto-load when the section mounts; the user opened the drawer so the
// cost is one small HTTP round-trip.

View file

@ -769,3 +769,57 @@
word-break: break-word;
font-family: var(--font-mono, ui-monospace, monospace);
}
/* -------------------------------------------------------------------------
Multi-project: board switcher + create-board dialog
------------------------------------------------------------------------- */
.hermes-kanban-boardswitcher {
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
border-radius: 0.5rem;
padding: 0.6rem 0.85rem;
background: var(--color-card-subtle, rgba(255, 255, 255, 0.02));
}
.hermes-kanban-boardswitcher-inner {
display: flex;
align-items: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.hermes-kanban-boardswitcher-compact {
display: flex;
justify-content: flex-end;
padding: 0 0.25rem;
}
.hermes-kanban-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(8, 10, 16, 0.55);
backdrop-filter: blur(2px);
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
}
.hermes-kanban-dialog {
background: var(--color-card, #121421);
color: var(--color-foreground);
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
border-radius: 0.5rem;
padding: 1.1rem 1.2rem 1rem;
width: 28rem;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 3rem);
overflow: auto;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
}
.hermes-kanban-dialog-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.hermes-kanban-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}

View file

@ -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 "

View file

@ -0,0 +1,483 @@
"""Tests for the multi-board kanban layer (``hermes kanban boards …``).
Covers the pieces added when boards became a first-class concept:
* Slug validation and normalisation.
* Path resolution for ``default`` (legacy ``<root>/kanban.db``) vs
named boards (``<root>/kanban/boards/<slug>/kanban.db``).
* Current-board persistence via ``<root>/kanban/current`` and
``HERMES_KANBAN_BOARD`` env var.
* ``connect(board=)`` isolation writes on one board don't leak.
* ``create_board`` / ``list_boards`` / ``remove_board`` round trip.
* CLI surface: ``hermes kanban boards list/create/switch/rm``.
* ``_default_spawn`` injects ``HERMES_KANBAN_BOARD`` into worker env.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
# Ensure the worktree (not the stale global clone) is first on sys.path.
_WORKTREE = Path(__file__).resolve().parents[2]
if str(_WORKTREE) not in sys.path:
sys.path.insert(0, str(_WORKTREE))
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def fresh_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with no prior kanban state.
The autouse hermetic conftest already nukes credentials + TZ; this
fixture layers a per-test HERMES_HOME plus a path-init cache reset
so each test sees a truly empty board set.
"""
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
for var in (
"HERMES_KANBAN_DB",
"HERMES_KANBAN_WORKSPACES_ROOT",
"HERMES_KANBAN_HOME",
"HERMES_KANBAN_BOARD",
):
monkeypatch.delenv(var, raising=False)
# Also reset hermes_constants cache so get_default_hermes_root() re-reads.
try:
import hermes_constants
hermes_constants._cached_default_hermes_root = None # type: ignore[attr-defined]
except Exception:
pass
# Kanban module-level init cache must not leak between tests.
kb._INITIALIZED_PATHS.clear()
return home
# ---------------------------------------------------------------------------
# Slug validation
# ---------------------------------------------------------------------------
class TestSlugValidation:
@pytest.mark.parametrize("good", [
"default", "atm10-server", "hermes-agent", "proj_1", "a",
"very-long-but-still-ok-slug-with-hyphens-and-numbers-1234",
])
def test_accepts_valid(self, good):
assert kb._normalize_board_slug(good) == good
@pytest.mark.parametrize("bad", [
"-leading-hyphen", "_leading_underscore",
"with/slash", "with space",
"has.dot", "has?question",
"..", "../etc", "foo\x00bar",
])
def test_rejects_invalid(self, bad):
with pytest.raises(ValueError):
kb._normalize_board_slug(bad)
def test_empty_returns_none(self):
assert kb._normalize_board_slug(None) is None
assert kb._normalize_board_slug("") is None
assert kb._normalize_board_slug(" ") is None
def test_auto_lowercases(self):
# Uppercase is auto-downcased (friendlier than rejecting). ``Default``
# → ``default``, ``ATM10`` → ``atm10``. The on-disk slug is always
# lowercase regardless of what the user typed.
assert kb._normalize_board_slug("Default") == "default"
assert kb._normalize_board_slug("ATM10-Server") == "atm10-server"
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
class TestPathResolution:
def test_default_board_legacy_path(self, fresh_home):
"""The default board's DB lives at ``<root>/kanban.db`` for back-compat."""
assert kb.kanban_db_path() == fresh_home / "kanban.db"
assert kb.kanban_db_path(board="default") == fresh_home / "kanban.db"
def test_named_board_under_boards_dir(self, fresh_home):
p = kb.kanban_db_path(board="atm10-server")
assert p == fresh_home / "kanban" / "boards" / "atm10-server" / "kanban.db"
def test_workspaces_per_board(self, fresh_home):
assert kb.workspaces_root() == fresh_home / "kanban" / "workspaces"
# Uppercase input gets auto-downcased to the on-disk slug.
assert kb.workspaces_root(board="projA") == (
fresh_home / "kanban" / "boards" / "proja" / "workspaces"
)
def test_logs_per_board(self, fresh_home):
assert kb.worker_logs_dir() == fresh_home / "kanban" / "logs"
assert kb.worker_logs_dir(board="other") == (
fresh_home / "kanban" / "boards" / "other" / "logs"
)
def test_env_var_db_override_still_wins(self, fresh_home, tmp_path, monkeypatch):
"""``HERMES_KANBAN_DB`` pins the file regardless of board= arg."""
forced = tmp_path / "custom.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(forced))
assert kb.kanban_db_path() == forced
assert kb.kanban_db_path(board="ignored") == forced
def test_env_var_workspaces_override(self, fresh_home, tmp_path, monkeypatch):
forced = tmp_path / "ws"
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(forced))
assert kb.workspaces_root(board="any") == forced
# ---------------------------------------------------------------------------
# Current-board resolution
# ---------------------------------------------------------------------------
class TestCurrentBoard:
def test_default_when_unset(self, fresh_home):
assert kb.get_current_board() == "default"
def test_env_var_takes_precedence(self, fresh_home, monkeypatch):
# Create the board so the env-var value is honoured (get_current_board
# trusts env-var validity, but the resolution chain doesn't require
# the board to exist; we just test that env trumps).
kb.create_board("envboard")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envboard")
assert kb.get_current_board() == "envboard"
def test_file_pointer_honoured(self, fresh_home):
kb.create_board("filepick")
kb.set_current_board("filepick")
assert kb.get_current_board() == "filepick"
def test_env_beats_file(self, fresh_home, monkeypatch):
kb.create_board("a")
kb.create_board("b")
kb.set_current_board("a")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "b")
assert kb.get_current_board() == "b"
def test_invalid_env_falls_through(self, fresh_home, monkeypatch):
monkeypatch.setenv("HERMES_KANBAN_BOARD", "!!bad!!")
# Should not crash — falls through to default.
assert kb.get_current_board() == "default"
def test_clear_current_board(self, fresh_home):
kb.create_board("x")
kb.set_current_board("x")
kb.clear_current_board()
assert kb.get_current_board() == "default"
def test_kanban_db_path_reads_current(self, fresh_home):
"""kanban_db_path() with no args respects the on-disk pointer."""
kb.create_board("my-proj")
kb.set_current_board("my-proj")
expected = fresh_home / "kanban" / "boards" / "my-proj" / "kanban.db"
assert kb.kanban_db_path() == expected
# ---------------------------------------------------------------------------
# Board CRUD
# ---------------------------------------------------------------------------
class TestBoardCRUD:
def test_create_and_list(self, fresh_home):
assert [b["slug"] for b in kb.list_boards()] == ["default"]
kb.create_board("foo", name="Foo Board", description="test")
slugs = [b["slug"] for b in kb.list_boards()]
assert slugs == ["default", "foo"]
def test_create_is_idempotent(self, fresh_home):
kb.create_board("bar")
kb.create_board("bar") # no error
slugs = [b["slug"] for b in kb.list_boards()]
assert slugs == ["default", "bar"]
def test_create_writes_metadata(self, fresh_home):
meta = kb.create_board(
"baz",
name="Baz",
description="desc",
icon="📦",
color="#abcdef",
)
assert meta["slug"] == "baz"
assert meta["name"] == "Baz"
assert meta["icon"] == "📦"
# Round-trip via read_board_metadata.
again = kb.read_board_metadata("baz")
assert again["name"] == "Baz"
assert again["description"] == "desc"
assert again["icon"] == "📦"
def test_remove_archive(self, fresh_home):
kb.create_board("toremove")
res = kb.remove_board("toremove")
assert res["action"] == "archived"
assert Path(res["new_path"]).exists()
assert "toremove" not in [b["slug"] for b in kb.list_boards()]
def test_remove_hard_delete(self, fresh_home):
kb.create_board("nuke")
d = kb.board_dir("nuke")
assert d.exists()
res = kb.remove_board("nuke", archive=False)
assert res["action"] == "deleted"
assert not d.exists()
def test_remove_default_forbidden(self, fresh_home):
with pytest.raises(ValueError, match="default"):
kb.remove_board("default")
def test_remove_nonexistent_raises(self, fresh_home):
with pytest.raises(ValueError, match="does not exist"):
kb.remove_board("nosuch")
def test_remove_clears_current_pointer(self, fresh_home):
kb.create_board("pinned")
kb.set_current_board("pinned")
kb.remove_board("pinned")
assert kb.get_current_board() == "default"
def test_rename_updates_metadata(self, fresh_home):
kb.create_board("slug-immutable")
kb.write_board_metadata("slug-immutable", name="New Display Name")
assert kb.read_board_metadata("slug-immutable")["name"] == "New Display Name"
# Slug must not change.
assert kb.board_exists("slug-immutable")
# ---------------------------------------------------------------------------
# Connection isolation
# ---------------------------------------------------------------------------
class TestConnectionIsolation:
def test_tasks_do_not_leak_across_boards(self, fresh_home):
kb.create_board("alpha")
kb.create_board("beta")
with kb.connect(board="alpha") as conn:
kb.create_task(conn, title="alpha-task-1", assignee="dev")
kb.create_task(conn, title="alpha-task-2", assignee="dev")
with kb.connect(board="beta") as conn:
kb.create_task(conn, title="beta-only", assignee="dev")
with kb.connect(board="alpha") as conn:
a = kb.list_tasks(conn)
with kb.connect(board="beta") as conn:
b = kb.list_tasks(conn)
with kb.connect(board="default") as conn:
d = kb.list_tasks(conn)
assert {t.title for t in a} == {"alpha-task-1", "alpha-task-2"}
assert {t.title for t in b} == {"beta-only"}
assert d == []
def test_connect_without_args_uses_current(self, fresh_home):
kb.create_board("curr")
kb.set_current_board("curr")
with kb.connect() as conn:
kb.create_task(conn, title="implicit", assignee="x")
with kb.connect(board="curr") as conn:
tasks = kb.list_tasks(conn)
assert [t.title for t in tasks] == ["implicit"]
def test_connect_env_var_overrides_current(self, fresh_home, monkeypatch):
kb.create_board("persist")
kb.create_board("envwin")
kb.set_current_board("persist")
monkeypatch.setenv("HERMES_KANBAN_BOARD", "envwin")
with kb.connect() as conn:
kb.create_task(conn, title="via-env", assignee="x")
with kb.connect(board="envwin") as conn:
assert [t.title for t in kb.list_tasks(conn)] == ["via-env"]
with kb.connect(board="persist") as conn:
assert kb.list_tasks(conn) == []
# ---------------------------------------------------------------------------
# Worker spawn env injection
# ---------------------------------------------------------------------------
class TestWorkerSpawnEnv:
"""Ensure the dispatcher pins ``HERMES_KANBAN_BOARD`` / DB / workspaces on spawn.
We monkey-patch ``subprocess.Popen`` to capture the child env without
actually spawning anything.
"""
def test_default_spawn_sets_env_vars(self, fresh_home, monkeypatch):
captured = {}
class FakeProc:
pid = 12345
def fake_popen(cmd, *args, **kwargs):
captured["cmd"] = cmd
captured["env"] = kwargs.get("env", {})
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
kb.create_board("spawntest")
task = kb.Task(
id="t_abc",
title="worker test",
body=None,
assignee="teknium",
status="ready",
priority=0,
created_by="user",
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(fresh_home / "ws"), board="spawntest")
env = captured["env"]
assert env["HERMES_KANBAN_BOARD"] == "spawntest"
assert env["HERMES_KANBAN_TASK"] == "t_abc"
# DB path should match the per-board DB, not the legacy default.
expected_db = fresh_home / "kanban" / "boards" / "spawntest" / "kanban.db"
assert env["HERMES_KANBAN_DB"] == str(expected_db)
expected_ws = fresh_home / "kanban" / "boards" / "spawntest" / "workspaces"
assert env["HERMES_KANBAN_WORKSPACES_ROOT"] == str(expected_ws)
def test_default_board_spawn_keeps_legacy_paths(self, fresh_home, monkeypatch):
captured = {}
class FakeProc:
pid = 1
def fake_popen(cmd, *args, **kwargs):
captured["env"] = kwargs.get("env", {})
return FakeProc()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
task = kb.Task(
id="t_def",
title="",
body=None,
assignee="teknium",
status="ready",
priority=0,
created_by=None,
created_at=0,
started_at=None,
completed_at=None,
workspace_kind="scratch",
workspace_path=None,
claim_lock=None,
claim_expires=None,
tenant=None,
)
kb._default_spawn(task, str(fresh_home / "ws"), board=None)
env = captured["env"]
assert env["HERMES_KANBAN_BOARD"] == "default"
assert env["HERMES_KANBAN_DB"] == str(fresh_home / "kanban.db")
# ---------------------------------------------------------------------------
# CLI surface
# ---------------------------------------------------------------------------
def _cli(args: list[str], env_extra: dict | None = None) -> subprocess.CompletedProcess:
"""Run ``hermes kanban …`` with PYTHONPATH pinned to the worktree."""
env = dict(os.environ)
env["PYTHONPATH"] = str(_WORKTREE)
if env_extra:
env.update(env_extra)
return subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "kanban"] + args,
env=env,
capture_output=True,
text=True,
cwd=str(_WORKTREE),
timeout=30,
)
class TestCLI:
def test_boards_list_default_only(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
res = _cli(["boards", "list", "--json"], env_extra=env)
assert res.returncode == 0, res.stderr
data = json.loads(res.stdout)
slugs = [b["slug"] for b in data]
assert slugs == ["default"]
assert data[0]["is_current"] is True
def test_boards_create_and_switch(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
r1 = _cli(
["boards", "create", "myproj", "--name", "My Project", "--switch"],
env_extra=env,
)
assert r1.returncode == 0, r1.stderr
assert "created" in r1.stdout
assert "Switched" in r1.stdout
r2 = _cli(["boards", "list", "--json"], env_extra=env)
data = json.loads(r2.stdout)
cur = [b for b in data if b["is_current"]][0]
assert cur["slug"] == "myproj"
def test_per_board_task_isolation_via_cli(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
assert _cli(["boards", "create", "projA"], env_extra=env).returncode == 0
assert _cli(["boards", "create", "projB"], env_extra=env).returncode == 0
# Create one task on each via --board.
r = _cli(["--board", "projA", "create", "Task A", "--assignee", "dev"], env_extra=env)
assert r.returncode == 0, r.stderr
r = _cli(["--board", "projB", "create", "Task B", "--assignee", "dev"], env_extra=env)
assert r.returncode == 0, r.stderr
# list on each board only shows its own.
listA = _cli(["--board", "projA", "list", "--json"], env_extra=env)
listB = _cli(["--board", "projB", "list", "--json"], env_extra=env)
listD = _cli(["list", "--json"], env_extra=env)
titlesA = [t["title"] for t in json.loads(listA.stdout)]
titlesB = [t["title"] for t in json.loads(listB.stdout)]
titlesD = [t["title"] for t in json.loads(listD.stdout)]
assert titlesA == ["Task A"]
assert titlesB == ["Task B"]
assert titlesD == []
def test_board_flag_rejects_unknown(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
r = _cli(["--board", "ghost", "list"], env_extra=env)
# main.py's dispatcher doesn't propagate return codes today, so we
# assert the user-visible signal: a stderr error message. Whether
# the exit code stays 0 is a separate (pre-existing) issue.
assert "does not exist" in r.stderr
def test_boards_rm_archives(self, tmp_path):
env = {"HERMES_HOME": str(tmp_path)}
_cli(["boards", "create", "rmme"], env_extra=env)
r = _cli(["boards", "rm", "rmme"], env_extra=env)
assert r.returncode == 0, r.stderr
assert "archived" in r.stdout
# Default board list no longer shows it.
res = _cli(["boards", "list", "--json"], env_extra=env)
slugs = [b["slug"] for b in json.loads(res.stdout)]
assert "rmme" not in slugs

View file

@ -54,15 +54,106 @@ They coexist: a kanban worker may call `delegate_task` internally during its run
## Core concepts
- **Board** — a standalone queue of tasks with its own SQLite DB, workspaces
directory, and dispatcher loop. A single install can have many boards
(e.g. one per project, repo, or domain); see [Boards (multi-project)](#boards-multi-project)
below. Single-project users stay on the `default` board and never see the
word "board" outside this docs section.
- **Task** — a row with title, optional body, one assignee (a profile name), status (`triage | todo | ready | running | blocked | done | archived`), optional tenant namespace, optional idempotency key (dedup for retried automation).
- **Link**`task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`.
- **Comment** — the inter-agent protocol. Agents and humans append comments; when a worker is (re-)spawned it reads the full comment thread as part of its context.
- **Workspace** — the directory a worker operates in. Three kinds:
- `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/`.
- `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/` (or `~/.hermes/kanban/boards/<slug>/workspaces/<id>/` on non-default boards).
- `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). **Must be an absolute path.** Relative paths like `dir:../tenants/foo/` are rejected at dispatch because they'd resolve against whatever CWD the dispatcher happens to be in, which is ambiguous and a confused-deputy escape vector. The path is otherwise trusted — it's your box, your filesystem, the worker runs with your uid. This is the trusted-local-user threat model; kanban is single-host by design.
- `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. Worker-side `git worktree add` creates it.
- **Dispatcher** — a long-lived loop that, every N seconds (default 60): reclaims stale claims, reclaims crashed workers (PID gone but TTL not yet expired), promotes ready tasks, atomically claims, spawns assigned profiles. Runs **inside the gateway** by default (`kanban.dispatch_in_gateway: true`). After ~5 consecutive spawn failures on the same task the dispatcher auto-blocks it with the last error as the reason — prevents thrashing on tasks whose profile doesn't exist, workspace can't mount, etc.
- **Tenant** — optional string namespace. One specialist fleet can serve multiple businesses (`--tenant business-a`) with data isolation by workspace path and memory key prefix.
- **Dispatcher** — a long-lived loop that, every N seconds (default 60): reclaims stale claims, reclaims crashed workers (PID gone but TTL not yet expired), promotes ready tasks, atomically claims, spawns assigned profiles. Runs **inside the gateway** by default (`kanban.dispatch_in_gateway: true`). One dispatcher sweeps all boards per tick; workers are spawned with `HERMES_KANBAN_BOARD` pinned so they can't see other boards. After ~5 consecutive spawn failures on the same task the dispatcher auto-blocks it with the last error as the reason — prevents thrashing on tasks whose profile doesn't exist, workspace can't mount, etc.
- **Tenant** — optional string namespace *within* a board. One specialist fleet can serve multiple businesses (`--tenant business-a`) with data isolation by workspace path and memory key prefix. Tenants are a soft filter; boards are the hard isolation boundary.
## Boards (multi-project)
Boards let you separate unrelated streams of work — one per project, repo,
or domain — into isolated queues. A new install has exactly one board
called `default` (DB at `~/.hermes/kanban.db` for back-compat). Users who
only want one stream of work never need to know about boards; the feature
is opt-in.
Per-board isolation is absolute:
- Separate SQLite DB per board (`~/.hermes/kanban/boards/<slug>/kanban.db`).
- Separate `workspaces/` and `logs/` directories.
- Workers spawned for a task see **only** their board's tasks — the
dispatcher sets `HERMES_KANBAN_BOARD` in the child env and every
`kanban_*` tool the worker has access to reads it.
- Linking tasks across boards is not allowed (keeps the schema simple; if
you really need cross-project refs, use free-text mentions and look
them up by id manually).
### Managing boards from the CLI
```bash
# See what's on disk. Fresh installs show only "default".
hermes kanban boards list
# Create a new board.
hermes kanban boards create atm10-server \
--name "ATM10 Server" \
--description "Minecraft modded server ops" \
--icon 🎮 \
--switch # optional: make it the active board
# Operate on a specific board without switching.
hermes kanban --board atm10-server list
hermes kanban --board atm10-server create "Restart ATM server" --assignee ops
# Change which board is "current" for subsequent calls.
hermes kanban boards switch atm10-server
hermes kanban boards show # who's active right now?
# Rename the display name (the slug is immutable — it's the directory name).
hermes kanban boards rename atm10-server "ATM10 (Prod)"
# Archive (default) — moves the board's dir to boards/_archived/<slug>-<ts>/.
# Recoverable by moving the dir back.
hermes kanban boards rm atm10-server
# Hard delete — `rm -rf` the board dir. No recovery.
hermes kanban boards rm atm10-server --delete
```
Board resolution order (highest precedence first):
1. Explicit `--board <slug>` on the CLI call.
2. `HERMES_KANBAN_BOARD` env var (set by the dispatcher when spawning a
worker, so workers can't see other boards).
3. `~/.hermes/kanban/current` — the slug persisted by `hermes kanban
boards switch`.
4. `default`.
Slugs are validated: lowercase alphanumerics + hyphens + underscores, 1-64
chars, must start with alphanumeric. Uppercase input is auto-downcased.
Anything else (slashes, spaces, dots, `..`) is rejected at the CLI layer
so path-traversal tricks can't name a board.
### Managing boards from the dashboard
`hermes dashboard` → Kanban tab shows a board switcher at the top as soon
as more than one board exists (or any board has tasks). Single-board users
see only a small `+ New board` button; the switcher is hidden until it
matters.
- **Board dropdown** — pick the active board. Your selection is saved to
the browser's `localStorage` so it persists across reloads without
shifting the CLI's `current` pointer out from under a terminal you left
open.
- **+ New board** — opens a modal asking for slug, display name,
description, and icon. Option to auto-switch to the new board.
- **Archive** — only shown on non-`default` boards. Confirms, then moves
the board dir to `boards/_archived/`.
All dashboard API endpoints accept `?board=<slug>` for board scoping. The
events WebSocket is pinned to a board at connection time; switching in
the UI opens a fresh WS against the new board.
## Quick start