diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 0744a78753c..85af5e89f62 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -130,6 +130,8 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="scratch | worktree | dir: (default: scratch)") p_create.add_argument("--tenant", default=None, help="Tenant namespace") p_create.add_argument("--priority", type=int, default=0, help="Priority tiebreaker") + p_create.add_argument("--triage", action="store_true", + help="Park in triage — a specifier will flesh out the spec and promote to todo") p_create.add_argument("--created-by", default="user", help="Author name recorded on the task (default: user)") p_create.add_argument("--json", action="store_true", help="Emit JSON output") @@ -318,6 +320,7 @@ def _cmd_create(args: argparse.Namespace) -> int: tenant=args.tenant, priority=args.priority, parents=tuple(args.parent or ()), + triage=bool(getattr(args, "triage", False)), ) task = kb.get_task(conn, task_id) if getattr(args, "json", False): diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 862f9f3c1d7..4c3a8bddfa7 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -34,7 +34,7 @@ from typing import Any, Iterable, Optional # Constants # --------------------------------------------------------------------------- -VALID_STATUSES = {"todo", "ready", "running", "blocked", "done", "archived"} +VALID_STATUSES = {"triage", "todo", "ready", "running", "blocked", "done", "archived"} VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} # A running task's claim is valid for 15 minutes; after that the next @@ -279,11 +279,15 @@ def create_task( tenant: Optional[str] = None, priority: int = 0, parents: Iterable[str] = (), + triage: bool = False, ) -> str: """Create a new task and optionally link it under parent tasks. Returns the new task id. Status is ``ready`` when there are no parents (or all parents already ``done``), otherwise ``todo``. + If ``triage=True``, status is forced to ``triage`` regardless of + parents — a specifier/triager is expected to promote the task to + ``todo`` once the spec is fleshed out. """ if not title or not title.strip(): raise ValueError("title is required") @@ -301,20 +305,30 @@ def create_task( task_id = _new_task_id() try: with write_txn(conn): - # Determine initial status from parent status. - initial_status = "ready" - if parents: + # Determine initial status from parent status, unless the + # caller is parking this task in triage for a specifier. + if triage: + initial_status = "triage" + else: + initial_status = "ready" + if parents: + missing = _find_missing_parents(conn, parents) + if missing: + raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + # If any parent is not yet done, we're todo. + rows = conn.execute( + "SELECT status FROM tasks WHERE id IN " + "(" + ",".join("?" * len(parents)) + ")", + parents, + ).fetchall() + if any(r["status"] != "done" for r in rows): + initial_status = "todo" + # Even in triage mode we still need to validate parent ids + # so the eventual link rows don't dangle. + if triage and parents: missing = _find_missing_parents(conn, parents) if missing: raise ValueError(f"unknown parent task(s): {', '.join(missing)}") - # If any parent is not yet done, we're todo. - rows = conn.execute( - "SELECT status FROM tasks WHERE id IN " - "(" + ",".join("?" * len(parents)) + ")", - parents, - ).fetchall() - if any(r["status"] != "done" for r in rows): - initial_status = "todo" conn.execute( """ diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 081f08b6d2e..cf536f0211d 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -6,7 +6,7 @@ * and tails task_events over a WebSocket for live updates. * * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React + - * shadcn primitives; HTML5 drag-and-drop for card movement (no extra libs). + * shadcn primitives; HTML5 drag-and-drop for card movement. */ (function () { "use strict"; @@ -21,8 +21,6 @@ const h = React.createElement; const { Card, - CardHeader, - CardTitle, CardContent, Badge, Button, @@ -30,14 +28,14 @@ Label, Select, SelectOption, - Separator, } = SDK.components; const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks; const { cn, timeAgo } = SDK.utils; // Order matters — matches BOARD_COLUMNS in plugin_api.py. - const COLUMN_ORDER = ["todo", "ready", "running", "blocked", "done"]; + const COLUMN_ORDER = ["triage", "todo", "ready", "running", "blocked", "done"]; const COLUMN_LABEL = { + triage: "Triage", todo: "Todo", ready: "Ready", running: "In Progress", @@ -46,6 +44,7 @@ archived: "Archived", }; const COLUMN_HELP = { + triage: "Raw ideas — a specifier will flesh out the spec", todo: "Waiting on dependencies or unassigned", ready: "Assigned and waiting for a dispatcher tick", running: "Claimed by a worker — in-flight", @@ -54,6 +53,7 @@ archived: "Archived", }; const COLUMN_DOT = { + triage: "hermes-kanban-dot-triage", todo: "hermes-kanban-dot-todo", ready: "hermes-kanban-dot-ready", running: "hermes-kanban-dot-running", @@ -62,8 +62,49 @@ archived: "hermes-kanban-dot-archived", }; + // Confirmations required for any irreversible-looking status transition + // (the CLI can still do any of these without a prompt). + const DESTRUCTIVE_TRANSITIONS = { + done: "Mark this task as done? The worker's claim is released and dependent children become ready.", + archived: "Archive this task? It disappears from the default board view.", + blocked: "Mark this task as blocked? The worker's claim is released.", + }; + const API = "/api/plugins/kanban"; + // ------------------------------------------------------------------------- + // Error boundary — a bad card render shouldn't crash the whole tab. + // ------------------------------------------------------------------------- + + class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + static getDerivedStateFromError(error) { return { error: error }; } + componentDidCatch(error, info) { + // eslint-disable-next-line no-console + console.error("Kanban plugin crashed:", error, info); + } + render() { + if (this.state.error) { + return h(Card, null, + h(CardContent, { className: "p-6 text-sm" }, + h("div", { className: "text-destructive font-semibold mb-1" }, + "Kanban tab hit a rendering error"), + h("div", { className: "text-muted-foreground text-xs mb-3" }, + String(this.state.error && this.state.error.message || this.state.error)), + h(Button, { + onClick: () => this.setState({ error: null }), + className: "h-7 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Reload view"), + ), + ); + } + return this.props.children; + } + } + // ------------------------------------------------------------------------- // Root page // ------------------------------------------------------------------------- @@ -77,11 +118,15 @@ const [assigneeFilter, setAssigneeFilter] = useState(""); const [includeArchived, setIncludeArchived] = useState(false); const [search, setSearch] = useState(""); + const [laneByProfile, setLaneByProfile] = useState(true); const [selectedTaskId, setSelectedTaskId] = useState(null); const cursorRef = useRef(0); + const reloadTimerRef = useRef(null); const wsRef = useRef(null); + const wsBackoffRef = useRef(1000); // reconnect delay in ms, grows on failure + const wsClosedRef = useRef(false); // --- fetch full board --------------------------------------------------- const loadBoard = useCallback(() => { @@ -103,49 +148,76 @@ }); }, [tenantFilter, includeArchived]); + // Debounced reload — a burst of events shouldn't trigger N refetches. + const scheduleReload = useCallback(function () { + if (reloadTimerRef.current) return; + reloadTimerRef.current = setTimeout(function () { + reloadTimerRef.current = null; + loadBoard(); + }, 250); + }, [loadBoard]); + useEffect(function () { loadBoard(); + return function () { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + }; }, [loadBoard]); // --- live updates via WebSocket ---------------------------------------- useEffect(function () { if (!board) return undefined; - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const url = `${proto}//${window.location.host}${API}/events?since=${cursorRef.current}`; - let ws; - let closed = false; - try { - ws = new WebSocket(url); - } catch (e) { - return undefined; - } - wsRef.current = ws; - ws.onmessage = function (ev) { - try { - const msg = JSON.parse(ev.data); - if (msg && Array.isArray(msg.events) && msg.events.length > 0) { - cursorRef.current = msg.cursor || cursorRef.current; - // Cheapest correct strategy: reload the board on any event burst. - // The board endpoint is a single fast SQL query; this is fine. - loadBoard(); + 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({ + since: String(cursorRef.current || 0), + token: token, + }); + const url = `${proto}//${window.location.host}${API}/events?${qs}`; + let ws; + try { ws = new WebSocket(url); } catch (e) { return; } + wsRef.current = ws; + + ws.onopen = function () { + wsBackoffRef.current = 1000; // reset backoff on successful open + }; + ws.onmessage = function (ev) { + try { + const msg = JSON.parse(ev.data); + if (msg && Array.isArray(msg.events) && msg.events.length > 0) { + cursorRef.current = msg.cursor || cursorRef.current; + scheduleReload(); + } + } catch (_e) { /* ignore malformed */ } + }; + ws.onclose = function (ev) { + if (wsClosedRef.current) return; + // Auth rejection — don't loop forever. + if (ev && ev.code === 1008) { + setError("WebSocket auth failed — reload the page to refresh the session token."); + return; } - } catch (e) { - // ignore malformed frames - } - }; - ws.onclose = function () { - if (!closed) { - // Auto-reconnect after a short delay. - setTimeout(function () { - if (!closed) loadBoard(); - }, 1500); - } - }; + // Exponential backoff, capped at 30s. + const delay = Math.min(wsBackoffRef.current, 30000); + wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000); + setTimeout(openWs, delay); + }; + } + + openWs(); + return function () { - closed = true; - try { ws.close(); } catch (e) { /* noop */ } + wsClosedRef.current = true; + try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ } }; - }, [board, loadBoard]); + }, [!!board, scheduleReload]); // --- filtering ---------------------------------------------------------- const filteredBoard = useMemo(function () { @@ -170,6 +242,10 @@ // --- actions ------------------------------------------------------------ const moveTask = useCallback(function (taskId, newStatus) { + // Confirm for destructive-looking moves (drag into Done / Archived / Blocked). + const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; + if (confirmMsg && !window.confirm(confirmMsg)) return; + // Optimistic move: update local board first, reconcile on refresh. setBoard(function (b) { if (!b) return b; @@ -217,42 +293,47 @@ h("div", { className: "text-sm text-destructive" }, "Failed to load Kanban board: ", error), h("div", { className: "text-xs text-muted-foreground mt-2" }, - "Make sure kanban.db exists (run `hermes kanban init`) and the dashboard was restarted after installing this plugin."), + "The backend auto-creates kanban.db on first read. If this persists, check the dashboard logs."), ), ); } if (!filteredBoard) return null; - return h("div", { className: "hermes-kanban flex flex-col gap-4" }, - h(BoardToolbar, { - board: board, - tenantFilter: tenantFilter, - setTenantFilter: setTenantFilter, - assigneeFilter: assigneeFilter, - setAssigneeFilter: setAssigneeFilter, - includeArchived: includeArchived, - setIncludeArchived: setIncludeArchived, - search: search, - setSearch: setSearch, - onNudgeDispatch: function () { - SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" }) - .then(loadBoard) - .catch(function (e) { setError(String(e.message || e)); }); - }, - onRefresh: loadBoard, - }), - error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, - h(BoardColumns, { - board: filteredBoard, - onMove: moveTask, - onOpen: setSelectedTaskId, - onCreate: createTask, - }), - selectedTaskId ? h(TaskDrawer, { - taskId: selectedTaskId, - onClose: function () { setSelectedTaskId(null); }, - onRefresh: loadBoard, - }) : null, + return h(ErrorBoundary, null, + h("div", { className: "hermes-kanban flex flex-col gap-4" }, + h(BoardToolbar, { + board: board, + tenantFilter: tenantFilter, + setTenantFilter: setTenantFilter, + assigneeFilter: assigneeFilter, + setAssigneeFilter: setAssigneeFilter, + includeArchived: includeArchived, + setIncludeArchived: setIncludeArchived, + laneByProfile: laneByProfile, + setLaneByProfile: setLaneByProfile, + search: search, + setSearch: setSearch, + onNudgeDispatch: function () { + SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" }) + .then(loadBoard) + .catch(function (e) { setError(String(e.message || e)); }); + }, + onRefresh: loadBoard, + }), + error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, + h(BoardColumns, { + board: filteredBoard, + laneByProfile: laneByProfile, + onMove: moveTask, + onOpen: setSelectedTaskId, + onCreate: createTask, + }), + selectedTaskId ? h(TaskDrawer, { + taskId: selectedTaskId, + onClose: function () { setSelectedTaskId(null); }, + onRefresh: loadBoard, + }) : null, + ), ); } @@ -308,6 +389,15 @@ }), "Show archived", ), + h("label", { className: "flex items-center gap-2 text-xs", + title: "Group the Running column by assigned profile" }, + h("input", { + type: "checkbox", + checked: props.laneByProfile, + onChange: function (e) { props.setLaneByProfile(e.target.checked); }, + }), + "Lanes by profile", + ), h("div", { className: "flex-1" }), h(Button, { onClick: props.onNudgeDispatch, @@ -330,6 +420,7 @@ return h(Column, { key: col.name, column: col, + laneByProfile: props.laneByProfile, onMove: props.onMove, onOpen: props.onOpen, onCreate: props.onCreate, @@ -355,6 +446,19 @@ if (taskId) props.onMove(taskId, props.column.name); }; + // Running column gets per-assignee lanes when the toggle is on. + const lanes = useMemo(function () { + if (!props.laneByProfile || props.column.name !== "running") return null; + const byProfile = {}; + for (const t of props.column.tasks) { + const key = t.assignee || "(unassigned)"; + (byProfile[key] = byProfile[key] || []).push(t); + } + return Object.keys(byProfile).sort().map(function (k) { + return { assignee: k, tasks: byProfile[k] }; + }); + }, [props.column, props.laneByProfile]); + return h("div", { className: cn( "hermes-kanban-column", @@ -380,7 +484,7 @@ h("div", { className: "hermes-kanban-column-sub" }, COLUMN_HELP[props.column.name] || ""), showCreate ? h(InlineCreate, { - defaultStatus: props.column.name, + columnName: props.column.name, onSubmit: function (body) { props.onCreate(body).then(function () { setShowCreate(false); }); }, @@ -389,13 +493,21 @@ h("div", { className: "hermes-kanban-column-body" }, props.column.tasks.length === 0 ? h("div", { className: "hermes-kanban-empty" }, "— no tasks —") - : props.column.tasks.map(function (t) { - return h(TaskCard, { - key: t.id, - task: t, - onOpen: props.onOpen, - }); - }), + : lanes + ? lanes.map(function (lane) { + return h("div", { key: lane.assignee, className: "hermes-kanban-lane" }, + h("div", { className: "hermes-kanban-lane-head" }, + h("span", { className: "hermes-kanban-lane-name" }, lane.assignee), + h("span", { className: "hermes-kanban-lane-count" }, lane.tasks.length), + ), + lane.tasks.map(function (t) { + return h(TaskCard, { key: t.id, task: t, onOpen: props.onOpen }); + }), + ); + }) + : props.column.tasks.map(function (t) { + return h(TaskCard, { key: t.id, task: t, onOpen: props.onOpen }); + }), ), ); } @@ -412,6 +524,8 @@ e.dataTransfer.effectAllowed = "move"; }; + const progress = t.progress; + return h("div", { className: "hermes-kanban-card", draggable: true, @@ -428,6 +542,15 @@ t.tenant ? h(Badge, { variant: "outline", className: "hermes-kanban-tag" }, t.tenant) : null, + progress + ? h("span", { + className: cn( + "hermes-kanban-progress", + progress.done === progress.total ? "hermes-kanban-progress--full" : "", + ), + title: `${progress.done} of ${progress.total} child tasks done`, + }, `${progress.done}/${progress.total}`) + : null, ), h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"), h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" }, @@ -462,10 +585,12 @@ const submit = function () { const trimmed = title.trim(); if (!trimmed) return; + // Creating from the Triage column parks the task in triage. props.onSubmit({ title: trimmed, assignee: assignee.trim() || null, priority: Number(priority) || 0, + triage: props.columnName === "triage", }); setTitle(""); setAssignee(""); @@ -480,7 +605,9 @@ if (e.key === "Enter") { e.preventDefault(); submit(); } if (e.key === "Escape") props.onCancel(); }, - placeholder: "New task title…", + placeholder: props.columnName === "triage" + ? "Rough idea — AI will spec it…" + : "New task title…", autoFocus: true, className: "h-8 text-sm", }), @@ -488,7 +615,7 @@ h(Input, { value: assignee, onChange: function (e) { setAssignee(e.target.value); }, - placeholder: "assignee (optional)", + placeholder: props.columnName === "triage" ? "specifier (optional)" : "assignee (optional)", className: "h-7 text-xs flex-1", }), h(Input, { @@ -531,6 +658,13 @@ useEffect(function () { load(); }, [load]); + // Escape closes the drawer. + useEffect(function () { + function onKey(e) { if (e.key === "Escape") props.onClose(); } + window.addEventListener("keydown", onKey); + return function () { window.removeEventListener("keydown", onKey); }; + }, [props.onClose]); + const handleComment = function () { const body = newComment.trim(); if (!body) return; @@ -558,14 +692,17 @@ type: "button", onClick: props.onClose, className: "hermes-kanban-drawer-close", - title: "Close", + title: "Close (Esc)", }, "×"), ), loading ? h("div", { className: "p-4 text-sm text-muted-foreground" }, "Loading…") : err ? h("div", { className: "p-4 text-sm text-destructive" }, err) : data ? h(TaskDetail, { data: data, - onPatch: function (patch) { + onPatch: function (patch, opts) { + if (opts && opts.confirm && !window.confirm(opts.confirm)) { + return Promise.resolve(); + } return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -685,9 +822,9 @@ function StatusActions(props) { const t = props.task; - const b = function (label, patch, enabled) { + const b = function (label, patch, enabled, confirmMsg) { return h(Button, { - onClick: function () { if (enabled !== false) props.onPatch(patch); }, + onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); }, disabled: enabled === false, className: cn( "h-7 px-2 text-xs border border-border cursor-pointer", @@ -697,12 +834,18 @@ }; return h("div", { className: "hermes-kanban-actions" }, + b("→ triage", { status: "triage" }, t.status !== "triage"), b("→ ready", { status: "ready" }, t.status !== "ready"), b("→ running", { status: "running" }, t.status !== "running"), - b("Block", { status: "blocked" }, t.status === "running" || t.status === "ready"), + b("Block", { status: "blocked" }, + t.status === "running" || t.status === "ready", + DESTRUCTIVE_TRANSITIONS.blocked), b("Unblock", { status: "ready" }, t.status === "blocked"), - b("Complete", { status: "done" }, t.status === "running" || t.status === "ready" || t.status === "blocked"), - b("Archive", { status: "archived" }, t.status !== "archived"), + b("Complete", { status: "done" }, + t.status === "running" || t.status === "ready" || t.status === "blocked", + DESTRUCTIVE_TRANSITIONS.done), + b("Archive", { status: "archived" }, t.status !== "archived", + DESTRUCTIVE_TRANSITIONS.archived), ); } diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index f22698228ed..f3dff00f120 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -107,6 +107,7 @@ border-radius: 999px; background: var(--color-muted-foreground); } +.hermes-kanban-dot-triage { background: #b47dd6; } /* lilac — fresh/unspecified */ .hermes-kanban-dot-todo { background: var(--color-muted-foreground); } .hermes-kanban-dot-ready { background: #d4b348; } /* amber */ .hermes-kanban-dot-running { background: #3fb97d; } /* green */ @@ -114,6 +115,56 @@ .hermes-kanban-dot-done { background: #4a8cd1; } /* blue */ .hermes-kanban-dot-archived { background: var(--color-border); } +/* ---- Progress pill (N/M child tasks done) --------------------------- */ + +.hermes-kanban-progress { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.62rem; + padding: 0.05rem 0.35rem; + border-radius: 999px; + background: color-mix(in srgb, var(--color-foreground) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent); + color: var(--color-muted-foreground); + letter-spacing: 0.02em; +} +.hermes-kanban-progress--full { + background: color-mix(in srgb, #3fb97d 22%, transparent); + border-color: color-mix(in srgb, #3fb97d 45%, transparent); + color: var(--color-foreground); +} + +/* ---- Lanes (per-profile sub-grouping inside Running) ---------------- */ + +.hermes-kanban-lane { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.25rem 0 0.35rem; + border-top: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent); +} +.hermes-kanban-lane:first-child { + border-top: 0; + padding-top: 0; +} +.hermes-kanban-lane-head { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-muted-foreground); + padding: 0 0.1rem; +} +.hermes-kanban-lane-name { + font-weight: 600; + font-family: var(--font-mono, ui-monospace, monospace); +} +.hermes-kanban-lane-count { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + /* ---- Card ------------------------------------------------------------ */ .hermes-kanban-card { diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 8208ed36f72..c9dcf838253 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -10,11 +10,24 @@ cannot drift. Live updates arrive via the ``/events`` WebSocket, which tails the append-only ``task_events`` table on a short poll interval (WAL mode lets reads run alongside the dispatcher's IMMEDIATE write transactions). + +Security note +------------- +The dashboard's HTTP auth middleware (``web_server.auth_middleware``) +explicitly skips ``/api/plugins/`` — plugin routes are unauthenticated by +design because the dashboard binds to localhost by default. For the +WebSocket we still require the session token as a ``?token=`` query +parameter (browsers cannot set the ``Authorization`` header on an upgrade +request), matching the established pattern used by the in-browser PTY +bridge in ``hermes_cli/web_server.py``. If you run the dashboard with +``--host 0.0.0.0``, every plugin route — kanban included — becomes +reachable from the network. Don't do that on a shared host. """ from __future__ import annotations import asyncio +import hmac import json import logging import sqlite3 @@ -22,7 +35,7 @@ import time from dataclasses import asdict from typing import Any, Optional -from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status from pydantic import BaseModel, Field from hermes_cli import kanban_db @@ -32,13 +45,42 @@ log = logging.getLogger(__name__) router = APIRouter() +# --------------------------------------------------------------------------- +# Auth helper — WebSocket only (HTTP routes live behind the dashboard's +# existing plugin-bypass; this is documented above). +# --------------------------------------------------------------------------- + +def _check_ws_token(provided: Optional[str]) -> bool: + """Constant-time compare against the dashboard session token. + + Imported lazily so the plugin still loads in test contexts where the + dashboard web_server module isn't importable (e.g. the bare-FastAPI + test harness). + """ + if not provided: + return False + try: + from hermes_cli import web_server as _ws + except Exception: + # No dashboard context (tests). Accept so the tail loop is still + # testable; in production the dashboard module always imports + # cleanly because it's the caller. + return True + expected = getattr(_ws, "_SESSION_TOKEN", None) + if not expected: + return True + return hmac.compare_digest(str(provided), str(expected)) + + # --------------------------------------------------------------------------- # Serialization helpers # --------------------------------------------------------------------------- # Columns shown by the dashboard, in left-to-right order. "archived" is # available via a filter toggle rather than a visible column. -BOARD_COLUMNS: list[str] = ["todo", "ready", "running", "blocked", "done"] +BOARD_COLUMNS: list[str] = [ + "triage", "todo", "ready", "running", "blocked", "done", +] def _task_dict(task: kanban_db.Task) -> dict[str, Any]: @@ -95,7 +137,18 @@ def get_board( tenant: Optional[str] = Query(None, description="Filter to a single tenant"), include_archived: bool = Query(False), ): - """Return the full board grouped by status column.""" + """Return the full board grouped by status column. + + Auto-initializes ``kanban.db`` on first call so a fresh install + doesn't surface a "failed to load" error on the plugin tab. + """ + # Idempotent; handles the "user opened the tab before running + # `hermes kanban init`" case. No-op on established DBs. + try: + kanban_db.init_db() + except Exception as exc: + log.warning("kanban init_db failed: %s", exc) + conn = kanban_db.connect() try: tasks = kanban_db.list_tasks( @@ -121,6 +174,19 @@ def get_board( ) } + # Progress rollup: for each parent, how many children are done / total. + # One pass over task_links joined with child status — cheaper than + # N per-task queries and the plugin uses it to render "N/M". + progress: dict[str, dict[str, int]] = {} + for row in conn.execute( + "SELECT l.parent_id AS pid, t.status AS cstatus " + "FROM task_links l JOIN tasks t ON t.id = l.child_id" + ).fetchall(): + p = progress.setdefault(row["pid"], {"done": 0, "total": 0}) + p["total"] += 1 + if row["cstatus"] == "done": + p["done"] += 1 + latest_event_id = conn.execute( "SELECT COALESCE(MAX(id), 0) AS m FROM task_events" ).fetchone()["m"] @@ -133,6 +199,7 @@ def get_board( d = _task_dict(t) d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0}) d["comment_count"] = comment_counts.get(t.id, 0) + d["progress"] = progress.get(t.id) # None when the task has no children col = t.status if t.status in columns else "todo" columns[col].append(d) @@ -202,6 +269,7 @@ class CreateTaskBody(BaseModel): workspace_kind: str = "scratch" workspace_path: Optional[str] = None parents: list[str] = Field(default_factory=list) + triage: bool = False @router.post("/tasks") @@ -219,6 +287,7 @@ def create_task(payload: CreateTaskBody): tenant=payload.tenant, priority=payload.priority, parents=payload.parents, + triage=payload.triage, ) task = kanban_db.get_task(conn, task_id) return {"task": _task_dict(task) if task else None} @@ -279,7 +348,7 @@ def update_task(task_id: str, payload: UpdateTaskBody): ok = _set_status_direct(conn, task_id, "ready") elif s == "archived": ok = kanban_db.archive_task(conn, task_id) - elif s in ("todo", "running"): + elif s in ("todo", "running", "triage"): ok = _set_status_direct(conn, task_id, s) else: raise HTTPException(status_code=400, detail=f"unknown status: {s}") @@ -446,6 +515,13 @@ _EVENT_POLL_SECONDS = 0.3 @router.websocket("/events") async def stream_events(ws: WebSocket): + # Enforce the dashboard session token as a query param — browsers can't + # set Authorization on a WS upgrade. This matches how the PTY bridge + # authenticates in hermes_cli/web_server.py. + token = ws.query_params.get("token") + if not _check_ws_token(token): + await ws.close(code=http_status.WS_1008_POLICY_VIOLATION) + return await ws.accept() try: since_raw = ws.query_params.get("since", "0") diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 837826403c0..f1cdb5cddc4 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -67,9 +67,9 @@ def test_board_empty(client): r = client.get("/api/plugins/kanban/board") assert r.status_code == 200 data = r.json() - # All canonical columns present, each empty. + # All canonical columns present (triage + the rest), each empty. names = [c["name"] for c in data["columns"]] - for expected in ("todo", "ready", "running", "blocked", "done"): + for expected in ("triage", "todo", "ready", "running", "blocked", "done"): assert expected in names, f"missing column {expected}: {names}" assert all(len(c["tasks"]) == 0 for c in data["columns"]) assert data["tenants"] == [] @@ -331,3 +331,175 @@ def test_dispatch_dry_run(client): body = r.json() # DispatchResult is serialized as a dataclass dict. assert isinstance(body, dict) + + +# --------------------------------------------------------------------------- +# Triage column (new v1 status) +# --------------------------------------------------------------------------- + + +def test_create_triage_lands_in_triage_column(client): + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "rough idea, spec me", "triage": True}, + ) + assert r.status_code == 200 + task = r.json()["task"] + assert task["status"] == "triage" + + r = client.get("/api/plugins/kanban/board") + triage = next(c for c in r.json()["columns"] if c["name"] == "triage") + assert len(triage["tasks"]) == 1 + assert triage["tasks"][0]["title"] == "rough idea, spec me" + + +def test_triage_task_not_promoted_to_ready(client): + """Triage tasks must stay in triage even when they have no parents.""" + client.post( + "/api/plugins/kanban/tasks", + json={"title": "must stay put", "triage": True}, + ) + # Run the dispatcher — it should NOT promote the triage task. + client.post("/api/plugins/kanban/dispatch?dry_run=false&max=4") + r = client.get("/api/plugins/kanban/board") + triage = next(c for c in r.json()["columns"] if c["name"] == "triage") + ready = next(c for c in r.json()["columns"] if c["name"] == "ready") + assert len(triage["tasks"]) == 1 + assert len(ready["tasks"]) == 0 + + +def test_patch_status_triage_works(client): + """A user (or specifier) can push a task back into triage, and out of it.""" + t = client.post( + "/api/plugins/kanban/tasks", json={"title": "x"}, + ).json()["task"] + # Normal creation is 'ready'; push to triage. + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "triage"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "triage" + + # Now promote to todo. + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "todo"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "todo" + + +# --------------------------------------------------------------------------- +# Progress rollup (done children / total children) +# --------------------------------------------------------------------------- + + +def test_board_progress_rollup(client): + parent = client.post( + "/api/plugins/kanban/tasks", json={"title": "parent"}, + ).json()["task"] + child_a = client.post( + "/api/plugins/kanban/tasks", + json={"title": "a", "parents": [parent["id"]]}, + ).json()["task"] + child_b = client.post( + "/api/plugins/kanban/tasks", + json={"title": "b", "parents": [parent["id"]]}, + ).json()["task"] + # Children start as "todo" because the parent isn't done yet; promote + # them to "ready" so complete_task will accept the transition. + for cid in (child_a["id"], child_b["id"]): + r = client.patch( + f"/api/plugins/kanban/tasks/{cid}", json={"status": "ready"}, + ) + assert r.status_code == 200 + + # 0/2 done. + r = client.get("/api/plugins/kanban/board") + parent_row = next( + t for col in r.json()["columns"] for t in col["tasks"] + if t["id"] == parent["id"] + ) + assert parent_row["progress"] == {"done": 0, "total": 2} + + # Complete one child. 1/2. + r = client.patch( + f"/api/plugins/kanban/tasks/{child_a['id']}", + json={"status": "done"}, + ) + assert r.status_code == 200 + r = client.get("/api/plugins/kanban/board") + parent_row = next( + t for col in r.json()["columns"] for t in col["tasks"] + if t["id"] == parent["id"] + ) + assert parent_row["progress"] == {"done": 1, "total": 2} + + # Childless tasks report progress=None, not {0/0}. + assert next( + t for col in r.json()["columns"] for t in col["tasks"] + if t["id"] == child_b["id"] + )["progress"] is None + + +# --------------------------------------------------------------------------- +# Auto-init on first board read +# --------------------------------------------------------------------------- + + +def test_board_auto_initializes_missing_db(tmp_path, monkeypatch): + """If kanban.db doesn't exist yet, GET /board must create it, not 500.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Deliberately DO NOT call kb.init_db(). + + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + c = TestClient(app) + r = c.get("/api/plugins/kanban/board") + assert r.status_code == 200 + assert (home / "kanban.db").exists(), "init_db wasn't invoked by /board" + + +# --------------------------------------------------------------------------- +# WebSocket auth (query-param token) +# --------------------------------------------------------------------------- + + +def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): + """When _SESSION_TOKEN is set (normal dashboard context), a missing or + wrong ?token= query param must be rejected with policy-violation.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + + # Stub web_server so _check_ws_token has a token to compare against. + import types + stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz") + monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub) + + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + c = TestClient(app) + + # No token → policy violation close. + from starlette.websockets import WebSocketDisconnect + with pytest.raises(WebSocketDisconnect) as exc: + with c.websocket_connect("/api/plugins/kanban/events"): + pass + assert exc.value.code == 1008 + + # Wrong token → policy violation close. + with pytest.raises(WebSocketDisconnect) as exc: + with c.websocket_connect("/api/plugins/kanban/events?token=nope"): + pass + assert exc.value.code == 1008 + + # Correct token → accepted (connect then close cleanly from our side). + with c.websocket_connect( + "/api/plugins/kanban/events?token=secret-xyz" + ) as ws: + assert ws is not None # handshake succeeded diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 57f27694b36..2d031738374 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -118,13 +118,15 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills" ### What the plugin gives you -- A **Kanban** tab showing one column per status: `todo`, `ready`, `running`, `blocked`, `done` (plus `archived` when the toggle is on). -- Cards show the task id, title, priority badge, tenant tag, assigned profile, comment/link counts, and "created N ago". -- **Live updates via WebSocket** — the plugin tails the append-only `task_events` table on a short poll interval; the board reflects changes the instant any profile (CLI, gateway, or another dashboard tab) acts. -- **Drag-drop** cards between columns to change status. The drop sends a `PATCH /api/plugins/kanban/tasks/:id` which routes through the same `kanban_db` code the CLI uses — the three surfaces can never drift. -- **Inline create** — click `+` on any column header to type a title, assignee, and priority without leaving the board. -- **Click a card** to open a side drawer with the full description, status actions (→ ready / → running / block / unblock / complete / archive), dependency links, comment thread with Enter-to-submit, and the last 20 events. -- **Toolbar filters** — free-text search, tenant dropdown, assignee dropdown, "show archived" toggle, and a **Nudge dispatcher** button so you don't have to wait for the next 60 s tick. +- A **Kanban** tab showing one column per status: `triage`, `todo`, `ready`, `running`, `blocked`, `done` (plus `archived` when the toggle is on). + - `triage` is the parking column for rough ideas a specifier is expected to flesh out. Tasks created with `hermes kanban create --triage` (or via the Triage column's inline create) land here and the dispatcher leaves them alone until a human or specifier promotes them to `todo` / `ready`. +- Cards show the task id, title, priority badge, tenant tag, assigned profile, comment/link counts, a **progress pill** (`N/M` children done when the task has dependents), and "created N ago". +- **Per-profile lanes inside Running** — toggled by the toolbar checkbox, the Running column sub-groups by assignee so you see at a glance which specialist is busy on what. +- **Live updates via WebSocket** — the plugin tails the append-only `task_events` table on a short poll interval; the board reflects changes the instant any profile (CLI, gateway, or another dashboard tab) acts. Reloads are debounced so a burst of events triggers a single refetch. +- **Drag-drop** cards between columns to change status. The drop sends a `PATCH /api/plugins/kanban/tasks/:id` which routes through the same `kanban_db` code the CLI uses — the three surfaces can never drift. Moves into destructive statuses (`done`, `archived`, `blocked`) prompt for confirmation. +- **Inline create** — click `+` on any column header to type a title, assignee, and priority without leaving the board. Creating from the Triage column automatically parks the new task in triage. +- **Click a card** to open a side drawer (Escape or click-outside closes) with the full description, status actions (→ triage / → ready / → running / block / unblock / complete / archive), dependency link chips, comment thread with Enter-to-submit, and the last 20 events. +- **Toolbar filters** — free-text search, tenant dropdown, assignee dropdown, "show archived" toggle, "lanes by profile" toggle, and a **Nudge dispatcher** button so you don't have to wait for the next 60 s tick. Visually the target is the familiar Linear / Fusion layout: dark theme, column headers with counts, coloured status dots, pill chips for priority and tenant. The plugin reads only theme CSS vars (`--color-*`, `--radius`, `--font-mono`, ...), so it reskins automatically with whichever dashboard theme is active. @@ -168,7 +170,17 @@ All routes are mounted under `/api/plugins/kanban/` and protected by the dashboa | `POST` | `/dispatch?max=…&dry_run=…` | Nudge the dispatcher — skip the 60 s wait | | `WS` | `/events?since=` | Live stream of `task_events` rows | -Every handler is a thin wrapper — the plugin is ~500 lines of Python (including the WebSocket tail loop) and adds no new business logic. +Every handler is a thin wrapper — the plugin is ~500 lines of Python (including the WebSocket tail loop) and adds no new business logic. GET `/board` auto-initializes `kanban.db` on first read, so opening the tab on a fresh install just works even if `hermes kanban init` was skipped. + +### Security model + +The dashboard's HTTP auth middleware [explicitly skips `/api/plugins/`](./extending-the-dashboard#backend-api-routes) — plugin routes are unauthenticated by design because the dashboard binds to localhost by default. That means the kanban REST surface is reachable from any process on the host. + +The WebSocket takes one additional step: it requires the dashboard's ephemeral session token as a `?token=…` query parameter (browsers can't set `Authorization` on an upgrade request), matching the pattern used by the in-browser PTY bridge. + +If you run `hermes dashboard --host 0.0.0.0`, every plugin route — kanban included — becomes reachable from the network. **Don't do that on a shared host.** The board contains task bodies, comments, and workspace paths; an attacker reaching these routes gets read access to your entire collaboration surface and can also create / reassign / archive tasks. + +Tasks in `~/.hermes/kanban.db` are profile-agnostic on purpose (that's the coordination primitive). If you open the dashboard with `hermes -p dashboard`, the board still shows tasks created by any other profile on the host. Same user owns all profiles, but this is worth knowing if multiple personas coexist. ### Live updates @@ -191,7 +203,7 @@ hermes kanban init # create kanban.db hermes kanban create "" [--body ...] [--assignee <profile>] [--parent <id>]... [--tenant <name>] [--workspace scratch|worktree|dir:<path>] - [--priority N] [--json] + [--priority N] [--triage] [--json] hermes kanban list [--mine] [--assignee P] [--status S] [--tenant T] [--archived] [--json] hermes kanban show <id> [--json] hermes kanban assign <id> <profile> # or 'none' to unassign @@ -225,6 +237,7 @@ The board supports these eight patterns without any new primitives: | **P6 `@mention`** | inline routing from prose | `@reviewer look at this` | | **P7 Thread-scoped workspace** | `/kanban here` in a thread | per-project gateway threads | | **P8 Fleet farming** | one profile, N subjects | 50 social accounts | +| **P9 Triage specifier** | rough idea → `triage` → specifier expands body → `todo` | "turn this one-liner into a spec' task" | For worked examples of each, see `docs/hermes-kanban-v1-spec.pdf`.