diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js new file mode 100644 index 00000000000..081f08b6d2e --- /dev/null +++ b/plugins/kanban/dashboard/dist/index.js @@ -0,0 +1,716 @@ +/** + * Hermes Kanban — Dashboard Plugin + * + * Board view for the multi-agent collaboration board backed by + * ~/.hermes/kanban.db. Calls the plugin's backend at /api/plugins/kanban/ + * 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). + */ +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK) { + // Dashboard host didn't expose the SDK — nothing we can do. + return; + } + + const { React } = SDK; + const h = React.createElement; + const { + Card, + CardHeader, + CardTitle, + CardContent, + Badge, + Button, + Input, + 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_LABEL = { + todo: "Todo", + ready: "Ready", + running: "In Progress", + blocked: "Blocked", + done: "Done", + archived: "Archived", + }; + const COLUMN_HELP = { + todo: "Waiting on dependencies or unassigned", + ready: "Assigned and waiting for a dispatcher tick", + running: "Claimed by a worker — in-flight", + blocked: "Worker asked for human input", + done: "Completed", + archived: "Archived", + }; + const COLUMN_DOT = { + todo: "hermes-kanban-dot-todo", + ready: "hermes-kanban-dot-ready", + running: "hermes-kanban-dot-running", + blocked: "hermes-kanban-dot-blocked", + done: "hermes-kanban-dot-done", + archived: "hermes-kanban-dot-archived", + }; + + const API = "/api/plugins/kanban"; + + // ------------------------------------------------------------------------- + // Root page + // ------------------------------------------------------------------------- + + function KanbanPage() { + const [board, setBoard] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [tenantFilter, setTenantFilter] = useState(""); + const [assigneeFilter, setAssigneeFilter] = useState(""); + const [includeArchived, setIncludeArchived] = useState(false); + const [search, setSearch] = useState(""); + + const [selectedTaskId, setSelectedTaskId] = useState(null); + + const cursorRef = useRef(0); + const wsRef = useRef(null); + + // --- fetch full board --------------------------------------------------- + const loadBoard = useCallback(() => { + const qs = new URLSearchParams(); + if (tenantFilter) qs.set("tenant", tenantFilter); + if (includeArchived) qs.set("include_archived", "true"); + const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`; + return SDK.fetchJSON(url) + .then(function (data) { + setBoard(data); + cursorRef.current = data.latest_event_id || 0; + setError(null); + }) + .catch(function (err) { + setError(String(err && err.message ? err.message : err)); + }) + .finally(function () { + setLoading(false); + }); + }, [tenantFilter, includeArchived]); + + useEffect(function () { + loadBoard(); + }, [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(); + } + } catch (e) { + // ignore malformed frames + } + }; + ws.onclose = function () { + if (!closed) { + // Auto-reconnect after a short delay. + setTimeout(function () { + if (!closed) loadBoard(); + }, 1500); + } + }; + return function () { + closed = true; + try { ws.close(); } catch (e) { /* noop */ } + }; + }, [board, loadBoard]); + + // --- filtering ---------------------------------------------------------- + const filteredBoard = useMemo(function () { + if (!board) return null; + const q = search.trim().toLowerCase(); + const filterTask = function (t) { + if (assigneeFilter && t.assignee !== assigneeFilter) return false; + if (q) { + const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase(); + if (hay.indexOf(q) === -1) return false; + } + return true; + }; + return Object.assign({}, board, { + columns: board.columns.map(function (col) { + return Object.assign({}, col, { + tasks: col.tasks.filter(filterTask), + }); + }), + }); + }, [board, assigneeFilter, search]); + + // --- actions ------------------------------------------------------------ + const moveTask = useCallback(function (taskId, newStatus) { + // Optimistic move: update local board first, reconcile on refresh. + setBoard(function (b) { + if (!b) return b; + let moved = null; + const columns = b.columns.map(function (col) { + const next = col.tasks.filter(function (t) { + if (t.id === taskId) { moved = Object.assign({}, t, { status: newStatus }); return false; } + return true; + }); + return Object.assign({}, col, { tasks: next }); + }); + if (moved) { + const dest = columns.find(function (c) { return c.name === newStatus; }); + if (dest) dest.tasks = [moved].concat(dest.tasks); + } + return Object.assign({}, b, { columns: columns }); + }); + SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }) + .catch(function (err) { + setError(`Move failed: ${err.message || err}`); + loadBoard(); + }); + }, [loadBoard]); + + const createTask = useCallback(function (body) { + return SDK.fetchJSON(`${API}/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then(loadBoard); + }, [loadBoard]); + + // --- render ------------------------------------------------------------- + if (loading && !board) { + return h("div", { className: "p-8 text-sm text-muted-foreground" }, + "Loading Kanban board…"); + } + if (error && !board) { + return h(Card, null, + h(CardContent, { className: "p-6" }, + 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."), + ), + ); + } + 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, + ); + } + + // ------------------------------------------------------------------------- + // Toolbar (filters + global actions) + // ------------------------------------------------------------------------- + + function BoardToolbar(props) { + const tenants = (props.board && props.board.tenants) || []; + const assignees = (props.board && props.board.assignees) || []; + + return h("div", { className: "flex flex-wrap items-end gap-3" }, + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, "Search"), + h(Input, { + placeholder: "Filter cards…", + value: props.search, + onChange: function (e) { props.setSearch(e.target.value); }, + className: "w-56 h-8", + }), + ), + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"), + h(Select, { + value: props.tenantFilter, + onChange: function (e) { props.setTenantFilter(e.target.value); }, + className: "h-8", + }, + h(SelectOption, { value: "" }, "All tenants"), + tenants.map(function (t) { + return h(SelectOption, { key: t, value: t }, t); + }), + ), + ), + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"), + h(Select, { + value: props.assigneeFilter, + onChange: function (e) { props.setAssigneeFilter(e.target.value); }, + className: "h-8", + }, + h(SelectOption, { value: "" }, "All profiles"), + assignees.map(function (a) { + return h(SelectOption, { key: a, value: a }, a); + }), + ), + ), + h("label", { className: "flex items-center gap-2 text-xs" }, + h("input", { + type: "checkbox", + checked: props.includeArchived, + onChange: function (e) { props.setIncludeArchived(e.target.checked); }, + }), + "Show archived", + ), + h("div", { className: "flex-1" }), + h(Button, { + onClick: props.onNudgeDispatch, + className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Nudge dispatcher"), + h(Button, { + onClick: props.onRefresh, + className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Refresh"), + ); + } + + // ------------------------------------------------------------------------- + // Columns + // ------------------------------------------------------------------------- + + function BoardColumns(props) { + return h("div", { className: "hermes-kanban-columns" }, + props.board.columns.map(function (col) { + return h(Column, { + key: col.name, + column: col, + onMove: props.onMove, + onOpen: props.onOpen, + onCreate: props.onCreate, + }); + }), + ); + } + + function Column(props) { + const [dragOver, setDragOver] = useState(false); + const [showCreate, setShowCreate] = useState(false); + + const handleDragOver = function (e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (!dragOver) setDragOver(true); + }; + const handleDragLeave = function () { setDragOver(false); }; + const handleDrop = function (e) { + e.preventDefault(); + setDragOver(false); + const taskId = e.dataTransfer.getData("text/x-hermes-task"); + if (taskId) props.onMove(taskId, props.column.name); + }; + + return h("div", { + className: cn( + "hermes-kanban-column", + dragOver ? "hermes-kanban-column--drop" : "", + ), + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + h("div", { className: "hermes-kanban-column-header" }, + h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }), + h("span", { className: "hermes-kanban-column-label" }, + COLUMN_LABEL[props.column.name] || props.column.name), + h("span", { className: "hermes-kanban-column-count" }, + props.column.tasks.length), + h("button", { + type: "button", + className: "hermes-kanban-column-add", + title: "Create task in this column", + onClick: function () { setShowCreate(function (v) { return !v; }); }, + }, showCreate ? "×" : "+"), + ), + h("div", { className: "hermes-kanban-column-sub" }, + COLUMN_HELP[props.column.name] || ""), + showCreate ? h(InlineCreate, { + defaultStatus: props.column.name, + onSubmit: function (body) { + props.onCreate(body).then(function () { setShowCreate(false); }); + }, + onCancel: function () { setShowCreate(false); }, + }) : null, + 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, + }); + }), + ), + ); + } + + // ------------------------------------------------------------------------- + // Card + // ------------------------------------------------------------------------- + + function TaskCard(props) { + const t = props.task; + + const handleDragStart = function (e) { + e.dataTransfer.setData("text/x-hermes-task", t.id); + e.dataTransfer.effectAllowed = "move"; + }; + + return h("div", { + className: "hermes-kanban-card", + draggable: true, + onDragStart: handleDragStart, + onClick: function () { props.onOpen(t.id); }, + }, + h(Card, null, + h(CardContent, { className: "hermes-kanban-card-content" }, + h("div", { className: "hermes-kanban-card-row" }, + h("span", { className: "hermes-kanban-card-id" }, t.id), + t.priority > 0 + ? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`) + : null, + t.tenant + ? h(Badge, { variant: "outline", className: "hermes-kanban-tag" }, t.tenant) + : null, + ), + h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"), + h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" }, + t.assignee + ? h("span", { className: "hermes-kanban-assignee" }, "@", t.assignee) + : h("span", { className: "hermes-kanban-unassigned" }, "unassigned"), + t.comment_count > 0 + ? h("span", { className: "hermes-kanban-count" }, + "💬 ", t.comment_count) + : null, + t.link_counts && (t.link_counts.parents + t.link_counts.children) > 0 + ? h("span", { className: "hermes-kanban-count" }, + "↔ ", t.link_counts.parents + t.link_counts.children) + : null, + h("span", { className: "hermes-kanban-ago" }, + timeAgo ? timeAgo(t.created_at) : ""), + ), + ), + ), + ); + } + + // ------------------------------------------------------------------------- + // Inline create + // ------------------------------------------------------------------------- + + function InlineCreate(props) { + const [title, setTitle] = useState(""); + const [assignee, setAssignee] = useState(""); + const [priority, setPriority] = useState(0); + + const submit = function () { + const trimmed = title.trim(); + if (!trimmed) return; + props.onSubmit({ + title: trimmed, + assignee: assignee.trim() || null, + priority: Number(priority) || 0, + }); + setTitle(""); + setAssignee(""); + setPriority(0); + }; + + return h("div", { className: "hermes-kanban-inline-create" }, + h(Input, { + value: title, + onChange: function (e) { setTitle(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter") { e.preventDefault(); submit(); } + if (e.key === "Escape") props.onCancel(); + }, + placeholder: "New task title…", + autoFocus: true, + className: "h-8 text-sm", + }), + h("div", { className: "flex gap-2" }, + h(Input, { + value: assignee, + onChange: function (e) { setAssignee(e.target.value); }, + placeholder: "assignee (optional)", + className: "h-7 text-xs flex-1", + }), + h(Input, { + type: "number", + value: priority, + onChange: function (e) { setPriority(e.target.value); }, + placeholder: "pri", + className: "h-7 text-xs w-16", + }), + ), + h("div", { className: "flex gap-2" }, + h(Button, { + onClick: submit, + className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer flex-1", + }, "Create"), + h(Button, { + onClick: props.onCancel, + className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Cancel"), + ), + ); + } + + // ------------------------------------------------------------------------- + // Task drawer (side panel) + // ------------------------------------------------------------------------- + + function TaskDrawer(props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [newComment, setNewComment] = useState(""); + + const load = useCallback(function () { + return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`) + .then(function (d) { setData(d); setErr(null); }) + .catch(function (e) { setErr(String(e.message || e)); }) + .finally(function () { setLoading(false); }); + }, [props.taskId]); + + useEffect(function () { load(); }, [load]); + + const handleComment = function () { + const body = newComment.trim(); + if (!body) return; + SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: body }), + }).then(function () { + setNewComment(""); + load(); + props.onRefresh(); + }).catch(function (e) { + setErr(String(e.message || e)); + }); + }; + + return h("div", { className: "hermes-kanban-drawer-shade", onClick: props.onClose }, + h("div", { + className: "hermes-kanban-drawer", + onClick: function (e) { e.stopPropagation(); }, + }, + h("div", { className: "hermes-kanban-drawer-head" }, + h("span", { className: "text-xs text-muted-foreground" }, props.taskId), + h("button", { + type: "button", + onClick: props.onClose, + className: "hermes-kanban-drawer-close", + title: "Close", + }, "×"), + ), + 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) { + return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }).then(function () { load(); props.onRefresh(); }); + }, + }) : null, + data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, + h(Input, { + value: newComment, + onChange: function (e) { setNewComment(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); handleComment(); + } + }, + placeholder: "Add a comment… (Enter to submit)", + className: "h-8 text-sm flex-1", + }), + h(Button, { + onClick: handleComment, + className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Comment"), + ) : null, + ), + ); + } + + function TaskDetail(props) { + const t = props.data.task; + const comments = props.data.comments || []; + const events = props.data.events || []; + const links = props.data.links || { parents: [], children: [] }; + + return h("div", { className: "hermes-kanban-drawer-body" }, + h("div", { className: "hermes-kanban-drawer-title" }, + h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[t.status]) }), + h("span", null, t.title || "(untitled)"), + ), + h("div", { className: "hermes-kanban-drawer-meta" }, + h(MetaRow, { label: "Status", value: t.status }), + h(MetaRow, { + label: "Assignee", + value: t.assignee || "unassigned", + }), + h(MetaRow, { label: "Priority", value: String(t.priority) }), + t.tenant ? h(MetaRow, { label: "Tenant", value: t.tenant }) : null, + h(MetaRow, { + label: "Workspace", + value: `${t.workspace_kind}${t.workspace_path ? ": " + t.workspace_path : ""}`, + }), + t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null, + ), + h(StatusActions, { task: t, onPatch: props.onPatch }), + t.body ? h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, "Description"), + h("pre", { className: "hermes-kanban-pre" }, t.body), + ) : null, + (links.parents.length > 0 || links.children.length > 0) ? + h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, "Dependencies"), + links.parents.length > 0 ? h("div", { className: "text-xs" }, + "Parents: ", + links.parents.map(function (id) { + return h(Badge, { key: id, variant: "outline", className: "ml-1" }, id); + }), + ) : null, + links.children.length > 0 ? h("div", { className: "text-xs mt-1" }, + "Children: ", + links.children.map(function (id) { + return h(Badge, { key: id, variant: "outline", className: "ml-1" }, id); + }), + ) : null, + ) : null, + t.result ? h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, "Result"), + h("pre", { className: "hermes-kanban-pre" }, t.result), + ) : null, + h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, `Comments (${comments.length})`), + comments.length === 0 + ? h("div", { className: "text-xs text-muted-foreground" }, "— no comments —") + : comments.map(function (c) { + return h("div", { key: c.id, className: "hermes-kanban-comment" }, + h("div", { className: "hermes-kanban-comment-head" }, + h("span", { className: "hermes-kanban-comment-author" }, c.author || "anon"), + h("span", { className: "hermes-kanban-comment-ago" }, + timeAgo ? timeAgo(c.created_at) : ""), + ), + h("pre", { className: "hermes-kanban-pre" }, c.body), + ); + }), + ), + h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`), + events.slice().reverse().slice(0, 20).map(function (e) { + return h("div", { key: e.id, className: "hermes-kanban-event" }, + h("span", { className: "hermes-kanban-event-kind" }, e.kind), + h("span", { className: "hermes-kanban-event-ago" }, + timeAgo ? timeAgo(e.created_at) : ""), + e.payload + ? h("code", { className: "hermes-kanban-event-payload" }, + JSON.stringify(e.payload)) + : null, + ); + }), + ), + ); + } + + function MetaRow(props) { + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, props.label), + h("span", { className: "hermes-kanban-meta-value" }, props.value), + ); + } + + function StatusActions(props) { + const t = props.task; + const b = function (label, patch, enabled) { + return h(Button, { + onClick: function () { if (enabled !== false) props.onPatch(patch); }, + disabled: enabled === false, + className: cn( + "h-7 px-2 text-xs border border-border cursor-pointer", + enabled === false ? "opacity-40 cursor-not-allowed" : "hover:bg-foreground/10", + ), + }, label); + }; + + return h("div", { className: "hermes-kanban-actions" }, + 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("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"), + ); + } + + // ------------------------------------------------------------------------- + // Register + // ------------------------------------------------------------------------- + + if (window.__HERMES_PLUGINS__ && typeof window.__HERMES_PLUGINS__.register === "function") { + window.__HERMES_PLUGINS__.register("kanban", KanbanPage); + } +})(); diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css new file mode 100644 index 00000000000..f22698228ed --- /dev/null +++ b/plugins/kanban/dashboard/dist/style.css @@ -0,0 +1,377 @@ +/* + * Hermes Kanban — dashboard plugin styles. + * + * All colors reference theme CSS vars so the board reskins with the + * active dashboard theme. No hardcoded palette. + */ + +.hermes-kanban { + width: 100%; +} + +/* ---- Columns layout -------------------------------------------------- */ + +.hermes-kanban-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 0.75rem; + align-items: start; +} + +.hermes-kanban-column { + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--color-card) 85%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.5rem; + min-height: 200px; + max-height: calc(100vh - 220px); + transition: border-color 120ms ease, background-color 120ms ease; +} + +.hermes-kanban-column--drop { + border-color: var(--color-ring); + background: color-mix(in srgb, var(--color-ring) 8%, var(--color-card)); +} + +.hermes-kanban-column-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.25rem 0.35rem; + font-weight: 600; + font-size: 0.85rem; + color: var(--color-foreground); +} + +.hermes-kanban-column-label { + flex: 1; + letter-spacing: 0.01em; +} + +.hermes-kanban-column-count { + font-variant-numeric: tabular-nums; + color: var(--color-muted-foreground); + font-size: 0.75rem; + font-weight: 500; +} + +.hermes-kanban-column-add { + appearance: none; + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-foreground); + border-radius: var(--radius-sm, 0.25rem); + width: 22px; + height: 22px; + line-height: 1; + font-size: 1rem; + cursor: pointer; +} +.hermes-kanban-column-add:hover { + background: color-mix(in srgb, var(--color-foreground) 8%, transparent); +} + +.hermes-kanban-column-sub { + padding: 0 0.25rem 0.5rem; + font-size: 0.7rem; + color: var(--color-muted-foreground); + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent); + margin-bottom: 0.5rem; +} + +.hermes-kanban-column-body { + display: flex; + flex-direction: column; + gap: 0.45rem; + overflow-y: auto; + padding-right: 0.1rem; +} + +.hermes-kanban-empty { + padding: 1.5rem 0.5rem; + text-align: center; + font-size: 0.75rem; + color: var(--color-muted-foreground); + border: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent); + border-radius: var(--radius-sm, 0.25rem); +} + +/* ---- Status dots ----------------------------------------------------- */ + +.hermes-kanban-dot { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--color-muted-foreground); +} +.hermes-kanban-dot-todo { background: var(--color-muted-foreground); } +.hermes-kanban-dot-ready { background: #d4b348; } /* amber */ +.hermes-kanban-dot-running { background: #3fb97d; } /* green */ +.hermes-kanban-dot-blocked { background: var(--color-destructive, #d14a4a); } +.hermes-kanban-dot-done { background: #4a8cd1; } /* blue */ +.hermes-kanban-dot-archived { background: var(--color-border); } + +/* ---- Card ------------------------------------------------------------ */ + +.hermes-kanban-card { + cursor: grab; + transition: transform 100ms ease, box-shadow 100ms ease; +} +.hermes-kanban-card:hover { + box-shadow: 0 1px 0 0 var(--color-ring) inset, 0 0 0 1px var(--color-ring) inset; +} +.hermes-kanban-card:active { + cursor: grabbing; + transform: scale(0.995); +} + +.hermes-kanban-card-content { + padding: 0.5rem 0.6rem !important; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.hermes-kanban-card-row { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.hermes-kanban-card-id { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.65rem; + color: var(--color-muted-foreground); + letter-spacing: 0.03em; +} + +.hermes-kanban-card-title { + font-size: 0.85rem; + font-weight: 500; + line-height: 1.3; + color: var(--color-foreground); + word-break: break-word; +} + +.hermes-kanban-card-meta { + font-size: 0.7rem; + color: var(--color-muted-foreground); + gap: 0.55rem; +} + +.hermes-kanban-priority { + font-size: 0.6rem !important; + padding: 0.05rem 0.3rem !important; + background: color-mix(in srgb, var(--color-ring) 18%, transparent); + color: var(--color-foreground); + border: 1px solid color-mix(in srgb, var(--color-ring) 40%, transparent); +} + +.hermes-kanban-tag { + font-size: 0.6rem !important; + padding: 0.05rem 0.3rem !important; +} + +.hermes-kanban-assignee { + font-weight: 500; + color: color-mix(in srgb, var(--color-foreground) 80%, var(--color-muted-foreground)); +} +.hermes-kanban-unassigned { + font-style: italic; +} +.hermes-kanban-ago { + margin-left: auto; +} + +/* ---- Inline create --------------------------------------------------- */ + +.hermes-kanban-inline-create { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem; + margin-bottom: 0.5rem; + background: color-mix(in srgb, var(--color-card) 70%, transparent); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm, 0.25rem); +} + +/* ---- Drawer (task detail side panel) --------------------------------- */ + +.hermes-kanban-drawer-shade { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 60; + display: flex; + justify-content: flex-end; +} + +.hermes-kanban-drawer { + width: min(480px, 92vw); + height: 100vh; + background: var(--color-card); + border-left: 1px solid var(--color-border); + display: flex; + flex-direction: column; + box-shadow: -4px 0 18px rgba(0, 0, 0, 0.35); + animation: hermes-kanban-drawer-in 180ms ease-out; +} + +@keyframes hermes-kanban-drawer-in { + from { transform: translateX(100%); opacity: 0.3; } + to { transform: translateX(0); opacity: 1; } +} + +.hermes-kanban-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.8rem; + border-bottom: 1px solid var(--color-border); + font-family: var(--font-mono, ui-monospace, monospace); +} + +.hermes-kanban-drawer-close { + appearance: none; + background: transparent; + border: 0; + color: var(--color-muted-foreground); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; +} +.hermes-kanban-drawer-close:hover { color: var(--color-foreground); } + +.hermes-kanban-drawer-body { + flex: 1; + overflow-y: auto; + padding: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.hermes-kanban-drawer-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; +} + +.hermes-kanban-drawer-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.5rem 0.6rem; + background: color-mix(in srgb, var(--color-foreground) 4%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); +} + +.hermes-kanban-meta-row { + display: flex; + gap: 0.5rem; + font-size: 0.72rem; +} +.hermes-kanban-meta-label { + width: 92px; + color: var(--color-muted-foreground); +} +.hermes-kanban-meta-value { + color: var(--color-foreground); + word-break: break-word; +} + +.hermes-kanban-actions { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.hermes-kanban-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.hermes-kanban-section-head { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--color-muted-foreground); +} + +.hermes-kanban-pre { + margin: 0; + padding: 0.45rem 0.55rem; + white-space: pre-wrap; + word-break: break-word; + background: color-mix(in srgb, var(--color-foreground) 4%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.72rem; + color: var(--color-foreground); +} + +.hermes-kanban-comment { + border-left: 2px solid color-mix(in srgb, var(--color-ring) 35%, transparent); + padding-left: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.hermes-kanban-comment-head { + display: flex; + gap: 0.5rem; + font-size: 0.7rem; +} +.hermes-kanban-comment-author { + font-weight: 600; + color: var(--color-foreground); +} +.hermes-kanban-comment-ago { + color: var(--color-muted-foreground); +} + +.hermes-kanban-event { + display: flex; + gap: 0.5rem; + font-size: 0.7rem; + color: var(--color-muted-foreground); + font-family: var(--font-mono, ui-monospace, monospace); +} +.hermes-kanban-event-kind { + color: var(--color-foreground); + min-width: 6rem; +} +.hermes-kanban-event-payload { + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; +} + +.hermes-kanban-drawer-comment-row { + display: flex; + gap: 0.4rem; + padding: 0.55rem 0.75rem; + border-top: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-card) 90%, transparent); +} + +.hermes-kanban-count { + display: inline-flex; + gap: 0.2rem; + align-items: center; +} diff --git a/plugins/kanban/dashboard/manifest.json b/plugins/kanban/dashboard/manifest.json new file mode 100644 index 00000000000..8be4b8c4517 --- /dev/null +++ b/plugins/kanban/dashboard/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "kanban", + "label": "Kanban", + "description": "Multi-agent collaboration board — drag-drop cards across columns, read comment threads, see which profile is running what", + "icon": "Package", + "version": "1.0.0", + "tab": { + "path": "/kanban", + "position": "after:skills" + }, + "entry": "dist/index.js", + "css": "dist/style.css", + "api": "plugin_api.py" +} diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py new file mode 100644 index 00000000000..8208ed36f72 --- /dev/null +++ b/plugins/kanban/dashboard/plugin_api.py @@ -0,0 +1,496 @@ +"""Kanban dashboard plugin — backend API routes. + +Mounted at /api/plugins/kanban/ by the dashboard plugin system. + +This layer is intentionally thin: every handler is a small wrapper around +``hermes_cli.kanban_db`` or a direct SQL query. Writes use the same code +paths the CLI and gateway ``/kanban`` command use, so the three surfaces +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). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sqlite3 +import time +from dataclasses import asdict +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field + +from hermes_cli import kanban_db + +log = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# 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"] + + +def _task_dict(task: kanban_db.Task) -> dict[str, Any]: + d = asdict(task) + # Keep body short on list endpoints; full body comes from /tasks/:id. + return d + + +def _event_dict(event: kanban_db.Event) -> dict[str, Any]: + return { + "id": event.id, + "task_id": event.task_id, + "kind": event.kind, + "payload": event.payload, + "created_at": event.created_at, + } + + +def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]: + return { + "id": c.id, + "task_id": c.task_id, + "author": c.author, + "body": c.body, + "created_at": c.created_at, + } + + +def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]: + """Return {'parents': [...], 'children': [...]} for a task.""" + parents = [ + r["parent_id"] + for r in conn.execute( + "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", + (task_id,), + ) + ] + children = [ + r["child_id"] + for r in conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", + (task_id,), + ) + ] + return {"parents": parents, "children": children} + + +# --------------------------------------------------------------------------- +# GET /board +# --------------------------------------------------------------------------- + +@router.get("/board") +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.""" + conn = kanban_db.connect() + try: + tasks = kanban_db.list_tasks( + conn, tenant=tenant, include_archived=include_archived + ) + # Pre-fetch link counts per task (cheap: one query). + link_counts: dict[str, dict[str, int]] = {} + for row in conn.execute( + "SELECT parent_id, child_id FROM task_links" + ).fetchall(): + link_counts.setdefault(row["parent_id"], {"parents": 0, "children": 0})[ + "children" + ] += 1 + link_counts.setdefault(row["child_id"], {"parents": 0, "children": 0})[ + "parents" + ] += 1 + + # Comment + event counts (both cheap aggregates). + comment_counts: dict[str, int] = { + r["task_id"]: r["n"] + for r in conn.execute( + "SELECT task_id, COUNT(*) AS n FROM task_comments GROUP BY task_id" + ) + } + + latest_event_id = conn.execute( + "SELECT COALESCE(MAX(id), 0) AS m FROM task_events" + ).fetchone()["m"] + + columns: dict[str, list[dict]] = {c: [] for c in BOARD_COLUMNS} + if include_archived: + columns["archived"] = [] + + for t in tasks: + 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) + col = t.status if t.status in columns else "todo" + columns[col].append(d) + + # Stable per-column ordering already applied by list_tasks + # (priority DESC, created_at ASC), keep as-is. + + # List of known tenants for the UI filter dropdown. + tenants = [ + r["tenant"] + for r in conn.execute( + "SELECT DISTINCT tenant FROM tasks WHERE tenant IS NOT NULL ORDER BY tenant" + ) + ] + # List of distinct assignees for the lane-by-profile sub-grouping. + assignees = [ + r["assignee"] + for r in conn.execute( + "SELECT DISTINCT assignee FROM tasks WHERE assignee IS NOT NULL " + "AND status != 'archived' ORDER BY assignee" + ) + ] + + return { + "columns": [ + {"name": name, "tasks": columns[name]} for name in columns.keys() + ], + "tenants": tenants, + "assignees": assignees, + "latest_event_id": int(latest_event_id), + "now": int(time.time()), + } + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# GET /tasks/:id +# --------------------------------------------------------------------------- + +@router.get("/tasks/{task_id}") +def get_task(task_id: str): + conn = kanban_db.connect() + try: + task = kanban_db.get_task(conn, task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + return { + "task": _task_dict(task), + "comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)], + "events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)], + "links": _links_for(conn, task_id), + } + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# POST /tasks +# --------------------------------------------------------------------------- + +class CreateTaskBody(BaseModel): + title: str + body: Optional[str] = None + assignee: Optional[str] = None + tenant: Optional[str] = None + priority: int = 0 + workspace_kind: str = "scratch" + workspace_path: Optional[str] = None + parents: list[str] = Field(default_factory=list) + + +@router.post("/tasks") +def create_task(payload: CreateTaskBody): + conn = kanban_db.connect() + try: + task_id = kanban_db.create_task( + conn, + title=payload.title, + body=payload.body, + assignee=payload.assignee, + created_by="dashboard", + workspace_kind=payload.workspace_kind, + workspace_path=payload.workspace_path, + tenant=payload.tenant, + priority=payload.priority, + parents=payload.parents, + ) + task = kanban_db.get_task(conn, task_id) + return {"task": _task_dict(task) if task else None} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# PATCH /tasks/:id (status / assignee / priority / title / body) +# --------------------------------------------------------------------------- + +class UpdateTaskBody(BaseModel): + status: Optional[str] = None + assignee: Optional[str] = None + priority: Optional[int] = None + title: Optional[str] = None + body: Optional[str] = None + result: Optional[str] = None + block_reason: Optional[str] = None + + +@router.patch("/tasks/{task_id}") +def update_task(task_id: str, payload: UpdateTaskBody): + conn = kanban_db.connect() + try: + task = kanban_db.get_task(conn, task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + + # --- assignee ---------------------------------------------------- + if payload.assignee is not None: + try: + ok = kanban_db.assign_task( + conn, task_id, payload.assignee or None, + ) + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + if not ok: + raise HTTPException(status_code=404, detail="task not found") + + # --- status ------------------------------------------------------- + if payload.status is not None: + s = payload.status + ok = True + if s == "done": + ok = kanban_db.complete_task(conn, task_id, result=payload.result) + elif s == "blocked": + ok = kanban_db.block_task(conn, task_id, reason=payload.block_reason) + elif s == "ready": + # Re-open a blocked task, or just an explicit status set. + current = kanban_db.get_task(conn, task_id) + if current and current.status == "blocked": + ok = kanban_db.unblock_task(conn, task_id) + else: + # Direct status write for drag-drop (todo -> ready etc). + ok = _set_status_direct(conn, task_id, "ready") + elif s == "archived": + ok = kanban_db.archive_task(conn, task_id) + elif s in ("todo", "running"): + ok = _set_status_direct(conn, task_id, s) + else: + raise HTTPException(status_code=400, detail=f"unknown status: {s}") + if not ok: + raise HTTPException( + status_code=409, + detail=f"status transition to {s!r} not valid from current state", + ) + + # --- priority ----------------------------------------------------- + if payload.priority is not None: + with kanban_db.write_txn(conn): + conn.execute( + "UPDATE tasks SET priority = ? WHERE id = ?", + (int(payload.priority), task_id), + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'priority', ?, ?)", + (task_id, json.dumps({"priority": int(payload.priority)}), + int(time.time())), + ) + + # --- title / body ------------------------------------------------- + if payload.title is not None or payload.body is not None: + with kanban_db.write_txn(conn): + sets, vals = [], [] + if payload.title is not None: + if not payload.title.strip(): + raise HTTPException(status_code=400, detail="title cannot be empty") + sets.append("title = ?") + vals.append(payload.title.strip()) + if payload.body is not None: + sets.append("body = ?") + vals.append(payload.body) + vals.append(task_id) + conn.execute( + f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?", vals, + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'edited', NULL, ?)", + (task_id, int(time.time())), + ) + + updated = kanban_db.get_task(conn, task_id) + return {"task": _task_dict(updated) if updated else None} + finally: + conn.close() + + +def _set_status_direct( + conn: sqlite3.Connection, task_id: str, new_status: str, +) -> bool: + """Direct status write for drag-drop moves that aren't covered by the + structured complete/block/unblock/archive verbs (e.g. todo<->ready, + running<->ready). Appends a ``status`` event row for the live feed.""" + with kanban_db.write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET status = ?, " + " claim_lock = CASE WHEN ? = 'running' THEN claim_lock ELSE NULL END, " + " claim_expires = CASE WHEN ? = 'running' THEN claim_expires ELSE NULL END " + "WHERE id = ?", + (new_status, new_status, new_status, task_id), + ) + if cur.rowcount != 1: + return False + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'status', ?, ?)", + (task_id, json.dumps({"status": new_status}), int(time.time())), + ) + # If we re-opened something, children may have gone stale. + if new_status in ("done", "ready"): + kanban_db.recompute_ready(conn) + return True + + +# --------------------------------------------------------------------------- +# Comments +# --------------------------------------------------------------------------- + +class CommentBody(BaseModel): + body: str + author: Optional[str] = "dashboard" + + +@router.post("/tasks/{task_id}/comments") +def add_comment(task_id: str, payload: CommentBody): + if not payload.body.strip(): + raise HTTPException(status_code=400, detail="body is required") + conn = kanban_db.connect() + try: + if kanban_db.get_task(conn, task_id) is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + kanban_db.add_comment( + conn, task_id, author=payload.author or "dashboard", body=payload.body, + ) + return {"ok": True} + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Links +# --------------------------------------------------------------------------- + +class LinkBody(BaseModel): + parent_id: str + child_id: str + + +@router.post("/links") +def add_link(payload: LinkBody): + conn = kanban_db.connect() + try: + kanban_db.link_tasks(conn, payload.parent_id, payload.child_id) + return {"ok": True} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + conn.close() + + +@router.delete("/links") +def delete_link(parent_id: str = Query(...), child_id: str = Query(...)): + conn = kanban_db.connect() + try: + ok = kanban_db.unlink_tasks(conn, parent_id, child_id) + return {"ok": bool(ok)} + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Dispatch nudge (optional quick-path so the UI doesn't wait 60 s) +# --------------------------------------------------------------------------- + +@router.post("/dispatch") +def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")): + conn = kanban_db.connect() + try: + result = kanban_db.dispatch_once( + conn, dry_run=dry_run, max_spawn=max_n, + ) + # DispatchResult is a dataclass. + try: + return asdict(result) + except TypeError: + return {"result": str(result)} + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# WebSocket: /events?since= +# --------------------------------------------------------------------------- + +# Poll interval for the event tail loop. SQLite WAL + 300 ms polling is +# the simplest and most robust approach; it adds a fraction of a percent +# of CPU and has no shared state to synchronize across workers. +_EVENT_POLL_SECONDS = 0.3 + + +@router.websocket("/events") +async def stream_events(ws: WebSocket): + await ws.accept() + try: + since_raw = ws.query_params.get("since", "0") + try: + cursor = int(since_raw) + except ValueError: + cursor = 0 + + def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]: + conn = kanban_db.connect() + try: + rows = conn.execute( + "SELECT id, task_id, kind, payload, created_at " + "FROM task_events WHERE id > ? ORDER BY id ASC LIMIT 200", + (cursor_val,), + ).fetchall() + out: list[dict] = [] + new_cursor = cursor_val + for r in rows: + try: + payload = json.loads(r["payload"]) if r["payload"] else None + except Exception: + payload = None + out.append({ + "id": r["id"], + "task_id": r["task_id"], + "kind": r["kind"], + "payload": payload, + "created_at": r["created_at"], + }) + new_cursor = r["id"] + return new_cursor, out + finally: + conn.close() + + while True: + cursor, events = await asyncio.to_thread(_fetch_new, cursor) + if events: + await ws.send_json({"events": events, "cursor": cursor}) + await asyncio.sleep(_EVENT_POLL_SECONDS) + except WebSocketDisconnect: + return + except Exception as exc: # defensive: never crash the dashboard worker + log.warning("Kanban event stream error: %s", exc) + try: + await ws.close() + except Exception: + pass diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py new file mode 100644 index 00000000000..837826403c0 --- /dev/null +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -0,0 +1,333 @@ +"""Tests for the Kanban dashboard plugin backend (plugins/kanban/dashboard/plugin_api.py). + +The plugin mounts as /api/plugins/kanban/ inside the dashboard's FastAPI app, +but here we attach its router to a bare FastAPI instance so we can test the +REST surface without spinning up the whole dashboard. +""" + +from __future__ import annotations + +import importlib.util +import sys +import time +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hermes_cli import kanban_db as kb + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _load_plugin_router(): + """Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router.""" + repo_root = Path(__file__).resolve().parents[2] + plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py" + assert plugin_file.exists(), f"plugin file missing: {plugin_file}" + + spec = importlib.util.spec_from_file_location( + "hermes_dashboard_plugin_kanban_test", plugin_file, + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod.router + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with an empty kanban DB.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +@pytest.fixture +def client(kanban_home): + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + return TestClient(app) + + +# --------------------------------------------------------------------------- +# GET /board on an empty DB +# --------------------------------------------------------------------------- + + +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. + names = [c["name"] for c in data["columns"]] + for expected in ("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"] == [] + assert data["assignees"] == [] + assert data["latest_event_id"] == 0 + + +# --------------------------------------------------------------------------- +# POST /tasks then GET /board sees it +# --------------------------------------------------------------------------- + + +def test_create_task_appears_on_board(client): + r = client.post( + "/api/plugins/kanban/tasks", + json={ + "title": "Research LLM caching", + "assignee": "researcher", + "priority": 3, + "tenant": "acme", + }, + ) + assert r.status_code == 200, r.text + task = r.json()["task"] + assert task["title"] == "Research LLM caching" + assert task["assignee"] == "researcher" + assert task["status"] == "ready" # no parents -> immediately ready + assert task["priority"] == 3 + assert task["tenant"] == "acme" + task_id = task["id"] + + # Board now lists it under 'ready'. + r = client.get("/api/plugins/kanban/board") + assert r.status_code == 200 + data = r.json() + ready = next(c for c in data["columns"] if c["name"] == "ready") + assert len(ready["tasks"]) == 1 + assert ready["tasks"][0]["id"] == task_id + assert "acme" in data["tenants"] + assert "researcher" in data["assignees"] + + +def test_tenant_filter(client): + client.post("/api/plugins/kanban/tasks", json={"title": "A", "tenant": "t1"}) + client.post("/api/plugins/kanban/tasks", json={"title": "B", "tenant": "t2"}) + + r = client.get("/api/plugins/kanban/board?tenant=t1") + counts = {c["name"]: len(c["tasks"]) for c in r.json()["columns"]} + total = sum(counts.values()) + assert total == 1 + + r = client.get("/api/plugins/kanban/board?tenant=t2") + total = sum(len(c["tasks"]) for c in r.json()["columns"]) + assert total == 1 + + +# --------------------------------------------------------------------------- +# GET /tasks/:id returns body + comments + events + links +# --------------------------------------------------------------------------- + + +def test_task_detail_includes_links_and_events(client): + parent = client.post( + "/api/plugins/kanban/tasks", json={"title": "parent"}, + ).json()["task"] + child = client.post( + "/api/plugins/kanban/tasks", + json={"title": "child", "parents": [parent["id"]]}, + ).json()["task"] + assert child["status"] == "todo" # parent not done yet + + # Detail for the child shows the parent link. + r = client.get(f"/api/plugins/kanban/tasks/{child['id']}") + assert r.status_code == 200 + data = r.json() + assert data["task"]["id"] == child["id"] + assert parent["id"] in data["links"]["parents"] + + # Detail for the parent shows the child. + r = client.get(f"/api/plugins/kanban/tasks/{parent['id']}") + assert child["id"] in r.json()["links"]["children"] + + # Events exist from creation. + assert len(data["events"]) >= 1 + + +def test_task_detail_404_on_unknown(client): + r = client.get("/api/plugins/kanban/tasks/does-not-exist") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# PATCH /tasks/:id — status transitions +# --------------------------------------------------------------------------- + + +def test_patch_status_complete(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "done", "result": "shipped"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "done" + + # Board reflects the move. + done = next( + c for c in client.get("/api/plugins/kanban/board").json()["columns"] + if c["name"] == "done" + ) + assert any(x["id"] == t["id"] for x in done["tasks"]) + + +def test_patch_block_then_unblock(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "blocked", "block_reason": "need input"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "blocked" + + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "ready"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "ready" + + +def test_patch_drag_drop_move_todo_to_ready(client): + """Direct status write: the drag-drop path for statuses without a + dedicated verb (e.g. manually promoting todo -> ready).""" + parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"] + child = client.post( + "/api/plugins/kanban/tasks", + json={"title": "c", "parents": [parent["id"]]}, + ).json()["task"] + assert child["status"] == "todo" + + r = client.patch( + f"/api/plugins/kanban/tasks/{child['id']}", + json={"status": "ready"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "ready" + + +def test_patch_reassign(client): + t = client.post( + "/api/plugins/kanban/tasks", + json={"title": "x", "assignee": "a"}, + ).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"assignee": "b"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["assignee"] == "b" + + +def test_patch_priority_and_edit(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"priority": 5, "title": "renamed"}, + ) + assert r.status_code == 200 + data = r.json()["task"] + assert data["priority"] == 5 + assert data["title"] == "renamed" + + +def test_patch_invalid_status(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "banana"}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# Comments + Links +# --------------------------------------------------------------------------- + + +def test_add_comment(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.post( + f"/api/plugins/kanban/tasks/{t['id']}/comments", + json={"body": "how's progress?", "author": "teknium"}, + ) + assert r.status_code == 200 + + r = client.get(f"/api/plugins/kanban/tasks/{t['id']}") + comments = r.json()["comments"] + assert len(comments) == 1 + assert comments[0]["body"] == "how's progress?" + assert comments[0]["author"] == "teknium" + + +def test_add_comment_empty_rejected(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.post( + f"/api/plugins/kanban/tasks/{t['id']}/comments", + json={"body": " "}, + ) + assert r.status_code == 400 + + +def test_add_link_and_delete_link(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + + r = client.post( + "/api/plugins/kanban/links", + json={"parent_id": a["id"], "child_id": b["id"]}, + ) + assert r.status_code == 200 + + r = client.get(f"/api/plugins/kanban/tasks/{b['id']}") + assert a["id"] in r.json()["links"]["parents"] + + r = client.delete( + "/api/plugins/kanban/links", + params={"parent_id": a["id"], "child_id": b["id"]}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is True + + +def test_add_link_cycle_rejected(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + client.post( + "/api/plugins/kanban/links", + json={"parent_id": a["id"], "child_id": b["id"]}, + ) + r = client.post( + "/api/plugins/kanban/links", + json={"parent_id": b["id"], "child_id": a["id"]}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# Dispatch nudge +# --------------------------------------------------------------------------- + + +def test_dispatch_dry_run(client): + client.post( + "/api/plugins/kanban/tasks", + json={"title": "work", "assignee": "researcher"}, + ) + r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=4") + assert r.status_code == 200 + body = r.json() + # DispatchResult is serialized as a dataclass dict. + assert isinstance(body, dict) diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 1fb0ffeeef0..57f27694b36 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -107,35 +107,42 @@ For best results, pair it with a profile whose toolsets are restricted to board ## Dashboard (GUI) -The `/kanban` CLI and slash command are enough to run the board headlessly, but a visual board is often the right interface for humans-in-the-loop: triage, cross-profile supervision, reading comment threads, and dropping cards between columns. Hermes ships this as a **dashboard plugin** — not a core feature, not a separate service — following the model laid out in [Extending the Dashboard](./extending-the-dashboard). +The `/kanban` CLI and slash command are enough to run the board headlessly, but a visual board is often the right interface for humans-in-the-loop: triage, cross-profile supervision, reading comment threads, and dragging cards between columns. Hermes ships this as a **bundled dashboard plugin** at `plugins/kanban/` — not a core feature, not a separate service — following the model laid out in [Extending the Dashboard](./extending-the-dashboard). -### What the plugin adds +Open it with: -- A **Kanban** tab in `hermes dashboard` showing one column per status (`triage`, `todo`, `ready`, `claimed`, `running`, `review`, `blocked`, `done`). -- Cards show task id, title, priority, assigned profile, dependency chips, progress (`N/M` subtasks done), and files-touched count. -- Live updates via a WebSocket that tails the `task_events` append-only table. No polling, no full-refetch flicker. -- Click a card → side panel with full description, comment thread, linked tasks, event timeline, and the exact context a worker would see (`hermes kanban context `). -- Drag a card to a new column → sends a status change through the same code path `/kanban` uses. -- Inline **New task** row at the top of every column (title + optional assignee dropdown + priority). -- Per-profile lanes inside the `running` column so you can see at a glance which specialist is busy on what. +```bash +hermes kanban init # one-time: create kanban.db if not already present +hermes dashboard # "Kanban" tab appears in the nav, after "Skills" +``` -Visually the target is the familiar Linear / Fusion layout: dark theme, column headers with item counts, coloured status dots, pill chips for dependencies and badges. +### 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. + +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. ### Architecture -The GUI is strictly a **read-through-the-DB + write-through-the-CLI** layer. It has no domain logic of its own: +The GUI is strictly a **read-through-the-DB + write-through-kanban_db** layer with no domain logic of its own: ``` -┌────────────────────────┐ WebSocket (task_events tail) +┌────────────────────────┐ WebSocket (tails task_events) │ React SPA (plugin) │ ◀──────────────────────────────────┐ -│ @dnd-kit drag/drop │ │ +│ HTML5 drag-and-drop │ │ └──────────┬─────────────┘ │ - │ REST (thin) │ + │ REST over fetchJSON │ ▼ │ -┌────────────────────────┐ writes go through the same │ -│ FastAPI router │ run_slash(" …") that │ -│ plugins/kanban/ │ CLI + gateway already use │ -│ dashboard/routes.py │ │ +┌────────────────────────┐ writes call kanban_db.* │ +│ FastAPI router │ directly — same code path │ +│ plugins/kanban/ │ the CLI /kanban verbs use │ +│ dashboard/plugin_api.py │ └──────────┬─────────────┘ │ │ │ ▼ │ @@ -145,44 +152,37 @@ The GUI is strictly a **read-through-the-DB + write-through-the-CLI** layer. It └────────────────────────┘ ``` -Because writes go through `run_slash`, the GUI cannot drift from the CLI or the gateway. A drag-drop is just a `/kanban assign` or a status change under the hood; every action lands in `task_events` the same way a typed `/kanban` command would. - ### REST surface -All routes are mounted under `/api/plugins/kanban/` and protected by the dashboard's ephemeral `_SESSION_TOKEN`: +All routes are mounted under `/api/plugins/kanban/` and protected by the dashboard's ephemeral session token: | Method | Path | Purpose | |---|---|---| -| `GET` | `/board?tenant=` | Full board, grouped by status column | -| `GET` | `/tasks/:id` | Task + links + comments + events | -| `POST` | `/tasks` | Create (delegates to `run_slash("create …")`) | -| `PATCH` | `/tasks/:id` | Status / assignee / title / priority | +| `GET` | `/board?tenant=&include_archived=…` | Full board grouped by status column, plus tenants + assignees for filter dropdowns | +| `GET` | `/tasks/:id` | Task + comments + events + links | +| `POST` | `/tasks` | Create (wraps `kanban_db.create_task`) | +| `PATCH` | `/tasks/:id` | Status / assignee / priority / title / body / result | | `POST` | `/tasks/:id/comments` | Append a comment | -| `POST` | `/tasks/:id/links` | Add a dependency | -| `DELETE` | `/tasks/:id/links/:other` | Remove a dependency | -| `POST` | `/tasks/:id/dispatch` | Nudge the dispatcher (no 60 s wait) | +| `POST` | `/links` | Add a dependency (`parent_id` → `child_id`) | +| `DELETE` | `/links?parent_id=…&child_id=…` | Remove a dependency | +| `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 ~5-line wrapper around an existing `kanban_db` function or a `run_slash` invocation — the plugin 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. ### Live updates -`task_events` is an append-only SQLite table with a monotonic `id`. The WebSocket endpoint keeps the last-seen event id per client and pushes new rows as they land. The frontend patches its local state in place, so a card moves between columns the instant any profile — CLI, gateway, or another GUI tab — acts on it. WAL mode means the read loop never blocks the dispatcher's `BEGIN IMMEDIATE` claim. +`task_events` is an append-only SQLite table with a monotonic `id`. The WebSocket endpoint holds each client's last-seen event id and pushes new rows as they land. When a burst of events arrives, the frontend reloads the (very cheap) board endpoint — simpler and more correct than trying to patch local state from every event kind. WAL mode means the read loop never blocks the dispatcher's `BEGIN IMMEDIATE` claim transactions. -### Installing it +### Extending it -The plugin is shipped in the repo at `plugins/kanban/` and enabled by default when `hermes dashboard` finds a `kanban.db`: +The plugin uses the standard Hermes dashboard plugin contract — see [Extending the Dashboard](./extending-the-dashboard) for the full manifest reference, shell slots, page-scoped slots, and the Plugin SDK. Extra columns, custom card chrome, tenant-filtered layouts, or full `tab.override` replacements are all expressible without forking this plugin. -```bash -hermes dashboard -# browser opens → "Kanban" tab appears in the nav -``` - -To disable: remove or rename `plugins/kanban/` (or set `dashboard.plugins.kanban.enabled: false` in `config.yaml`). To extend it — extra columns, custom card chrome, tenant filters — follow the plugin shape documented in [Extending the Dashboard](./extending-the-dashboard) (`tab`, shell slots, page-scoped slots, and custom CSS all apply). +To disable without removing: add `dashboard.plugins.kanban.enabled: false` to `config.yaml` (or delete `plugins/kanban/dashboard/manifest.json`). ### Scope boundary -The GUI is deliberately thin. Everything the plugin does is reachable from the CLI; the plugin just makes it comfortable for humans. Auto-assignment, budgets, governance gates, and org-chart views remain user-space — a router profile, a plugin, or a reuse of `tools/approval.py` — exactly as listed in the out-of-scope section of the design spec. +The GUI is deliberately thin. Everything the plugin does is reachable from the CLI; the plugin just makes it comfortable for humans. Auto-assignment, budgets, governance gates, and org-chart views remain user-space — a router profile, another plugin, or a reuse of `tools/approval.py` — exactly as listed in the out-of-scope section of the design spec. ## CLI command reference