Files
hermes-agent/plugins/kanban/dashboard/plugin_api.py
teknium1 f7ea39481a fix(kanban): dashboard estimate calls declare a relay-affinity key too
The dashboard's estimate endpoints make the same headless auxiliary call
as specify/decompose but never bound an affinity scope, so they still sent
no x-opencode-session and the OpenCode Go relay answered 400
MissingSessionID (#112043). Declare kanban:<task_id> for an existing task
and a stable kanban:estimate key for the create dialog (no task yet),
unless a scope is already bound.

Test: _run_estimate captured header None before; now kanban:t_1 /
kanban:estimate and nothing leaks past the call.
2026-09-15 18:33:43 -07:00

1737 lines
80 KiB
Python

"""Kanban dashboard plugin — backend API routes, mounted at /api/plugins/kanban/.
Every handler is a thin wrapper around ``hermes_cli.kanban_db`` (the same code paths the CLI
and gateway ``/kanban`` command use, so the surfaces cannot drift). The ``/events`` WebSocket
tails the append-only ``task_events`` table on a short poll (WAL reads run alongside the
dispatcher's write txns); it carries its credential in the query string (browsers can't set
``Authorization`` on an upgrade) and is gated by the dashboard's canonical WS auth check.
"""
from __future__ import annotations
import asyncio
import importlib
import json
import logging
import re
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing, contextmanager
from dataclasses import asdict
from functools import partial
from pathlib import Path
from typing import Any, Callable, Iterator, Optional
from fastapi import (
APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status)
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from hermes_cli import kanban_db
from hermes_cli.web_read_coalescing import coalesced_read
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_notify as kbn
from hermes_cli import kanban_db_dispatch as kbd
from hermes_cli import kanban_db_workspace as kbw
from hermes_cli import kanban_diagnostics as kd
from hermes_cli.kanban_db import KANBAN_ATTACHMENT_MAX_BYTES, _collision_free_path, _safe_attachment_name
log = logging.getLogger(__name__)
router = APIRouter()
_BOARD_Q = Query(None, description="Kanban board slug (omit for current)")
# --- Connection / board helpers ---------------------------------------------
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
"""Authorize a WS upgrade via the dashboard's canonical gate (``web_server_chat._ws_auth_ok``:
``?token=`` / ``?ticket=`` / ``?internal=``) so this endpoint can never drift from core
auth; accepts when the dashboard isn't importable (bare-FastAPI test harness)."""
try:
from hermes_cli import web_server_chat as _ws
except Exception:
return True
return bool(_ws._ws_auth_ok(ws))
def _normalize_slug_or_400(slug: str) -> Optional[str]:
with _value_error_400():
return kanban_db._normalize_board_slug(slug)
def _resolve_board(board: Optional[str]) -> Optional[str]:
"""Validate/normalise a board slug query param (400 malformed, 404 unknown);
``None`` when omitted so ``kb.connect()`` falls through to the active board."""
if board is None or board == "":
return None
normed = _normalize_slug_or_400(board)
if normed and normed != kanban_db.DEFAULT_BOARD and not kanban_db.board_exists(normed):
raise HTTPException(status_code=404, detail=f"board {normed!r} does not exist")
return normed
def _existing_board_slug(slug: str) -> str:
"""Normalise a path slug and require the board to exist (400 / 404)."""
normed = _normalize_slug_or_400(slug)
if not normed or not kanban_db.board_exists(normed):
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
return normed
def _conn(board: Optional[str] = None):
"""Connect to the already-normalised ``board`` (``None`` = active). ``init_db`` is
idempotent; running it here lets a fresh install self-heal if POST /tasks arrives first."""
try:
kanban_db.init_db(board=board)
except Exception as exc:
log.warning("kanban init_db failed: %s", exc)
return kbc.connect(board=board)
@contextmanager
def _board_conn(board: Optional[str]) -> Iterator[tuple[Optional[str], sqlite3.Connection]]:
"""Resolve the ``board`` query param, open a connection, close it on exit."""
board = _resolve_board(board)
with closing(_conn(board=board)) as conn:
yield board, conn
def _with_board_pinned(board: Optional[str], fn: Callable[[], Any]) -> Any:
"""Run ``fn`` with the board pinned context-locally, not via the process-global
``HERMES_KANBAN_BOARD`` env var (concurrent requests for different boards would cross-write)."""
with kanban_db.scoped_current_board(_resolve_board(board) or kanban_db.DEFAULT_BOARD):
return fn()
def _require(getter: Callable, conn: sqlite3.Connection, ident, label: str):
obj = getter(conn, ident)
if obj is None:
raise HTTPException(status_code=404, detail=f"{label} {ident} not found")
return obj
def _run_aux(board: Optional[str], module: str, fn: str, task_id: str, author: Optional[str]) -> Any:
"""Run a slow auxiliary-LLM task helper (``hermes_cli.<module>.<fn>``) with the board pinned;
the module is imported lazily so a missing aux client can't break plugin load."""
def _run():
return getattr(importlib.import_module(f"hermes_cli.{module}"), fn)(task_id, author=(author or None))
return _with_board_pinned(board, _run)
def _require_task(conn: sqlite3.Connection, task_id: str) -> kanban_db.Task:
return _require(kanban_db.get_task, conn, task_id, "task")
def _require_run(conn: sqlite3.Connection, run_id: int) -> kanban_db.Run:
return _require(kanban_db.get_run, conn, run_id, "run")
def _require_ok(ok: bool) -> None:
"""404 when a kanban_db mutator reports the task vanished mid-request."""
if not ok:
raise HTTPException(status_code=404, detail="task not found")
def _conflict(detail: str) -> HTTPException:
return HTTPException(status_code=409, detail=detail)
@contextmanager
def _map_errors(status: int, *types: type[BaseException]) -> Iterator[None]:
"""Map the given exception types to ``HTTPException(status, str(exc))``."""
try:
yield
except types as e:
raise HTTPException(status_code=status, detail=str(e))
_value_error_400 = partial(_map_errors, 400, ValueError) # domain-layer validation refusals
@contextmanager
def _errors_to_500(prefix: str) -> Iterator[None]:
"""Map any unexpected exception to ``500 "<prefix>: <exc>"``; HTTPExceptions pass through."""
try:
yield
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"{prefix}: {exc}")
# --- Serialization helpers --------------------------------------------------
# Dashboard columns, left-to-right ("archived" is a filter toggle, not a column). Keep in
# sync with kanban_db.VALID_STATUSES — a status missing here gets mis-bucketed into ``todo``.
BOARD_COLUMNS: list[str] = ["triage", "todo", "scheduled", "ready", "running", "blocked", "review", "done"]
_CARD_SUMMARY_PREVIEW_CHARS = 200
def _task_dict(task: kanban_db.Task, *, latest_summary: Optional[str] = None) -> dict[str, Any]:
d = asdict(task)
# Derived age metrics so the UI can colour stale cards without client deltas.
try:
d["age"] = kanban_db.task_age(task)
except Exception:
d["age"] = {"created_age_seconds": None, "started_age_seconds": None, "time_to_complete_seconds": None}
# Latest non-null run summary (workers hand off via ``task_runs.summary``, not ``tasks.result``).
d["latest_summary"] = latest_summary
return d
def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]:
"""``stored_path`` is the absolute on-disk path workers read; UI downloads by ``id``."""
return {
"id": a.id, "task_id": a.task_id, "filename": a.filename, "content_type": a.content_type,
"size": a.size, "uploaded_by": a.uploaded_by, "stored_path": a.stored_path, "created_at": a.created_at}
def _placeholders(ids: list) -> str:
return ",".join(["?"] * len(ids))
def _compute_task_diagnostics(conn: sqlite3.Connection, task_ids: Optional[list[str]] = None) -> dict[str, list[dict]]:
"""``{task_id: [diagnostic_dict, ...]}`` (tasks with none omitted) via three aggregate
queries (tasks, events, runs) — slurps the board; paginate if profiling shows a hotspot."""
from hermes_cli.config import load_config
if task_ids is not None and not task_ids:
return {}
diag_config = kd.config_from_runtime_config(load_config())
if task_ids is not None:
rows = conn.execute(f"SELECT * FROM tasks WHERE id IN ({_placeholders(task_ids)})", tuple(task_ids)).fetchall()
else:
rows = conn.execute("SELECT * FROM tasks WHERE status != 'archived'").fetchall()
if not rows:
return {}
row_ids = [r["id"] for r in rows]
def _rows_by_task(table: str) -> dict[str, list]:
by_task: dict[str, list] = {tid: [] for tid in row_ids}
for row in conn.execute(
f"SELECT * FROM {table} WHERE task_id IN ({_placeholders(row_ids)}) ORDER BY id", tuple(row_ids)):
by_task.setdefault(row["task_id"], []).append(row)
return by_task
events_by_task = _rows_by_task("task_events")
runs_by_task = _rows_by_task("task_runs")
graph_by_task = kanban_db.task_graph_contexts(conn, row_ids)
out: dict[str, list[dict]] = {}
for r in rows:
tid = r["id"]
diags = kd.compute_task_diagnostics(
r, events_by_task[tid], runs_by_task[tid], config=diag_config, graph=graph_by_task.get(tid))
if diags:
out[tid] = [d.to_dict() for d in diags]
return out
def _warnings_summary_from_diagnostics(diagnostics: list[dict]) -> Optional[dict]:
"""Compact card badge summary ``{count, kinds, latest_at, highest_severity}``; None when empty."""
if not diagnostics:
return None
kinds: dict[str, int] = {}
count = latest = 0
highest_idx, highest_sev = -1, None
for d in diagnostics:
n = d.get("count", 1)
kinds[d["kind"]] = kinds.get(d["kind"], 0) + n
count += n
latest = max(latest, d.get("last_seen_at") or 0)
sev = d.get("severity")
if sev in kd.SEVERITY_ORDER and kd.SEVERITY_ORDER.index(sev) > highest_idx:
highest_idx, highest_sev = kd.SEVERITY_ORDER.index(sev), sev
return {"count": count, "kinds": kinds, "latest_at": latest, "highest_severity": highest_sev}
def _attach_diagnostics(task_d: dict, diags: Optional[list[dict]]) -> None:
"""Full list in the payload (drawer renders without a second round-trip); card badge gets the summary."""
if diags:
task_d["diagnostics"] = diags
task_d["warnings"] = _warnings_summary_from_diagnostics(diags)
def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
"""Return {'parents': [...], 'children': [...]} for a task."""
def _ids(col: str, other: str) -> list[str]:
return [r[col] for r in conn.execute(f"SELECT {col} FROM task_links WHERE {other} = ? ORDER BY {col}", (task_id,))]
return {"parents": _ids("parent_id", "child_id"), "children": _ids("child_id", "parent_id")}
# --- GET /board -------------------------------------------------------------
def get_board(
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
include_archived: bool = Query(False),
board: Optional[str] = _BOARD_Q,
workflow_template_id: Optional[str] = Query(None, description="Restrict to tasks using this workflow template id"),
current_step_key: Optional[str] = Query(None, description="Restrict to tasks at this workflow step key")):
"""Full board grouped by status column; omitting ``board`` uses the active board
(``HERMES_KANBAN_BOARD`` env → on-disk ``current`` pointer → ``default``)."""
with _board_conn(board) as (board, conn):
tasks = kanban_db.list_tasks(
conn, tenant=tenant, include_archived=include_archived,
workflow_template_id=workflow_template_id, current_step_key=current_step_key)
# Link / comment / progress rollups are each one aggregate query rather than N per-task lookups.
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_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")}
progress: dict[str, dict[str, int]] = {} # per parent: children done / total, rendered as "N/M"
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
p["done"] += row["cstatus"] == "done"
diagnostics_per_task = _compute_task_diagnostics(conn, task_ids=None)
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"] = []
# One window-function query for latest summaries (avoids N+1); cards get a
# truncated preview, the full text comes from /tasks/:id.
summary_map = kanban_db.latest_summaries(conn, [t.id for t in tasks])
for t in tasks:
full = summary_map.get(t.id)
d = _task_dict(t, latest_summary=(full[:_CARD_SUMMARY_PREVIEW_CHARS] if full else None))
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
_attach_diagnostics(d, diagnostics_per_task.get(t.id))
columns[t.status if t.status in columns else "todo"].append(d)
# Per-column ordering (priority DESC, created_at ASC) comes from list_tasks.
tenants = [r["tenant"] for r in conn.execute("SELECT DISTINCT tenant FROM tasks WHERE tenant IS NOT NULL ORDER BY tenant")]
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], "tenants": tenants,
"assignees": assignees, "latest_event_id": int(latest_event_id), "now": int(time.time())}
_read_board = coalesced_read(get_board)
@router.get("/board")
async def get_board_endpoint(
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
include_archived: bool = Query(False),
board: Optional[str] = _BOARD_Q,
workflow_template_id: Optional[str] = Query(None, description="Restrict to tasks using this workflow template id"),
current_step_key: Optional[str] = Query(None, description="Restrict to tasks at this workflow step key"),
):
# Resolve selection before keying so a board switch cannot join an older read.
return await _read_board(
tenant=tenant,
include_archived=include_archived,
board=board or kanban_db.get_current_board(),
workflow_template_id=workflow_template_id,
current_step_key=current_step_key,
)
# --- GET /tasks/:id ---------------------------------------------------------
@router.get("/tasks/{task_id}")
def get_task(
task_id: str,
board: Optional[str] = Query(None),
run_state_type: Optional[str] = Query(None, description="With run_state_name: filter runs by column 'status' or 'outcome'"),
run_state_name: Optional[str] = Query(None, description="With run_state_type: exact value for that run column")):
with _board_conn(board) as (board, conn):
if (run_state_type is None) ^ (run_state_name is None):
raise HTTPException(status_code=400, detail="run_state_type and run_state_name must be passed together or omitted")
if run_state_type not in (None, "status", "outcome"):
raise HTTPException(status_code=400, detail="run_state_type must be 'status' or 'outcome'")
task = _require_task(conn, task_id)
# Drawer returns the FULL summary (cards on /board carry a 200-char preview).
task_d = _task_dict(task, latest_summary=kanban_db.latest_summary(conn, task_id))
links = _links_for(conn, task_id)
child_summaries = kanban_db.latest_summaries(conn, links["children"])
children = filter(None, (kanban_db.get_task(conn, cid) for cid in links["children"]))
_attach_diagnostics(task_d, _compute_task_diagnostics(conn, task_ids=[task_id]).get(task_id) or [])
return {
"task": task_d,
"comments": [asdict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [asdict(e) for e in kanban_db.list_events(conn, task_id)],
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
"links": links,
"child_results": [
{"id": c.id, "title": c.title, "status": c.status, "latest_summary": child_summaries.get(c.id), "result": c.result}
for c in children],
"runs": [asdict(r) for r in kanban_db.list_runs(conn, task_id, state_type=run_state_type, state_name=run_state_name)]}
# --- POST /tasks ------------------------------------------------------------
class CreateTaskBody(BaseModel):
title: str
body: Optional[str] = None
assignee: Optional[str] = None
tenant: Optional[str] = None
priority: int = 0
workspace_kind: Optional[str] = None # None = scratch, or the board project's worktree when scoped
workspace_path: Optional[str] = None
parents: list[str] = Field(default_factory=list)
triage: bool = False
idempotency_key: Optional[str] = None
max_runtime_seconds: Optional[int] = None
skills: Optional[list[str]] = None
goal_mode: bool = False
goal_max_turns: Optional[int] = None
model_override: Optional[str] = None
provider_override: Optional[str] = None
reasoning_effort: Optional[str] = None # none|minimal|…|ultra; None inherits the profile's level
project_id: Optional[str] = None # None inherits the board's scoped project (if any)
@router.post("/tasks")
def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn), _value_error_400():
# CreateTaskBody field names match create_task's keyword parameters.
task_id = kanban_db.create_task(conn, created_by="dashboard", board=board, **payload.model_dump())
task = kanban_db.get_task(conn, task_id)
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
# Dispatcher-presence warning so the UI can banner a ready+assigned task that would
# otherwise sit idle (no gateway / dispatch_in_gateway=false); triage/todo are expected
# to wait, unassigned tasks can't dispatch anyway. Probe the request's active home: the
# dashboard backend may run under a different HERMES_HOME than the board's profile.
if task and task.status == "ready" and task.assignee:
try:
from hermes_cli.kanban import _check_dispatcher_presence
from hermes_constants import get_hermes_home
running, message = _check_dispatcher_presence(hermes_home=get_hermes_home())
if not running and message:
body["warning"] = message
except Exception:
pass # probe failure must never block the create itself
return body
# --- Attachments — upload / list / download / delete ------------------------
# Size cap, filename sanitiser, and collision resolver live in ``kanban_db`` so the
# dashboard, agent toolset, and CLI share one implementation.
@router.get("/tasks/{task_id}/attachments")
def list_task_attachments(task_id: str, board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn):
_require_task(conn, task_id)
return {"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)]}
@router.post("/tasks/{task_id}/attachments")
async def upload_task_attachment(
task_id: str,
file: UploadFile = File(...),
board: Optional[str] = Query(None),
uploaded_by: Optional[str] = Form(None)):
"""Store an upload under ``attachments_root(board)/<task_id>/`` (sanitised,
collision-resolved name; ``_safe_attachment_name`` ValueError → 400) and record it."""
with _board_conn(board) as (board, conn), _value_error_400():
_require_task(conn, task_id)
safe_name = _safe_attachment_name(file.filename or "")
dest_dir = kanban_db.task_attachments_dir(task_id, board=board)
dest_dir.mkdir(parents=True, exist_ok=True)
dest_path = _collision_free_path(dest_dir, safe_name) # foo.pdf → foo (1).pdf …
total = 0 # stream in chunks with a hard size cap so one upload can't fill the disk
try:
with open(dest_path, "wb") as out:
while chunk := await file.read(1024 * 1024):
total += len(chunk)
if total > KANBAN_ATTACHMENT_MAX_BYTES:
out.close()
dest_path.unlink(missing_ok=True)
raise HTTPException(
status_code=413, detail=f"attachment exceeds {KANBAN_ATTACHMENT_MAX_BYTES // (1024 * 1024)} MB limit")
out.write(chunk)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}")
att_id = kanban_db.add_attachment(
conn, task_id, filename=dest_path.name, stored_path=str(dest_path.resolve()),
content_type=file.content_type, size=total, uploaded_by=(uploaded_by or "dashboard"))
att = kanban_db.get_attachment(conn, att_id)
return {"attachment": _attachment_dict(att) if att else None}
@router.get("/attachments/{attachment_id}")
def download_attachment(attachment_id: int, board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn):
att = kanban_db.get_attachment(conn, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="attachment not found")
# Defense in depth against a tampered DB row: the blob must still live under the board's attachments root.
root = kanban_db.attachments_root(board=board).resolve()
try:
stored = Path(att.stored_path).resolve()
stored.relative_to(root)
except (ValueError, OSError):
raise HTTPException(status_code=404, detail="attachment file unavailable")
if not stored.is_file():
raise HTTPException(status_code=404, detail="attachment file missing on disk")
return FileResponse(path=str(stored), filename=att.filename, media_type=att.content_type or "application/octet-stream")
@router.delete("/attachments/{attachment_id}")
def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn):
if kanban_db.delete_attachment(conn, attachment_id) is None:
raise HTTPException(status_code=404, detail="attachment not found")
return {"ok": True, "id": attachment_id}
# --- PATCH /tasks/:id and POST /tasks/bulk ---------------------------------
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
# Handoff fields forwarded to complete_task on -> 'done' (parity with ``hermes kanban complete``).
summary: Optional[str] = None
metadata: Optional[dict] = None
# In a PATCH ``None`` means "field not sent", so ``clear_*=True`` is the explicit clear signal.
# ``reasoning_effort="none"`` is a VALUE (thinking off); it is cleared separately so
# dropping a model override doesn't silently reset the depth.
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
reasoning_effort: Optional[str] = None
clear_reasoning_effort: bool = False
class BulkTaskBody(BaseModel):
ids: list[str]
status: Optional[str] = None
assignee: Optional[str] = None # "" or None = unassign
priority: Optional[int] = None
archive: bool = False
result: Optional[str] = None
summary: Optional[str] = None
metadata: Optional[dict] = None
reclaim_first: bool = False
# Same semantics as UpdateTaskBody.
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
reasoning_effort: Optional[str] = None
clear_reasoning_effort: bool = False
class _StatusRejected(Exception):
"""A status the dashboard may not set via this path; the message is user-facing."""
_RUNNING_DIRECT_MSG = "Cannot set status to 'running' directly; use the dispatcher/claim path"
def _drag_to(conn, task_id: str, s: str) -> bool:
"""Drag-drop into ready/todo/triage: blocked/scheduled -> ready re-opens via ``unblock_task``;
leaving ``review`` goes through ``reopen_review_task`` (stale-run recovery, parent re-gate,
``review_reopened`` event) instead of a raw write; ``triage`` needs no current-state query."""
current = kanban_db.get_task(conn, task_id) if s != "triage" else None
if s == "ready" and current and current.status in ("blocked", "scheduled"):
return kanban_db.unblock_task(conn, task_id)
if current is not None and current.status == "review":
return kanban_db.reopen_review_task(conn, task_id)
return _set_status_direct(conn, task_id, s)
# Status verb dispatch shared by PATCH /tasks/{id} and POST /tasks/bulk: (conn, task_id,
# payload) -> ok. ``review`` uses request_review (never a block, so it can't trip unblock-loop
# detection) with ``force=True``: a dashboard action is a human override of a live worker claim.
_STATUS_HANDLERS: dict[str, Any] = {
"done": lambda conn, tid, p: kanban_db.complete_task(conn, tid, result=p.result, summary=p.summary, metadata=p.metadata),
"blocked": lambda conn, tid, p: kanban_db.block_task(conn, tid, reason=getattr(p, "block_reason", None)),
"scheduled": lambda conn, tid, p: kanban_db.schedule_task(conn, tid, reason=getattr(p, "block_reason", None)),
"review": lambda conn, tid, p: kanban_db.request_review(
conn, tid, summary=p.summary, metadata=p.metadata, reviewer=(p.assignee or None), force=True),
"ready": lambda conn, tid, p: _drag_to(conn, tid, "ready"),
"todo": lambda conn, tid, p: _drag_to(conn, tid, "todo"),
"triage": lambda conn, tid, p: _drag_to(conn, tid, "triage")}
def _apply_status(conn, task_id: str, s: str, p, unknown_detail: str) -> bool:
"""Dispatch a status verb; raises ``_StatusRejected`` (user-facing message)
for ``running`` or an unknown status (``unknown_detail``)."""
if s == "running":
raise _StatusRejected(_RUNNING_DIRECT_MSG)
handler = _STATUS_HANDLERS.get(s)
if handler is None:
raise _StatusRejected(unknown_detail)
return handler(conn, task_id, p)
def _set_priority(conn, task_id: str, priority: int, board: Optional[str]) -> None:
with kanban_db.write_txn(conn):
conn.execute("UPDATE tasks SET priority = ? WHERE id = ?", (int(priority), task_id))
conn.execute(
"INSERT INTO task_events (task_id, kind, payload, created_at) VALUES (?, 'reprioritized', ?, ?)",
(task_id, json.dumps({"priority": int(priority)}), int(time.time())))
# Mutation-boundary observer (post-commit): this direct-SQL write bypasses every kanban_db mutator.
kanban_db.notify_task_updated(conn, task_id, ("priority",), board=board)
def _apply_model_override(conn, task_id: str, p) -> bool:
"""Raises ValueError/RuntimeError from kanban_db for the caller to map."""
new_model = None if p.clear_model_override else (p.model_override or "").strip() or None
return kanban_db.set_model_override(conn, task_id, new_model, provider=p.provider_override)
def _apply_reasoning_effort(conn, task_id: str, p) -> bool:
return kanban_db.set_reasoning_effort(conn, task_id, None if p.clear_reasoning_effort else p.reasoning_effort)
# Override knobs shared by PATCH and bulk: (payload wants it?, apply, bulk refusal message).
_OVERRIDE_OPS = (
(lambda p: p.clear_model_override or p.model_override is not None, _apply_model_override, "model override refused"),
(lambda p: p.clear_reasoning_effort or p.reasoning_effort is not None, _apply_reasoning_effort, "reasoning override refused"),
)
def _patch_status(conn, task_id: str, payload: UpdateTaskBody, review_assignee_deferred: bool) -> None:
"""PATCH status phase: 400 on a rejected verb, 409 when the transition is refused
(naming the blocking parent(s) for ``ready`` so the UI renders an actionable toast)."""
s = payload.status
if s == "archived":
ok = kanban_db.archive_task(conn, task_id)
else:
with _map_errors(400, _StatusRejected):
ok = _apply_status(conn, task_id, s, payload, f"unknown status: {s}")
if s == "review" and ok and review_assignee_deferred and not payload.assignee:
ok = kanban_db.assign_task(conn, task_id, None)
if ok:
return
blockers = _parents_blocking_ready(conn, task_id) if s == "ready" else []
if blockers:
names = ", ".join(f"{p['title']!r} ({p['id']}, status={p['status']})" for p in blockers)
raise _conflict(f"Cannot move to 'ready': blocked by parent(s) not done — {names}")
raise _conflict(f"status transition to {s!r} not valid from current state")
def _patch_title_body(conn, task_id: str, payload: UpdateTaskBody, board: Optional[str]) -> None:
"""PATCH title/body phase: one UPDATE + ``edited`` event, then the post-commit observer
(field names only — values never leave the DB via this payload)."""
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())))
kanban_db.notify_task_updated(
conn, task_id, [f for f in ("title", "body") if getattr(payload, f) is not None], board=board)
@router.patch("/tasks/{task_id}")
def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn):
_require_task(conn, task_id)
# For a combined assignee+review patch, request_review must capture the
# current implementer before the task is routed to the reviewer.
review_assignee_deferred = payload.status == "review" and payload.assignee is not None
if payload.assignee is not None and not review_assignee_deferred:
with _map_errors(409, RuntimeError):
_require_ok(kanban_db.assign_task(conn, task_id, payload.assignee or None))
if payload.status is not None:
_patch_status(conn, task_id, payload, review_assignee_deferred)
for wanted, apply, _refused in _OVERRIDE_OPS:
if wanted(payload):
with _map_errors(400, ValueError, RuntimeError):
ok = apply(conn, task_id, payload)
_require_ok(ok)
if payload.priority is not None:
_set_priority(conn, task_id, payload.priority, board)
if payload.title is not None or payload.body is not None:
_patch_title_body(conn, task_id, payload, board)
updated = kanban_db.get_task(conn, task_id)
return {"task": _task_dict(updated) if updated else None}
@router.delete("/tasks/{task_id}")
def delete_task(task_id: str, board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn):
if not kanban_db.delete_task(conn, task_id):
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
return {"deleted": True, "task_id": task_id}
def _parents_blocking_ready(conn: sqlite3.Connection, task_id: str) -> list:
"""Parent rows (id, title, status) not ``done`` that block promotion to ``ready``.
Used to enrich the 409 response from :func:`update_task` so the dashboard can show an actionable toast
(#26744) instead of a silent no-op. Returns ``[]`` when nothing blocks the transition (e.g. no parents,
or all parents already done).
"""
rows = conn.execute(
"SELECT t.id, t.title, t.status FROM tasks t "
"JOIN task_links l ON l.parent_id = t.id "
"WHERE l.child_id = ? AND t.status != 'done'",
(task_id,)).fetchall()
return [{"id": r["id"], "title": r["title"], "status": r["status"]} for r in rows]
def _set_status_direct(conn: sqlite3.Connection, task_id: str, new_status: str) -> bool:
"""Direct status write for drag-drop moves without a structured verb (todo<->ready,
running<->ready) + a ``status`` event. Leaving ``running`` closes the run as 'reclaimed'
so attempt history isn't orphaned; the worker is killed only AFTER the txn commits."""
terminations: list[tuple[Optional[int], Optional[str], Optional[int]]] = []
effective_status = new_status
with kanban_db.write_txn(conn):
prev = conn.execute(
"SELECT status, current_run_id, worker_pid, claim_lock, worker_started_at FROM tasks WHERE id = ?",
(task_id,)).fetchone()
if prev is None:
return False
if prev["status"] == "running" and new_status == "ready":
resume_status = kanban_db._retry_status_for_run(conn, task_id, prev["current_run_id"])
if resume_status == "review":
effective_status = "review" if kanban_db._parents_satisfied(conn, task_id) else "todo"
# Never promote to 'ready' unless all parents are done/archived — otherwise the
# dispatcher spawns a child whose upstream work hasn't completed.
if effective_status == "ready" and not kanban_db._parents_satisfied(conn, task_id):
return False
was_running = prev["status"] == "running"
reopening_satisfied_parent = prev["status"] in {"done", "archived"} and effective_status not in {"done", "archived"}
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, "
" worker_pid = CASE WHEN ? = 'running' THEN worker_pid ELSE NULL END "
"WHERE id = ?",
(effective_status,) * 4 + (task_id,))
if cur.rowcount != 1:
return False
run_id = None
if was_running and effective_status != "running" and prev["current_run_id"]:
run_id = kanban_db._end_run(
conn, task_id, outcome="reclaimed", status="reclaimed",
summary=f"status changed to {effective_status} (dashboard/direct)")
terminations.append((prev["worker_pid"], prev["claim_lock"], prev["worker_started_at"]))
conn.execute(
"INSERT INTO task_events (task_id, run_id, kind, payload, created_at) VALUES (?, ?, 'status', ?, ?)",
(task_id, run_id, json.dumps({"status": effective_status, "requested_status": new_status}), int(time.time())))
if reopening_satisfied_parent:
# Domain-layer invalidation composes via a savepoint inside our txn and hands
# back worker terminations to perform post-commit.
result = kanban_db.invalidate_descendants_for_parent_reopen(conn, task_id, author="dashboard")
terminations.extend(result["terminations"])
for pid, claim_lock, started_at in terminations:
kanban_db._terminate_reclaimed_worker(pid, claim_lock, started_at=started_at)
# Re-opening something may have made children stale.
if effective_status in {"done", "ready", "review"}:
kanban_db.recompute_ready(conn)
return True
# --- Comments / links -------------------------------------------------------
class CommentBody(BaseModel):
body: str
author: Optional[str] = "dashboard"
@router.post("/tasks/{task_id}/comments")
def add_comment(task_id: str, payload: CommentBody, board: Optional[str] = Query(None)):
if not payload.body.strip():
raise HTTPException(status_code=400, detail="body is required")
with _board_conn(board) as (board, conn):
_require_task(conn, task_id)
kanban_db.add_comment(conn, task_id, author=payload.author or "dashboard", body=payload.body)
return {"ok": True}
class LinkBody(BaseModel):
parent_id: str
child_id: str
@router.post("/links")
def add_link(payload: LinkBody, board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn), _value_error_400():
gated = kanban_db.link_tasks(conn, payload.parent_id, payload.child_id)
return {"ok": True, "gated": gated}
@router.delete("/links")
def delete_link(parent_id: str = Query(...), child_id: str = Query(...), board: Optional[str] = Query(None)):
with _board_conn(board) as (board, conn):
return {"ok": bool(kanban_db.unlink_tasks(conn, parent_id, child_id))}
def _bulk_apply_one(conn, tid: str, payload: BulkTaskBody, board: Optional[str], entry: dict) -> None:
"""Apply the bulk patch to one task, recording refusals in ``entry`` without aborting the
remaining ops — except a rejected status verb (``_StatusRejected`` propagates)."""
if payload.archive and not kanban_db.archive_task(conn, tid):
entry.update(ok=False, error="archive refused")
if payload.status is not None and not payload.archive:
s = payload.status
if not _apply_status(conn, tid, s, payload, f"unknown status {s!r}"):
entry.update(ok=False, error=f"transition to {s!r} refused")
if payload.assignee is not None:
try:
ok = (kanban_db.reassign_task(conn, tid, payload.assignee or None, reclaim_first=True) if payload.reclaim_first
else kanban_db.assign_task(conn, tid, payload.assignee or None))
if not ok:
entry.update(ok=False, error="assign refused")
except RuntimeError as e:
entry.update(ok=False, error=str(e))
if payload.priority is not None:
_set_priority(conn, tid, payload.priority, board)
for wanted, apply, refused in _OVERRIDE_OPS:
if wanted(payload):
try:
if not apply(conn, tid, payload):
entry.update(ok=False, error=refused)
except (ValueError, RuntimeError) as e:
entry.update(ok=False, error=str(e))
@router.post("/tasks/bulk")
def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
"""Apply the same patch to every id. Independent iteration — per-task
failures don't abort siblings; returns per-id outcome for partials."""
ids = [i for i in (payload.ids or []) if i]
if not ids:
raise HTTPException(status_code=400, detail="ids is required")
results: list[dict] = []
with _board_conn(board) as (board, conn):
for tid in ids:
entry: dict[str, Any] = {"id": tid, "ok": True}
try:
if kanban_db.get_task(conn, tid) is None:
entry.update(ok=False, error="not found")
else:
_bulk_apply_one(conn, tid, payload, board, entry)
except Exception as e: # one bad id shouldn't kill the batch (incl. _StatusRejected)
entry.update(ok=False, error=str(e))
results.append(entry)
return {"results": results}
# --- Diagnostics — fleet-wide distress signals (see kanban_diagnostics) ------
@router.get("/diagnostics")
def list_diagnostics(
board: Optional[str] = _BOARD_Q,
severity: Optional[str] = Query(None, description="Filter by severity: warning|error|critical")):
"""Tasks with an active diagnostic, highest severity first then most recent; also
consumed by ``hermes kanban diagnostics`` when the dashboard runs."""
with _board_conn(board) as (board, conn):
diags_by_task = _compute_task_diagnostics(conn, task_ids=None)
if severity and diags_by_task:
diags_by_task = {
tid: keep
for tid, dl in diags_by_task.items()
if (keep := [d for d in dl if kd.severity_at_or_above(d.get("severity"), severity)])}
if not diags_by_task:
return {"diagnostics": [], "count": 0}
ids = list(diags_by_task.keys())
rows = {r["id"]: r for r in conn.execute(
f"SELECT id, title, status, assignee FROM tasks WHERE id IN ({_placeholders(ids)})", tuple(ids)).fetchall()}
out = []
for tid, dl in diags_by_task.items():
r = rows.get(tid) or {"title": None, "status": None, "assignee": None}
out.append({
"task_id": tid, "task_title": r["title"], "task_status": r["status"], "task_assignee": r["assignee"],
"diagnostics": dl})
sev_idx = {s: i for i, s in enumerate(kd.SEVERITY_ORDER)}
out.sort(key=lambda row: (
-sev_idx.get(row["diagnostics"][0].get("severity"), -1), -(row["diagnostics"][0].get("last_seen_at") or 0)))
return {"diagnostics": out, "count": sum(len(d["diagnostics"]) for d in out)}
# --- Worker visibility — active-worker list, per-run inspect/terminate -------
try:
import psutil as _psutil
except ImportError:
_psutil = None # type: ignore[assignment]
@router.get("/workers/active")
def list_active_workers(board: Optional[str] = _BOARD_Q):
"""Every running worker: an open ``task_runs`` row with a ``worker_pid`` whose
task is ``running``. Returns ``{workers, count, checked_at}``."""
with _board_conn(board) as (board, conn):
rows = conn.execute(
"SELECT r.id AS run_id, r.task_id, t.title AS task_title, t.status AS task_status, "
"t.assignee AS task_assignee, r.profile, r.worker_pid, r.started_at, r.claim_lock, "
"r.claim_expires, r.last_heartbeat_at, r.max_runtime_seconds "
"FROM task_runs r JOIN tasks t ON t.id = r.task_id "
"WHERE r.ended_at IS NULL AND r.worker_pid IS NOT NULL AND t.status = 'running' "
"ORDER BY r.started_at ASC").fetchall()
workers = [dict(row) for row in rows]
return {"workers": workers, "count": len(workers), "checked_at": int(time.time())}
@router.get("/runs/{run_id}")
def get_run_endpoint(run_id: int, board: Optional[str] = _BOARD_Q):
"""``{run: {...}}`` with the same serialisation as ``GET /tasks/{id}``; 404 if unknown."""
with _board_conn(board) as (board, conn):
return {"run": asdict(_require_run(conn, run_id))}
@router.get("/runs/{run_id}/inspect")
def inspect_run_endpoint(run_id: int, board: Optional[str] = _BOARD_Q):
"""Live psutil stats for a run's worker; ``{alive: false, reason}`` when unavailable and
access-denied reported inline rather than as a 500."""
with _board_conn(board) as (board, conn):
r = _require_run(conn, run_id)
def _dead(reason: str, **extra) -> dict:
return {"run_id": run_id, "alive": False, **extra, "reason": reason}
if r.ended_at is not None:
return _dead("run already ended")
pid = r.worker_pid
if pid is None:
return _dead("no worker_pid recorded")
if _psutil is None:
return _dead("psutil not available", pid=pid)
try:
proc = _psutil.Process(pid)
info = proc.as_dict(attrs=["cpu_percent", "memory_info", "num_threads", "status", "create_time", "cmdline"])
try:
num_fds = proc.num_fds()
except AttributeError: # POSIX-only
num_fds = None
mem = info.get("memory_info")
return {
"run_id": run_id, "alive": True, "pid": pid,
"cpu_percent": info.get("cpu_percent"),
"memory_rss_bytes": mem.rss if mem else None,
"memory_vms_bytes": mem.vms if mem else None,
"num_threads": info.get("num_threads"), "num_fds": num_fds,
"status": info.get("status"), "create_time": info.get("create_time"), "cmdline": info.get("cmdline")}
except _psutil.NoSuchProcess:
return _dead("process not found", pid=pid)
except _psutil.AccessDenied:
return {"run_id": run_id, "alive": True, "pid": pid, "error": "access denied"}
class TerminateRunBody(BaseModel):
reason: Optional[str] = None
@router.post("/runs/{run_id}/terminate")
def terminate_run_endpoint(run_id: int, payload: TerminateRunBody, board: Optional[str] = _BOARD_Q):
"""Terminate an in-flight run via ``reclaim_task`` (same SIGTERM->SIGKILL flow, bookkeeping
and events as ``POST /tasks/{id}/reclaim``); 409 if already ended / not reclaimable.
Closes the gap left by PR #28432, which shipped the read-only sibling endpoints (``/workers/active``,
``/runs/{run_id}``, ``/runs/{run_id}/inspect``) but no termination control surface.
"""
with _board_conn(board) as (board, conn):
r = _require_run(conn, run_id)
if r.ended_at is not None:
raise _conflict(f"run {run_id} already ended")
if not kanban_db.reclaim_task(conn, r.task_id, reason=payload.reason):
raise _conflict(f"cannot terminate run {run_id}: task {r.task_id} is no longer in a reclaimable state")
return {"ok": True, "run_id": run_id, "task_id": r.task_id}
# --- Recovery actions — reclaim / specify / reassign / estimate -------------
class ReclaimBody(BaseModel):
reason: Optional[str] = None
@router.post("/tasks/{task_id}/reclaim")
def reclaim_task_endpoint(task_id: str, payload: ReclaimBody, board: Optional[str] = Query(None)):
"""Release an active worker claim without waiting for the claim TTL
(``hermes kanban reclaim <task_id> --reason ...``)."""
with _board_conn(board) as (board, conn):
if not kanban_db.reclaim_task(conn, task_id, reason=payload.reason):
raise _conflict(f"cannot reclaim {task_id}: not in a claimable state (not running, or unknown id)")
return {"ok": True, "task_id": task_id}
class SpecifyBody(BaseModel):
"""Only the author is configurable; model + prompt come from
``auxiliary.triage_specifier`` in config.yaml, same as the CLI."""
author: Optional[str] = None
@router.post("/tasks/{task_id}/specify")
def specify_task_endpoint(task_id: str, payload: SpecifyBody, board: Optional[str] = Query(None)):
"""Flesh out a triage task via the auxiliary LLM (``hermes kanban specify``). Non-OK is NOT
an HTTP error — the UI renders the reason inline. Sync ``def`` → runs in the threadpool."""
outcome = _run_aux(board, "kanban_specify", "specify_task", task_id, payload.author)
return {"ok": bool(outcome.ok), "task_id": outcome.task_id, "reason": outcome.reason, "new_title": outcome.new_title}
class ReassignBody(BaseModel):
profile: Optional[str] = None # "" or None = unassign
reclaim_first: bool = False
reason: Optional[str] = None
@router.post("/tasks/{task_id}/reassign")
def reassign_task_endpoint(task_id: str, payload: ReassignBody, board: Optional[str] = Query(None)):
"""Reassign to another profile, optionally reclaiming first
(``hermes kanban reassign <task_id> <profile> [--reclaim]``)."""
with _board_conn(board) as (board, conn):
ok = kanban_db.reassign_task(
conn, task_id, payload.profile or None, reclaim_first=bool(payload.reclaim_first), reason=payload.reason)
if not ok:
raise _conflict(
f"cannot reassign {task_id}: unknown id, or still "
"running (pass reclaim_first=true to release the claim first)")
return {"ok": True, "task_id": task_id, "assignee": payload.profile or None}
# Estimate: rough token/complexity read via the auxiliary model. NOT a dollar cost.
_ESTIMATE_SYSTEM_PROMPT = (
"You estimate how much work an autonomous coding agent will spend on a "
"kanban task. Given the task title and description, respond with STRICT "
"JSON only (no prose, no code fence):\n"
'{"est_tokens": <integer total tokens across the whole run>, '
'"complexity": "S"|"M"|"L", '
'"rationale": "<one short sentence>"}\n'
"Base the token figure on a realistic multi-turn agent run (reading files, "
"tool calls, edits, retries) — not a single reply. S≈small/localized, "
"M≈multi-file, L≈broad or ambiguous. Be honest that this is a rough guess.")
class EstimateBody(BaseModel):
title: str = ""
body: Optional[str] = None
@router.post("/estimate")
def estimate_text_endpoint(payload: EstimateBody):
"""Estimate from raw title/body (create dialog, before a task exists)."""
return _run_estimate(payload.title, payload.body, task_id=None)
@router.post("/tasks/{task_id}/estimate")
def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)):
"""Estimate for an existing task; ``{ok, est_tokens, complexity, rationale, model}``."""
with _board_conn(board) as (board, conn):
task = _require_task(conn, task_id)
return _run_estimate(task.title, task.body, task_id=task_id)
def _cap(s: Optional[str], n: int) -> str:
s = (s or "").strip()
return s if len(s) <= n else s[:n] + "…"
def _run_estimate(title: str, body: Optional[str], *, task_id: Optional[str]) -> dict:
"""Never raises — config/parse/API errors become ``{"ok": False, "reason"}`` so the UI renders them inline."""
if not (title or "").strip():
return {"ok": False, "reason": "a title is required to estimate"}
try:
from agent.auxiliary_client import call_llm
except Exception:
return {"ok": False, "reason": "auxiliary client unavailable"}
user_msg = f"Title: {_cap(title, 400)}\n\nDescription:\n{_cap(body, 4000) or '(none)'}"
# Headless like specify/decompose's _call_aux: without a bound affinity scope the relay-affinity
# headers are omitted and the OpenCode Go relay answers 400 MissingSessionID (#112043). The
# create dialog has no task yet, so it shares one stable key.
from agent.portal_tags import get_affinity_scope, reset_affinity_scope, set_affinity_scope
affinity_token = None if get_affinity_scope() else set_affinity_scope(f"kanban:{task_id or 'estimate'}")
try:
resp = call_llm(
task="kanban_estimator",
messages=[{"role": "system", "content": _ESTIMATE_SYSTEM_PROMPT}, {"role": "user", "content": user_msg}],
temperature=0.0, max_tokens=300, timeout=60)
except Exception as exc:
return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"}
finally:
if affinity_token is not None:
reset_affinity_scope(affinity_token)
try:
raw = (resp.choices[0].message.content or "").strip()
model = getattr(resp, "model", None)
except Exception:
raw, model = "", None
# Same tolerant JSON-blob extraction the specifier uses.
try:
m = None if raw.lstrip().startswith("{") else re.search(r"\{.*\}", raw, re.DOTALL)
obj = json.loads(m.group(0) if m else raw)
parsed = obj if isinstance(obj, dict) else None
except Exception:
parsed = None
if not parsed:
return {"ok": False, "reason": "could not parse an estimate from the model"}
try:
est_tokens = int(parsed.get("est_tokens") or 0)
except (TypeError, ValueError):
est_tokens = 0
complexity = str(parsed.get("complexity") or "").strip().upper()
return {
"ok": True, "est_tokens": est_tokens, "complexity": complexity if complexity in {"S", "M", "L"} else None,
"rationale": str(parsed.get("rationale") or "").strip() or None, "model": model}
# --- Plugin config ----------------------------------------------------------
def _load_config_or_empty() -> dict:
try:
from hermes_cli.config import load_config
return load_config() or {}
except Exception:
return {}
@router.get("/config")
def get_config():
"""Kanban dashboard preferences from the ``dashboard.kanban`` config section."""
k_cfg = (_load_config_or_empty().get("dashboard") or {}).get("kanban") or {}
return {
"default_tenant": k_cfg.get("default_tenant") or "",
"lane_by_profile": bool(k_cfg.get("lane_by_profile", True)),
"include_archived_by_default": bool(k_cfg.get("include_archived_by_default", False)),
"render_markdown": bool(k_cfg.get("render_markdown", True))}
# --- Home-channel subscriptions (per-task, per-platform toggles) -------------
# Each gateway platform has at most one "home" (chat_id, thread_id, name); a toggle-on writes
# exactly the notify_subs row ``/kanban create`` would, so the gateway notifier needs no plumbing.
def _configured_home_channels() -> list[dict]:
"""Every platform with a home_channel, from the live GatewayConfig (so env overlays
like ``TELEGRAM_HOME_CHANNEL`` are honored), sorted by platform."""
try:
from gateway.config import load_gateway_config
gw_cfg = load_gateway_config()
except Exception:
return []
result = [
{"platform": platform.value, "chat_id": pcfg.home_channel.chat_id,
"thread_id": pcfg.home_channel.thread_id or "", "name": pcfg.home_channel.name or "Home"}
for platform, pcfg in gw_cfg.platforms.items() if pcfg and pcfg.home_channel]
result.sort(key=lambda r: r["platform"])
return result
def _active_profile_name() -> str:
"""Current Hermes profile name for notify-sub ownership."""
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
except Exception:
return "default"
def _home_for_platform(platform: str, detail: str) -> dict:
home = next((h for h in _configured_home_channels() if h["platform"] == platform), None)
if not home:
raise HTTPException(status_code=404, detail=detail)
return home
@router.get("/home-channels")
def get_home_channels(task_id: Optional[str] = Query(None), board: Optional[str] = Query(None)):
"""Every platform with a home channel plus whether *task_id* (if given) is
subscribed to it; without ``task_id`` every ``subscribed`` is false."""
homes = _configured_home_channels()
subscribed_homes: set[tuple[str, str, str]] = set()
if task_id:
with _board_conn(board) as (board, conn):
subs = kbn.list_notify_subs(conn, task_id)
subscribed_homes = {
(str(sub.get("platform") or ""), str(sub.get("chat_id") or ""), str(sub.get("thread_id") or "")) for sub in subs}
return {"home_channels": [
{**home, "subscribed": (home["platform"], home["chat_id"], home["thread_id"]) in subscribed_homes} for home in homes]}
@router.post("/tasks/{task_id}/home-subscribe/{platform}")
def subscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)):
"""Subscribe *task_id* to *platform*'s home channel. Idempotent at the DB
layer; 404 when the platform has no home or the task doesn't exist."""
home = _home_for_platform(
platform,
f"No home channel configured for platform {platform!r}. "
f"Set one from the messenger via /sethome, or configure "
f"gateway.platforms.{platform}.home_channel in config.yaml.")
with _board_conn(board) as (board, conn):
_require_task(conn, task_id)
kbn.add_notify_sub(
conn, task_id=task_id, platform=platform, chat_id=home["chat_id"],
thread_id=home["thread_id"] or None, notifier_profile=_active_profile_name())
return {"ok": True, "task_id": task_id, "home_channel": home}
@router.delete("/tasks/{task_id}/home-subscribe/{platform}")
def unsubscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)):
"""Remove any notify subscription on *task_id* matching *platform*'s home."""
home = _home_for_platform(platform, f"No home channel configured for platform {platform!r}.")
with _board_conn(board) as (board, conn):
kbn.remove_notify_sub(
conn, task_id=task_id, platform=platform, chat_id=home["chat_id"], thread_id=home["thread_id"] or None)
return {"ok": True, "task_id": task_id, "home_channel": home}
# --- Stats / assignees / worker log / dispatch / model options ---------------
@router.get("/stats")
def get_stats(board: Optional[str] = Query(None)):
"""Per-status + per-assignee counts + oldest-ready age (HUD and router profiles)."""
with _board_conn(board) as (board, conn):
return kanban_db.board_stats(conn)
@router.get("/assignees")
def get_assignees(board: Optional[str] = Query(None)):
"""Union of on-disk profiles and assignees used on the board, so a fresh
profile appears in the picker before it has any task."""
with _board_conn(board) as (board, conn):
return {"assignees": kanban_db.known_assignees(conn)}
@router.get("/tasks/{task_id}/log")
def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_000), board: Optional[str] = Query(None)):
"""Worker stdout/stderr log. ``tail`` caps the response bytes; 404 if the
task never spawned. On-disk log rotates at 2 MiB with one ``.log.1`` kept."""
with _board_conn(board) as (board, conn):
_require_task(conn, task_id)
content = kanban_db.read_worker_log(task_id, tail_bytes=tail, board=board)
log_path = kanban_db.worker_log_path(task_id, board=board)
size = log_path.stat().st_size if log_path.exists() else 0
return {
"task_id": task_id, "path": str(log_path), "exists": content is not None,
"size_bytes": size, "content": content or "", "truncated": bool(tail and size > tail)}
@router.post("/dispatch")
def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max"), board: Optional[str] = Query(None)):
"""Dispatch nudge so the UI doesn't wait out the 60 s dispatcher tick."""
with _board_conn(board) as (board, conn):
result = kbd.dispatch_once(conn, dry_run=dry_run, max_spawn=max_n, board=board)
try:
return asdict(result) # DispatchResult is a dataclass
except TypeError:
return {"result": str(result)}
@router.get("/model-options")
def model_options():
"""Providers + curated models for the override dropdown via ``inventory.build_models_payload``
(same substrate as the Models page) so it can't offer a pair Hermes rejects. Skips pricing
and custom-provider probes: a slow/offline local endpoint must not hang the drawer."""
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
payload = build_models_payload(
load_picker_context(), explicit_only=True, canonical_order=True, probe_custom_providers=False)
return {
"providers": [
{"slug": row.get("slug", ""), "label": row.get("label") or row.get("slug", ""),
"models": list(row.get("models") or [])}
for row in payload.get("providers", [])
if row.get("models")]}
except Exception:
log.exception("kanban model-options failed")
return {"providers": []} # empty catalog → the UI falls back to a free-text input
# --- Boards CRUD (multi-project support) --------------------------------------
class CreateBoardBody(BaseModel):
slug: str
name: Optional[str] = None
description: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
default_workdir: Optional[str] = None
# Project (id or slug) scoping the board: default_workdir mirrors its primary repo, tasks inherit it.
project_id: Optional[str] = None
switch: bool = False
class RenameBoardBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
# For both fields: ``None`` = leave unchanged; "" = clear; value = validate/resolve + set.
default_workdir: Optional[str] = None
project_id: Optional[str] = None
# Board transfer exchanges filesystem PATHS, not bytes (same contract as profile export/import):
# clients run the native save/open dialog on the machine hosting the backend.
class ExportBoardBody(BaseModel):
output: str = "" # empty → staging path under the kanban root
attachments: bool = True
logs: bool = False
class ImportBoardBody(BaseModel):
archive: str # path to a board .tar.gz on the backend's filesystem
slug: Optional[str] = None # override the archive's slug; collisions auto-suffix
switch: bool = False
def _board_display_kwargs(p: BaseModel) -> dict[str, Any]:
"""Display-metadata fields shared by create_board / write_board_metadata."""
return {"name": p.name, "description": p.description, "icon": p.icon, "color": p.color}
def _resolve_project(ref: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve a project id/slug to ``(id, name, primary_path)``; ``(None,)*3``
for a falsy ref, 400 when a non-empty ref doesn't resolve."""
if not ref or not ref.strip():
return None, None, None
with _errors_to_500("projects unavailable"):
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
proj = pdb.get_project(pconn, ref.strip())
if proj is None:
raise HTTPException(status_code=400, detail=f"project {ref!r} does not exist")
return proj.id, proj.name, (proj.primary_path or None)
def _projects_by_id() -> dict[str, Any]:
"""Map every project id -> Project (archived included) for annotation."""
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
return {p.id: p for p in pdb.list_projects(pconn, include_archived=True)}
except Exception:
return {}
def _board_counts(slug: str) -> dict[str, int]:
"""``{status: count}`` for a board; ``{}`` on a missing/empty DB."""
try:
if not kanban_db.kanban_db_path(board=slug).exists():
return {}
with closing(kbc.connect(board=slug)) as conn:
rows = conn.execute("SELECT status, COUNT(*) AS n FROM tasks GROUP BY status").fetchall()
return {r["status"]: int(r["n"]) for r in rows}
except Exception:
return {}
def _default_workspace_kind(board: dict[str, Any]) -> str:
"""Recommend a non-destructive task workspace from board metadata."""
workdir = str(board.get("default_workdir") or "").strip()
if not workdir:
return "scratch"
try:
return "worktree" if kbw._git_toplevel(Path(workdir)) else "dir"
except (OSError, ValueError):
return "dir"
def _annotate_board_meta(meta: dict) -> dict:
meta["default_workspace_kind"] = _default_workspace_kind(meta)
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
return meta
@router.get("/projects")
def list_kanban_projects():
"""Live (non-archived) projects available for board scoping."""
with _errors_to_500("failed to list projects"):
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
projects = pdb.list_projects(pconn, include_archived=False)
return {"projects": [
{"id": p.id, "slug": p.slug, "name": p.name,
"primary_path": p.primary_path or "", "icon": p.icon or "", "color": p.color or ""}
for p in projects]}
@router.get("/boards")
def list_boards(include_archived: bool = Query(False)):
"""Every board on disk with task counts and the active slug."""
boards = kanban_db.list_boards(include_archived=include_archived)
current = kanban_db.get_current_board()
proj_map = _projects_by_id()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
# Live cards only — archived tasks are hidden from every default board view,
# so counting them in the switcher badge would visibly disagree.
b["total"] = sum(n for status, n in b["counts"].items() if status != "archived")
b["default_workspace_kind"] = _default_workspace_kind(b)
pid = b["project_id"] = b.get("project_id") or None
proj = proj_map.get(pid) if pid else None
b["project_name"] = proj.name if proj else None
return {"boards": boards, "current": current}
def _validate_workdir(raw: str) -> str:
"""Board default_workdir must be an absolute, existing directory (400 otherwise)."""
requested = Path(raw).expanduser()
if not requested.is_absolute():
raise HTTPException(status_code=400, detail="Project directory must be an absolute path.")
if not requested.is_dir():
raise HTTPException(status_code=400, detail="Project directory must be an existing directory.")
return str(requested.resolve())
@router.post("/boards")
def create_board_endpoint(payload: CreateBoardBody):
"""Create a board. Idempotent — ``slug`` collision returns the existing one."""
default_workdir = _validate_workdir(payload.default_workdir) if payload.default_workdir else None
# A chosen project's primary repo becomes the default workdir unless one was passed explicitly.
project_id, _pname, primary_path = _resolve_project(payload.project_id)
if primary_path and not default_workdir:
default_workdir = primary_path
with _value_error_400():
meta = kanban_db.create_board(
payload.slug, default_workdir=default_workdir, project_id=project_id, **_board_display_kwargs(payload))
if payload.switch:
with _value_error_400():
kanban_db.set_current_board(meta["slug"])
return {"board": _annotate_board_meta(meta), "current": kanban_db.get_current_board()}
@router.patch("/boards/{slug}")
def rename_board(slug: str, payload: RenameBoardBody):
"""Update display metadata / default workdir / project scope (slug is immutable)."""
normed = _existing_board_slug(slug)
# write_board_metadata treats a falsy value as "clear", so pass "" through.
default_workdir: Optional[str] = None
if payload.default_workdir is not None:
raw = payload.default_workdir.strip()
default_workdir = _validate_workdir(raw) if raw else ""
# A resolved project mirrors its repo into default_workdir unless the caller set it explicitly.
project_id: Optional[str] = None
if payload.project_id is not None:
if payload.project_id.strip():
project_id, _pname, primary_path = _resolve_project(payload.project_id)
if primary_path and default_workdir is None:
default_workdir = primary_path
else:
project_id = "" # clear the scope
meta = kanban_db.write_board_metadata(
normed, default_workdir=default_workdir, project_id=project_id, **_board_display_kwargs(payload))
return {"board": _annotate_board_meta(meta)}
@router.delete("/boards/{slug}")
def delete_board(slug: str, delete: bool = Query(False, description="Hard-delete instead of archive")):
"""Archive (default) or hard-delete a board."""
with _value_error_400():
res = kanban_db.remove_board(slug, archive=not delete)
return {"result": res, "current": kanban_db.get_current_board()}
async def _run_transfer(fn, log_label: str):
"""Run a blocking kanban_transfer call off the event loop, mapping its errors
to 404 (missing path) / 400 (invalid) / 500 (logged)."""
try:
return await asyncio.get_running_loop().run_in_executor(None, fn)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
log.exception("%s failed", log_label)
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/boards/{slug}/export")
async def export_board_endpoint(slug: str, body: ExportBoardBody):
"""Write ``slug`` to a portable archive; return the path written."""
from hermes_cli import kanban_transfer
output = (body.output or "").strip()
if not output:
staging = kanban_db.kanban_home() / "kanban" / "board-exports"
try:
staging.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not create export directory: {exc}")
output = str(staging / f"{slug}-{time.strftime('%Y%m%d-%H%M%S')}.tar.gz")
return await _run_transfer(
lambda: kanban_transfer.export_board(slug, output, include_attachments=body.attachments, include_logs=body.logs),
f"POST /boards/{slug}/export")
@router.post("/boards/import")
async def import_board_endpoint(body: ImportBoardBody):
"""Import a board archive as a NEW board; return the landed board."""
from hermes_cli import kanban_transfer
archive = (body.archive or "").strip()
if not archive:
raise HTTPException(status_code=400, detail="archive path is required")
result = await _run_transfer(
lambda: kanban_transfer.import_board(archive, (body.slug or "").strip() or None, activate=body.switch),
"POST /boards/import")
return {**result, "current": kanban_db.get_current_board()}
@router.post("/boards/{slug}/switch")
def switch_board(slug: str):
"""Persist ``slug`` as the active board for CLI / slash-command parity
(dashboard users pick boards client-side via localStorage)."""
normed = _existing_board_slug(slug)
kanban_db.set_current_board(normed)
return {"current": normed}
# --- Profile metadata & description editing (kanban orchestrator) ------------
class DescribeBody(BaseModel):
description: Optional[str] = None # explicit user-authored text
class DescribeAutoBody(BaseModel):
overwrite: bool = False
@router.get("/profiles")
def list_profile_roster():
"""Every installed profile with its description (profiles without one are
still routable on name alone, just less precisely)."""
with _errors_to_500("failed to list profiles"):
from hermes_cli import profiles as profiles_mod
profiles = profiles_mod.list_profiles()
return {"profiles": [
{"name": p.name, "is_default": bool(p.is_default), "model": p.model or "", "provider": p.provider or "",
"description": p.description or "", "description_auto": bool(p.description_auto),
"skill_count": int(p.skill_count or 0)}
for p in profiles]}
@router.patch("/profiles/{profile_name}")
def update_profile_description(profile_name: str, payload: DescribeBody):
"""Set (``description_auto: false`` so the auto-describer won't overwrite it
without ``--overwrite``) or clear (empty string) a profile's description."""
with _errors_to_500("failed to update profile"):
from hermes_cli import profiles as profiles_mod
canon = profiles_mod.normalize_profile_name(profile_name)
if canon == "default":
from hermes_constants import get_hermes_home # type: ignore
profile_dir = Path(get_hermes_home())
else:
profile_dir = profiles_mod.get_profile_dir(canon)
if not profile_dir.is_dir():
raise HTTPException(status_code=404, detail=f"profile '{profile_name}' not found")
text = (payload.description or "").strip()
profiles_mod.write_profile_meta(profile_dir, description=text, description_auto=False)
return {"ok": True, "profile": canon, "description": text}
@router.post("/profiles/{profile_name}/describe-auto")
def auto_describe_profile(profile_name: str, payload: DescribeAutoBody):
"""``hermes profile describe <name> --auto``: persist with ``description_auto: true``.
Non-OK outcomes are NOT HTTP errors — the UI renders the reason inline."""
with _errors_to_500("describer crashed"):
from hermes_cli import profile_describer
outcome = profile_describer.describe_profile(profile_name, overwrite=bool(payload.overwrite))
return {"ok": bool(outcome.ok), "profile": outcome.profile_name, "reason": outcome.reason, "description": outcome.description}
# --- Decompose (built-in decomposer fan-out) ----------------------------------
class DecomposeBody(BaseModel):
author: Optional[str] = None
@router.post("/tasks/{task_id}/decompose")
def decompose_task_endpoint(task_id: str, payload: DecomposeBody, board: Optional[str] = Query(None)):
"""Fan a triage task out into child tasks via the auxiliary LLM (``hermes kanban decompose``).
Non-OK is NOT an HTTP error. Sync ``def`` → runs in the threadpool."""
outcome = _run_aux(board, "kanban_decompose", "decompose_task", task_id, payload.author)
return {
"ok": bool(outcome.ok), "task_id": outcome.task_id, "reason": outcome.reason,
"fanout": bool(outcome.fanout), "child_ids": outcome.child_ids or [], "new_title": outcome.new_title}
# --- Orchestration settings (kanban.orchestrator_profile / default_assignee /
# auto_decompose / auto_promote_children) ----------------------------------
class OrchestrationSettingsBody(BaseModel):
orchestrator_profile: Optional[str] = None
default_assignee: Optional[str] = None
auto_decompose: Optional[bool] = None
auto_promote_children: Optional[bool] = None
_PROFILE_SETTINGS = ("orchestrator_profile", "default_assignee")
@router.get("/orchestration")
def get_orchestration_settings():
"""Current orchestration knobs from config.yaml plus the resolved effective
values (fallbacks filled the same way the decomposer does)."""
cfg = _load_config_or_empty()
kanban_cfg = (cfg.get("kanban") or {}) if isinstance(cfg, dict) else {}
explicit = {k: (kanban_cfg.get(k) or "").strip() for k in _PROFILE_SETTINGS}
resolved = dict(explicit)
try:
from hermes_cli import profiles as profiles_mod
active_default = profiles_mod.get_active_profile_name() or "default"
for k, v in explicit.items():
if not v or not profiles_mod.profile_exists(v):
resolved[k] = active_default
except Exception:
active_default = "default"
resolved = {k: v or active_default for k, v in resolved.items()}
return {
"orchestrator_profile": explicit["orchestrator_profile"],
"default_assignee": explicit["default_assignee"],
"auto_decompose": bool(kanban_cfg.get("auto_decompose", True)),
"auto_promote_children": bool(kanban_cfg.get("auto_promote_children", True)),
"resolved_orchestrator_profile": resolved["orchestrator_profile"],
"resolved_default_assignee": resolved["default_assignee"],
"active_profile": active_default}
def _validated_profile_name(raw: Optional[str], profiles_mod) -> str:
"""Strip a profile name; 400 if non-empty and unknown. Fails open when the lookup itself errors."""
name = (raw or "").strip()
if name and profiles_mod is not None:
try:
exists = profiles_mod.profile_exists(name)
except Exception:
exists = True
if not exists:
raise HTTPException(status_code=400, detail=f"profile '{name}' does not exist")
return name
@router.put("/orchestration")
def set_orchestration_settings(payload: OrchestrationSettingsBody):
"""Update orchestration knobs in config.yaml. Only fields explicitly passed
are written; empty profile strings clear the override."""
with _errors_to_500("failed to load config"):
from hermes_cli.config import load_config, save_config
cfg = load_config() or {}
kanban_section = cfg.setdefault("kanban", {})
if not isinstance(kanban_section, dict):
kanban_section = cfg["kanban"] = {}
try:
from hermes_cli import profiles as profiles_mod
except Exception:
profiles_mod = None # type: ignore
# Field order == write order (profiles validated first, then the booleans).
for key, value in payload.model_dump(exclude_none=True).items():
kanban_section[key] = _validated_profile_name(value, profiles_mod) if key in _PROFILE_SETTINGS else bool(value)
with _errors_to_500("failed to save config"):
save_config(cfg)
return get_orchestration_settings() # callers re-render from the resolved state
# --- WebSocket: /events?since=<event_id>&board=<slug> ------------------------
# Event tail poll interval: WAL + 300 ms polling is the simplest robust approach (negligible CPU).
_EVENT_POLL_SECONDS = 0.3
def _int_param(ws: WebSocket, name: str) -> int:
try:
return int(ws.query_params.get(name, "0"))
except ValueError:
return 0
def _ws_board(raw: Optional[str]) -> Optional[str]:
try:
return kanban_db._normalize_board_slug(raw) if raw else None
except ValueError:
return None
class _EventTail:
"""Per-socket ``task_events`` tailer. One SQLite connection, used/closed only on a
dedicated single-thread executor (connections are thread-affine); reusing it avoids
churning WAL/SHM sidecars while an idle dashboard polls."""
def __init__(self, board: Optional[str]) -> None:
self._board = board
self._conn: Optional[sqlite3.Connection] = None
self._executor: Optional[ThreadPoolExecutor] = None
def _fetch(self, cursor: int) -> tuple[int, list[dict]]:
if self._conn is None:
self._conn = kbc.connect(board=self._board)
rows = self._conn.execute(
"SELECT id, task_id, run_id, kind, payload, created_at "
"FROM task_events WHERE id > ? ORDER BY id ASC LIMIT 200",
(cursor,)).fetchall()
out: list[dict] = []
for r in rows:
try:
payload = json.loads(r["payload"]) if r["payload"] else None
except Exception:
payload = None
out.append({**dict(r), "payload": payload})
return (rows[-1]["id"] if rows else cursor), out
def _close(self) -> None:
if self._conn is not None:
self._conn.close()
self._conn = None
async def poll(self, cursor: int) -> tuple[int, list[dict]]:
if self._executor is None:
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="kanban-events")
return await asyncio.get_running_loop().run_in_executor(self._executor, self._fetch, cursor)
async def shutdown(self) -> None:
if self._executor is None:
return
try:
await asyncio.get_running_loop().run_in_executor(self._executor, self._close)
except Exception as exc:
log.warning("Kanban event stream connection cleanup failed: %s", exc)
finally:
self._executor.shutdown(wait=True, cancel_futures=True)
@router.websocket("/events")
async def stream_events(ws: WebSocket):
if not _ws_upgrade_authorized(ws):
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
return
await ws.accept()
# Board is pinned at the handshake; the UI opens a new WS on board change
# rather than reconciling two cursors mid-stream.
tail = _EventTail(_ws_board(ws.query_params.get("board")))
cursor = _int_param(ws, "since")
try:
while True:
# Race receive() against the poll interval so a disconnect is detected even when no
# events flow (else idle boards leak poll tasks). Other client messages are ignored.
try:
msg = await asyncio.wait_for(ws.receive(), timeout=_EVENT_POLL_SECONDS)
if msg["type"] == "websocket.disconnect":
return
except asyncio.TimeoutError:
pass # no client message — poll the DB
cursor, events = await tail.poll(cursor)
if events:
await ws.send_json({"events": events, "cursor": cursor})
except WebSocketDisconnect:
return
except asyncio.CancelledError:
return # normal shutdown; CancelledError is a BaseException the handler below wouldn't quiet
except Exception as exc: # never crash the dashboard worker
log.warning("Kanban event stream error: %s", exc)
try:
await ws.close()
except Exception:
pass
finally:
await tail.shutdown()