diff --git a/gateway/run.py b/gateway/run.py index 4ca4711cdd..28d13994ba 100644 --- a/gateway/run.py +++ b/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 diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 46ec6c32ab..4befd64fa4 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -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="", + help=( + "Board slug to operate on. Defaults to the current board " + "(set via `hermes kanban boards switch ` 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 ` 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 ``. + + 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 `)") + 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 `.") + 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 " + "/kanban/boards//.") + 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 diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 98ee4828d3..7344569924 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -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 ``/kanban.db`` where ```` 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 `` joins the same -board as the dispatcher that claimed the task. The same applies to -``/kanban/workspaces/`` and ``/kanban/logs/``. +In a fresh install the board lives at ``/kanban.db`` where +```` 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 `` joins the same board as the dispatcher that +claimed the task. The same applies to ``/kanban/workspaces/`` and +``/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 ``/kanban/boards//`` 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 ``/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). +* ``/kanban/current`` — a one-line text file holding the slug of + the "currently selected" board. Written by ``hermes kanban boards + switch ``. When absent, the active board is ``default``. In standard installs ```` is ``~/.hermes``. In Docker / custom deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g. -``/opt/hermes``), ```` is ``HERMES_HOME``. Three env-var overrides -are available (highest precedence first, all optional): +``/opt/hermes``), ```` 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 ``/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 + ``/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 ``/kanban/current``. + + One-line text file written by ``hermes kanban boards switch `` + 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. ``/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 ``/kanban/current``. The caller should validate the slug + exists first (via :func:`board_exists`) — this function does not — + so that ``hermes kanban boards switch `` 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 ``/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 ``/kanban/boards/default/`` **for metadata only** + (board.json + workspaces/ + logs/). Its DB file stays at + ``/kanban.db`` for back-compat — see :func:`kanban_db_path`. + + All other boards live at ``/kanban/boards//`` 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`` → ``/kanban.db`` (back-compat path). + Other boards → ``/kanban/boards//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 ``/kanban/workspaces/`` so + that existing scratch workspaces from before the boards feature are + preserved. Other boards use ``/kanban/boards//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 ``/kanban/logs/``. Other + boards use ``/kanban/boards//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 + ``/kanban/boards/_archived/-/`` 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 → + ``/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/workspaces//``, - where ```` 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 ``/workspaces//``, + where ```` is the active board's root. The path is the + same for the dispatcher and every profile worker, so handoff is + path-stable. - ``dir:``: 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 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 ` 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/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 /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: diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index a818514e2b..3bdd92d47e 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -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 ``/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= 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