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