feat(kanban): multi-project boards — one install, many kanbans (#19653)
Adds first-class board support to kanban so users can separate unrelated
streams of work (projects, repos, domains) into isolated queues. Single-
project users stay on the 'default' board and see no UI change.
Isolation model
---------------
- Each board is a directory at `~/.hermes/kanban/boards/<slug>/` with
its own `kanban.db`, `workspaces/`, and `logs/`. The 'default' board
keeps its legacy path (`~/.hermes/kanban.db`) for back-compat — fresh
installs and pre-boards users get zero migration.
- Workers spawned by the dispatcher have `HERMES_KANBAN_BOARD` pinned in
their env alongside the existing `HERMES_KANBAN_DB` /
`HERMES_KANBAN_WORKSPACES_ROOT` pins, so workers physically cannot see
other boards' tasks.
- The gateway's single dispatcher loop now sweeps every board per tick;
per-tick cost is a few extra filesystem stats.
- CAS concurrency guarantees are preserved per-board (each board is its
own SQLite DB, same WAL+IMMEDIATE machinery as before).
CLI
---
hermes kanban boards list|create|switch|show|rename|rm
hermes kanban --board <slug> <any-subcommand>
Board resolution order: `--board` flag → `HERMES_KANBAN_BOARD` env →
`~/.hermes/kanban/current` file → `default`. Slug validation is strict:
lowercase alphanumerics + hyphens + underscores, 1-64 chars, starts with
alphanumeric. Uppercase is auto-downcased; slashes / dots / `..` /
control chars are rejected so boards can't name their way out of the
boards/ directory.
Passive discoverability: when more than one board exists, `hermes kanban
list` prints a one-line header ("Board: foo (2 other boards …)") so
users who stumble across multi-project never have to hunt for the
feature. Invisible for single-board installs.
Dashboard
---------
- New `BoardSwitcher` component at the top of the Kanban tab: dropdown
with all boards + task counts, `+ New board` button, `Archive`
button (non-default only). Hidden entirely when only `default` exists
and is empty — single-project users never see it.
- New `NewBoardDialog` modal: slug / display name / description / icon
+ "switch to this board after creating" checkbox.
- Selected board persists to `localStorage` so browser users don't
shift the CLI's active board out from under a terminal they left open.
- New `?board=<slug>` query param on every existing endpoint plus a
new `/boards` CRUD surface (`GET /boards`, `POST /boards`,
`PATCH /boards/<slug>`, `DELETE /boards/<slug>`,
`POST /boards/<slug>/switch`).
- Events WebSocket is pinned to a board at connection time; switching
opens a fresh WS against the new board.
Also fixes a pre-existing bug in the plugin's tenant / assignee
filters: the SDK's `Select` uses `onValueChange(value)`, not
native `onChange(event)`, so those filters silently didn't work.
New `selectChangeHandler` helper wires both signatures.
Tests
-----
49 new tests in `tests/hermes_cli/test_kanban_boards.py` covering:
slug validation (valid / invalid / auto-downcase), path resolution
(default = legacy path, named = `boards/<slug>/`, env var override),
current-board resolution chain (env > file > default), board CRUD +
archive / hard-delete, per-board connection isolation (tasks don't
leak), worker spawn env injection (`HERMES_KANBAN_BOARD`,
`HERMES_KANBAN_DB`, `HERMES_KANBAN_WORKSPACES_ROOT` all point at the
right board), and end-to-end CLI surface.
Regression surface: all 264 pre-existing kanban tests continue to pass.
Live-tested via the dashboard: created 3 boards (default,
hermes-agent, atm10-server), created tasks on each via both CLI
(`--board <slug> create`) and dashboard (inline create on the Ready
column), confirmed zero cross-board leakage, confirmed `BoardSwitcher`
+ `NewBoardDialog` work end-to-end in the browser.
This commit is contained in:
parent
135b4c8b35
commit
5ec6baa400
8 changed files with 2191 additions and 212 deletions
212
gateway/run.py
212
gateway/run.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; 1–64 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 dispatcher→worker 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:
|
||||
|
|
|
|||
414
plugins/kanban/dashboard/dist/index.js
vendored
414
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -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.
|
||||
|
|
|
|||
54
plugins/kanban/dashboard/dist/style.css
vendored
54
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
483
tests/hermes_cli/test_kanban_boards.py
Normal file
483
tests/hermes_cli/test_kanban_boards.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue